mind-heartbeat: one heartbeat routes every Persona turn on an initiative timeline; the classifier's urgency multiplies regain, an operator mention fills, a Self-run completion reaches the classifier and never fills; burn is real spend read as the heartbeat; each Persona on her own Huginn; DM rooms answered alone; the mind-wake path deleted (squash of eureka-body/mind-heartbeat d54ba01..3a4d28f; Soul s1-s4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-30 02:49:35 +01:00
commit 7db86f7dd1
33 changed files with 3535 additions and 1308 deletions

View file

@ -40,7 +40,7 @@ edits `.cc` state. Huginn reads and writes minds.
## Layout
A Cargo workspace of five crates (`crates/eureka-pipeline` holds the document shapes and keys, `crates/mind-body` the control store, launch primitive and `mind-wake` one-shot; the three below are the mind, the daemon and the client):
A Cargo workspace of five crates (`crates/eureka-pipeline` holds the document shapes and keys, `crates/mind-body` the control store, launch primitive and `mind-heartbeat` timeline; the three below are the mind, the daemon and the client):
- `crates/huginn-mind`: storage, identity, admission, and queries and derived
status over memory documents.

View file

@ -10,11 +10,11 @@ publish.workspace = true
# store (`control`), the operator's CLI over it (`cli`, the `mind-control`
# binary) and the one fail-closed reader every acting organ calls
# (`control::read_effective`). It also holds the launch primitive (`launch`,
# `queue`): the one way her organs open a run and start its unit. The waker
# (`wake`, the `mind-wake` binary) is one cycle over them. The actuator
# `queue`): the one way her organs open a run and start its unit. The heartbeat
# (`heartbeat`, the `mind-heartbeat` binary) runs the initiative timeline over them. The actuator
# (`self_run`, the `mind-self-run` binary) carries her one open Self run.
#
# `rust_decimal` is the dial's number type: heat, cadence and the run cap are
# `rust_decimal` is the dial's number type: heat, the spend rate and the run cap are
# money-adjacent and the tests pin exact products, which a float would blur. It
# serialises as a string, so the stored document carries no float.
#
@ -72,10 +72,6 @@ path = "src/lib.rs"
name = "mind-control"
path = "src/main.rs"
[[bin]]
name = "mind-wake"
path = "src/bin/mind-wake.rs"
[[bin]]
name = "mind-self-run"
path = "src/bin/mind-self-run.rs"
@ -83,3 +79,7 @@ path = "src/bin/mind-self-run.rs"
[[bin]]
name = "mind-persona"
path = "src/bin/mind-persona.rs"
[[bin]]
name = "mind-heartbeat"
path = "src/bin/mind-heartbeat.rs"

View file

@ -1,7 +1,7 @@
# mind-body
The Mind's body: the control store, the launch primitive, the `mind-wake`
one-shot and the `mind-self-run` actuator.
The Mind's body: the control store, the launch primitive, the `mind-heartbeat`
timeline and the `mind-self-run` actuator.
`mind-self-run --instance <slug> [--endpoint rudp://<ip>:<port>]` runs inside
`mind-self@<instance>`. It finds her one open Self run, starts `claude` over
@ -10,28 +10,9 @@ ceiling and stops a child that outlives the flush (SIGINT, then SIGTERM and SIGK
otherwise, 3 when she has no single open Self run and 2 when the arguments are
wrong or the mind cannot be reached.
## The container lane
## The heartbeat
`tests/wake.rs` holds one `#[ignore]`d test that runs the real `mind-wake`
binary against the real `huginn-daemon` and a real store at the brake's root,
and asserts that the host a run records is the kernel's hostname. It writes
`/etc/gamecult/minds`, so it runs in a container only (`/.dockerenv`), as root,
with the daemon built beside the binary. A plain `cargo test` skips it, and so
does `cargo mutants`, which therefore reports the three `host()` mutants in
`src/bin/mind-wake.rs` as missed. The lane that kills them:
```sh
cargo build -p huginn-daemon -p mind-body --all-targets \
&& cargo test -p mind-body --test wake -- --include-ignored
```
On Yggdrasil, run it through the verify stopgap in the Rust image
(`ygg-verify.sh <worktree> <rev> rust '<the command above>'`).
Mutation testing of the waker needs the glob form, because `-f src/wake.rs`
finds no mutants in this workspace: `cargo mutants -p mind-body -f
"**/src/wake.rs" -f "**/src/bin/mind-wake.rs"`. The second glob reaches
`host()`, whose mutants only the container lane kills.
`mind-heartbeat` is the one process that decides who wakes and when: acting burns initiative by spend, the classifier's urgency multiplies how fast it regains, and the operator tagging or naming a Persona fills it. `mind-heartbeat timeline` prints its latest record.
The actuator has its own ignored test, `the_real_bin_finds_her_run_...` in
`tests/self_run.rs`: the real `mind-self-run` against the real daemon with a

View file

@ -0,0 +1,182 @@
//! `mind-heartbeat [timeline]`: the swarm's initiative timeline.
//!
//! Without an argument it runs forever: one `mind_body::heartbeat::poll` every
//! `HEARTBEAT_POLL_S` seconds (default 20), printing each outcome; a poll that
//! cannot reach its mind, mouth or store prints the error and the loop goes on.
//! `timeline` prints the latest trace record and exits. This reads the process's
//! arguments, environment and credential files, the things the library never
//! reads. Key files are read here and never printed.
//!
//! Environment: `HEARTBEAT_PERSONAS` (`slug,handle,Public Name,@matrix:id,rudp://<ip>:<port>`
//! entries separated by `;`, the last field her own Huginn daemon: each Mind is
//! its own daemon), `MIND_PERSONA_OPERATOR` (id[,id]),
//! `HEARTBEAT_CLASSIFIER_CONFIG` (the classifier tunables document, default
//! `classifier.cc` under `STATE_DIRECTORY`; absent means the standard one), `HEARTBEAT_POLL_S`,
//! `BIFROST_CHAT_ENDPOINT` and `BIFROST_CHAT_KEY_FILE`,
//! `MIND_CONNECTOR_ENDPOINT`, `MIND_CONNECTOR_KEY_FILE` and
//! `HEARTBEAT_CLASSIFIER_MODEL` (the cheapest model), and `STATE_DIRECTORY`
//! (`heartbeat.cc` lives there).
use std::net::SocketAddr;
use std::path::Path;
use std::time::Duration;
use anyhow::{Context, Result, anyhow, bail};
use eureka_state::HuginnClient;
use mind_body::chat_rudp::{RudpChat, connection_id};
use mind_body::connector::{self, KitConnector};
use mind_body::heartbeat::{
CLASSIFIER_CALLER, Config, FileControl, FileStore, FileTunables, Organs, PersonaId, Seat,
Store, poll,
};
use mind_body::launch::{SystemClock, SystemdLauncher};
use mind_body::queue::HuginnMind;
const TIMEOUT: Duration = Duration::from_secs(15);
const CHAT_TIMEOUT: Duration = Duration::from_secs(20);
fn host() -> String {
std::fs::read_to_string("/proc/sys/kernel/hostname")
.map(|name| name.trim().to_string())
.ok()
.filter(|name| !name.is_empty())
.unwrap_or_else(|| "unknown".into())
}
#[allow(
clippy::disallowed_methods,
reason = "the binary reads its own environment, and hands it to the library"
)]
fn setting(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|value| !value.is_empty())
}
fn required(name: &str) -> Result<String> {
setting(name).ok_or_else(|| anyhow!("{name} is not set"))
}
fn credential(name: &str) -> Result<String> {
std::fs::read_to_string(required(name)?)
.with_context(|| format!("the file named by {name} could not be read"))
}
fn rudp(name: &str) -> Result<SocketAddr> {
required(name)?
.strip_prefix("rudp://")
.and_then(|address| address.parse().ok())
.ok_or_else(|| anyhow!("{name} is not rudp://<ip>:<port>"))
}
fn whole(name: &str, default: i64) -> Result<i64> {
setting(name).map_or(Ok(default), |value| {
value
.parse()
.map_err(|_| anyhow!("{name} is not a whole number"))
})
}
fn config() -> Result<Config> {
let personas = required("HEARTBEAT_PERSONAS")?
.split(';')
.filter(|spec| !spec.trim().is_empty())
.map(PersonaId::parse)
.collect::<Result<Vec<_>>>()?;
let operators = required("MIND_PERSONA_OPERATOR")?
.split(',')
.map(|id| id.trim().to_string())
.filter(|id| !id.is_empty())
.collect();
Config::new(personas, operators)
}
fn store() -> Result<FileStore> {
let Some(state) = setting("STATE_DIRECTORY") else {
bail!("no STATE_DIRECTORY")
};
Ok(FileStore::new(Path::new(&state).join("heartbeat.cc")))
}
fn timeline() -> Result<()> {
match store()?.latest()? {
Some(trace) => print!("{trace}"),
None => println!("no poll has been recorded"),
}
Ok(())
}
fn run() -> Result<()> {
let config = config()?;
let store = store()?;
let chat_address = rudp("BIFROST_CHAT_ENDPOINT")?;
let chat_id = connection_id(&credential("BIFROST_CHAT_KEY_FILE")?);
let models = KitConnector::new(
connector::endpoint(&required("MIND_CONNECTOR_ENDPOINT")?)
.context("MIND_CONNECTOR_ENDPOINT must be <ip>:<port> or tcp://<ip>:<port>")?,
&credential("MIND_CONNECTOR_KEY_FILE")?,
required("HEARTBEAT_CLASSIFIER_MODEL")?,
CLASSIFIER_CALLER,
)?;
let poll_s = u64::try_from(whole("HEARTBEAT_POLL_S", 20)?)
.map_err(|_| anyhow!("HEARTBEAT_POLL_S is negative"))?
.max(1);
let minds: Vec<HuginnMind> = config
.personas
.iter()
.map(|who| HuginnMind::new(HuginnClient::new(who.huginn, who.slug.clone(), TIMEOUT)))
.collect();
let chats: Vec<RudpChat> = config
.personas
.iter()
.map(|who| RudpChat::new(chat_address, chat_id, who.slug.0.clone(), CHAT_TIMEOUT))
.collect();
let seats: Vec<Seat> = config
.personas
.iter()
.zip(minds.iter().zip(chats.iter()))
.map(|(who, (mind, chat))| Seat {
who: who.clone(),
mind,
chat,
})
.collect();
let tunables = FileTunables::new(match setting("HEARTBEAT_CLASSIFIER_CONFIG") {
Some(path) => Path::new(&path).to_path_buf(),
None => Path::new(&required("STATE_DIRECTORY")?).join("classifier.cc"),
});
let launcher = SystemdLauncher::new();
let host = host();
let organs = Organs {
seats: &seats,
control: &FileControl,
models: &models,
tunables: &tunables,
launcher: &launcher,
clock: &SystemClock,
host: &host,
store: &store,
};
loop {
match poll(&organs, &config) {
Ok(outcome) => println!("{outcome}"),
Err(error) => eprintln!("mind-heartbeat: {error:#}"),
}
std::thread::sleep(Duration::from_secs(poll_s));
}
}
#[allow(
clippy::disallowed_methods,
reason = "the binary reads its own arguments"
)]
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let result = match args.as_slice() {
[] => run(),
[word] if word == "timeline" => timeline(),
_ => Err(anyhow!("usage: mind-heartbeat [timeline]")),
};
if let Err(error) = result {
eprintln!("mind-heartbeat: {error:#}");
std::process::exit(2);
}
}

View file

@ -26,7 +26,7 @@ use eureka_state::HuginnClient;
use mind_body::chat_rudp::{RudpChat, connection_id};
use mind_body::cli::instance_and_endpoint;
use mind_body::connector::{self, KitConnector};
use mind_body::control::{CONTROL_ROOT, FileSource, control_path};
use mind_body::control::{CONTROL_ROOT, FileSource, control_path, swarm_path};
use mind_body::launch::{SystemClock, SystemdLauncher};
use mind_body::persona::{Config, FileTrace, HuginnPersona, Organs, start};
use mind_body::queue::HuginnMind;
@ -69,7 +69,7 @@ fn run() -> Result<String> {
.and_then(|address| address.parse().ok())
.ok_or_else(|| anyhow!("BIFROST_CHAT_ENDPOINT is not rudp://<ip>:<port>"))?;
let chat = RudpChat::new(chat_endpoint, connection_id(&credential("BIFROST_CHAT_KEY_FILE")?), instance.0.clone(), CHAT_TIMEOUT);
let connector = KitConnector::new(connector::endpoint(&required("MIND_CONNECTOR_ENDPOINT")?)?, &credential("MIND_CONNECTOR_KEY_FILE")?, required("MIND_CONNECTOR_MODEL")?, instance.0.clone())?;
let connector = KitConnector::new(connector::endpoint(&required("MIND_CONNECTOR_ENDPOINT")?).context("MIND_CONNECTOR_ENDPOINT must be <ip>:<port> or tcp://<ip>:<port>")?, &credential("MIND_CONNECTOR_KEY_FILE")?, required("MIND_CONNECTOR_MODEL")?, instance.0.clone())?;
let source = FileSource::new(control_path(Path::new(CONTROL_ROOT), &instance)?);
let mind = HuginnMind::new(HuginnClient::new(address, instance.clone(), TIMEOUT));
let persona = HuginnPersona::new(HuginnClient::new(address, instance, TIMEOUT));
@ -77,7 +77,8 @@ fn run() -> Result<String> {
let launcher = SystemdLauncher::new();
let host = host();
let organs = Organs { mind: &mind, launcher: &launcher, clock: &SystemClock, host: &host, chat: &chat, connector: &connector, persona: &persona, trace: &trace };
Ok(start(&organs, &source, &config)?.to_string())
let swarm = FileSource::new(swarm_path(Path::new(CONTROL_ROOT)));
Ok(start(&organs, &source, &swarm, &config)?.to_string())
}
fn main() {

View file

@ -1,51 +0,0 @@
//! `mind-wake --instance <slug> [--endpoint rudp://<ip>:<port>]`: one waker
//! cycle for one Mind. All behaviour is `mind_body::wake::cycle`; this reads the
//! process's arguments, environment and clock, the things the library never
//! reads, and prints the outcome. It exits 0 for every outcome and 2 when the
//! mind cannot be reached or the arguments are wrong. Without `--endpoint` the
//! daemon is `HUGINN_ENDPOINT`.
use std::path::Path;
use std::time::Duration;
use anyhow::Result;
use eureka_state::HuginnClient;
use mind_body::cli::instance_and_endpoint;
use mind_body::control::{CONTROL_ROOT, FileSource, control_path};
use mind_body::launch::{Ports, SystemClock, SystemdLauncher};
use mind_body::queue::HuginnMind;
use mind_body::wake::cycle;
/// How long one call to the mind may take.
const TIMEOUT: Duration = Duration::from_secs(15);
const USAGE: &str = "usage: mind-wake --instance <slug> [--endpoint rudp://<ip>:<port>]";
/// The host a run records: the kernel's name for this machine.
fn host() -> String {
std::fs::read_to_string("/proc/sys/kernel/hostname").map(|name| name.trim().to_string()).ok().filter(|name| !name.is_empty()).unwrap_or_else(|| "unknown".into())
}
// The binary is the one place that reads the process: its arguments and the
// daemon's address. The library reads neither (clippy.toml, `#![forbid]` in lib.rs).
#[allow(clippy::disallowed_methods, reason = "the binary reads its own arguments and the daemon's address, and hands them to the library")]
fn run() -> Result<String> {
let args: Vec<String> = std::env::args().skip(1).collect();
let (instance, address) = instance_and_endpoint(&args, std::env::var("HUGINN_ENDPOINT").ok(), USAGE)?;
let source = FileSource::new(control_path(Path::new(CONTROL_ROOT), &instance)?);
let mind = HuginnMind::new(HuginnClient::new(address, instance, TIMEOUT));
let launcher = SystemdLauncher::new();
let host = host();
let ports = Ports { mind: &mind, launcher: &launcher, clock: &SystemClock, host: &host };
Ok(cycle(&ports, &source)?.to_string())
}
fn main() {
match run() {
Ok(outcome) => println!("{outcome}"),
Err(error) => {
eprintln!("mind-wake: {error:#}");
std::process::exit(2);
}
}
}

View file

@ -22,6 +22,7 @@ pub const PENDING: &str = "gamecult.persona.chat_pending.v1";
pub const ACK: &str = "gamecult.persona.chat_ack.v1";
pub const SEND_REQUEST: &str = "gamecult.persona.chat_send_request.v1";
pub const SEND_RECEIPT: &str = "gamecult.persona.chat_send_receipt.v1";
pub const REFUSAL: &str = "gamecult.persona.chat_refusal.v1";
/// One earlier line of a room, delivered with a message for its context.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@ -43,6 +44,10 @@ pub struct ChatDelivery {
pub text: String,
pub at_unix_ms: u64,
pub context: Vec<ContextLine>,
/// The Matrix user ids the event's `m.mentions` names, as the mouth carries them.
pub mentions: Vec<String>,
/// Whether the room is a direct message.
pub is_direct: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@ -63,6 +68,56 @@ pub struct ChatAck {
pub through_seq: u64,
}
/// Why the mouth refused a request, as its typed names (Bifrost
/// `tools/persona-chat-documents.mjs` `refusals`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Refusal {
Brake,
PersonaMismatch,
UnknownRoom,
EmptyText,
SendFailed,
UnknownPersona,
}
impl Refusal {
/// The mouth's spelling: a MessagePack string, whatever the codec does with unit variants.
pub fn name(self) -> &'static str {
match self {
Self::Brake => "Brake",
Self::PersonaMismatch => "PersonaMismatch",
Self::UnknownRoom => "UnknownRoom",
Self::EmptyText => "EmptyText",
Self::SendFailed => "SendFailed",
Self::UnknownPersona => "UnknownPersona",
}
}
}
impl Serialize for Refusal {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.name())
}
}
impl<'de> Deserialize<'de> for Refusal {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let name = String::deserialize(deserializer)?;
[Self::Brake, Self::PersonaMismatch, Self::UnknownRoom, Self::EmptyText, Self::SendFailed, Self::UnknownPersona]
.into_iter()
.find(|refusal| refusal.name() == name)
.ok_or_else(|| serde::de::Error::custom("the mouth named a refusal this side does not know"))
}
}
/// The mouth's typed answer to a request that names a Persona it does not
/// serve: the pending request, the ack and the send alike.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChatRefusal {
pub persona: String,
pub refusal: Refusal,
}
/// A send. `request_id` is the mouth's transaction id, so a repeat of the same
/// id is the same send.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@ -93,7 +148,8 @@ pub fn decode<T: DeserializeOwned>(bytes: &[u8]) -> Result<T> {
/// The Persona organ's chat: what is waiting, what to say, what is done.
pub trait Chat {
/// Deliveries after `after_seq`, oldest first.
/// Deliveries after `after_seq`, oldest first. A `chat_refusal` answer is an
/// error that names the refusal.
fn pending(&self, after_seq: u64) -> Result<Vec<ChatDelivery>>;
fn send(&self, request: &ChatSendRequest) -> Result<ChatSendReceipt>;
/// Every delivery up to and including `through_seq` is answered; the mouth

View file

@ -5,6 +5,7 @@
use super::*;
const REFUSAL_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v1/gamecult.persona.chat_refusal.v1.msgpack");
const ACK_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v1/gamecult.persona.chat_ack.v1.msgpack");
const PENDING_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v1/gamecult.persona.chat_pending.v1.msgpack");
const PENDING_REQUEST_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v1/gamecult.persona.chat_pending_request.v1.msgpack");
@ -35,10 +36,17 @@ fn a_pending_answer_is_the_mouths_bytes_with_its_context() {
text: "hello eureka".into(),
at_unix_ms: 1_761_700_000_000,
context: vec![ContextLine { sender: "@eureka:gamecult.org".into(), text: "earlier".into(), at_unix_ms: 1_761_699_999_000 }],
mentions: vec!["@eureka:gamecult.org".into(), "@nova:gamecult.org".into()],
is_direct: true,
};
golden(PENDING_BYTES, &ChatPending { persona: "eureka".into(), deliveries: vec![delivery] });
}
#[test]
fn a_refusal_is_the_mouths_bytes_and_a_typed_name() {
golden(REFUSAL_BYTES, &ChatRefusal { persona: "ghost".into(), refusal: Refusal::UnknownPersona });
}
#[test]
fn a_send_and_its_receipt_are_the_mouths_bytes() {
golden(

View file

@ -29,8 +29,8 @@ use serde::de::DeserializeOwned;
use sha2::{Digest, Sha256};
use crate::chat_client::{
ACK, Chat, ChatAck, ChatDelivery, ChatPending, ChatPendingRequest, ChatSendReceipt, ChatSendRequest, PENDING, PENDING_REQUEST, SEND_RECEIPT, SEND_REQUEST, decode,
encode,
ACK, Chat, ChatAck, ChatDelivery, ChatPending, ChatPendingRequest, ChatRefusal, ChatSendReceipt, ChatSendRequest, PENDING, PENDING_REQUEST, REFUSAL, SEND_RECEIPT,
SEND_REQUEST, decode, encode,
};
/// The tag every chat document carries; the mouth refuses any other.
@ -95,7 +95,7 @@ impl RudpChat {
/// Puts one document and, when `answer` names a kind and record key, waits for
/// the document the mouth answers it with. Documents of other kinds or keys
/// (a late receipt of an earlier send) are skipped.
fn exchange<T: Serialize>(&self, kind: &str, record_key: &str, document: &T, answer: Option<(&str, &str)>) -> Result<Option<Vec<u8>>> {
fn exchange<T: Serialize>(&self, kind: &str, record_key: &str, document: &T, answer: Option<(&[&str], &str)>) -> Result<Option<(String, Vec<u8>)>> {
let mut slot = self.link.borrow_mut();
if slot.is_none() {
*slot = Some(self.open()?);
@ -117,7 +117,7 @@ impl RudpChat {
};
link.transport.send_schema_message(&message)?;
let deadline = Instant::now() + self.reply_timeout;
let Some((answer_kind, answer_key)) = answer else {
let Some((answer_kinds, answer_key)) = answer else {
// Nothing is answered; the ack is done when the mouth has the packet.
let left = deadline.saturating_duration_since(Instant::now());
link.transport.flush_reliable(left)?;
@ -130,15 +130,15 @@ impl RudpChat {
link.transport.poll_resends()?;
if let Some(CultNetMessage::DocumentPutRaw { document, .. }) = link.transport.receive_schema_message_once()? {
let tagged = document.tags.as_deref() == Some(&[TAG.to_string()][..]);
if tagged && document.schema_id == answer_kind && document.record_key == answer_key {
return Ok(Some(document.payload));
if tagged && answer_kinds.contains(&document.schema_id.as_str()) && document.record_key == answer_key {
return Ok(Some((document.schema_id, document.payload)));
}
}
}
}
fn call<Q: Serialize, A: DeserializeOwned>(&self, kind: &str, key: &str, request: &Q, answer: (&str, &str)) -> Result<A> {
let payload = self.exchange(kind, key, request, Some(answer))?.ok_or_else(|| anyhow!("no answer payload"))?;
let (_, payload) = self.exchange(kind, key, request, Some((&[answer.0][..], answer.1)))?.ok_or_else(|| anyhow!("no answer payload"))?;
decode(&payload)
}
}
@ -146,7 +146,13 @@ impl RudpChat {
impl Chat for RudpChat {
fn pending(&self, after_seq: u64) -> Result<Vec<ChatDelivery>> {
let request = ChatPendingRequest { persona: self.persona.clone(), after_seq };
let pending: ChatPending = self.call(PENDING_REQUEST, &self.persona, &request, (PENDING, &self.persona))?;
let kinds: &[&str] = &[PENDING, REFUSAL];
let (kind, payload) = self.exchange(PENDING_REQUEST, &self.persona, &request, Some((kinds, self.persona.as_str())))?.ok_or_else(|| anyhow!("no answer payload"))?;
if kind == REFUSAL {
let refusal: ChatRefusal = decode(&payload)?;
bail!("the mouth refused the pending request: {}", refusal.refusal.name());
}
let pending: ChatPending = decode(&payload)?;
if pending.persona != self.persona {
bail!("the mouth answered for another Persona");
}

View file

@ -6,7 +6,6 @@
//! mind-control [--root <dir>] --instance <slug> show
//! mind-control --instance <slug> brake hold (also stops her running units)
//! mind-control --instance <slug> brake release
//! mind-control --instance <slug> dial set --heat H --base-cooldown-s S --base-run-usd U
//! mind-control [--root <dir>] --instance <slug> check
//! mind-control [--root <dir>] swarm show
//! mind-control swarm brake hold | release
@ -17,7 +16,7 @@
//! The `swarm` verbs write `<root>/swarm.cc` and stop no unit: the swarm's
//! brake and heat 0 are read by the organs that act on the swarm's behalf.
//!
//! `check` exits 0 only when the brake is released and the dial in bounds; a
//! `check` exits 0 only when the instance brake and the swarm envelope are released; a
//! systemd unit runs it as its `ExecCondition`. It writes nothing.
//!
//! `brake hold` writes the brake held and then, whatever the write's outcome,
@ -35,12 +34,12 @@ use eureka_pipeline::{RunTurn, Slug};
use rust_decimal::Decimal;
use crate::control::{
BurnRate, ControlWriter, Effective, FileSource, Share, SwarmDial, SwarmWriter, control_path, derive, derive_swarm, load_state, load_swarm_state, swarm_path,
ControlWriter, Effective, FileSource, Share, SwarmDial, SwarmWriter, control_path, derive, derive_swarm, load_state, load_swarm_state, swarm_path,
};
use crate::launch::unit;
const USAGE: &str =
"usage: mind-control [--root <dir>] --instance <slug> show | check | brake hold | brake release | dial set --heat H --base-cooldown-s S --base-run-usd U | mind-control [--root <dir>] swarm show | brake hold | brake release | dial set --heat H --base-rate R --base-run U --k K | share set <instance> <weight>";
"usage: mind-control [--root <dir>] --instance <slug> show | check | brake hold | brake release | mind-control [--root <dir>] swarm show | brake hold | brake release | dial set --heat H --base-rate R --base-run U --k K | share set <instance> <weight>";
/// The arguments every organ binary shares: `--instance <slug>` and an optional
/// `--endpoint rudp://<ip>:<port>`, which `env_endpoint` (the binary's reading of
@ -108,7 +107,7 @@ pub fn run(
let words: Vec<&str> = command.iter().map(String::as_str).collect();
match words.as_slice() {
["show"] => show(root, &instance, out),
["check"] => check(&path, &instance, out),
["check"] => check(root, &path, &instance, out),
["brake", "hold"] => {
let written = ControlWriter::open(&path).and_then(|mut writer| writer.set_brake(false, now, set_by));
let stopped = stop_units(&instance);
@ -123,10 +122,6 @@ pub fn run(
}
}
["brake", "release"] => ControlWriter::open(&path)?.set_brake(true, now, set_by),
["dial", "set", flags @ ..] => {
let dial = parse_dial(flags, now, set_by)?;
ControlWriter::open(&path)?.set_dial(dial)
}
_ => bail!("{USAGE}"),
}
}
@ -212,30 +207,6 @@ fn show_swarm(path: &Path, out: &mut impl Write) -> Result<()> {
Ok(())
}
fn parse_dial(flags: &[&str], now: DateTime<Utc>, set_by: &str) -> Result<BurnRate> {
let (mut heat, mut cooldown, mut run_usd) = (None, None, None);
let mut rest = flags.iter();
while let Some(flag) = rest.next() {
let value = rest.next().ok_or_else(|| anyhow!("{flag} needs a value"))?;
let slot = match *flag {
"--heat" => &mut heat,
"--base-cooldown-s" => &mut cooldown,
"--base-run-usd" => &mut run_usd,
other => bail!("unknown flag {other:?}"),
};
if slot.replace(*value).is_some() {
bail!("{flag} given twice");
}
}
Ok(BurnRate {
heat: Decimal::from_str(need(heat, "--heat")?).context("--heat is not a decimal")?,
base_cooldown_s: need(cooldown, "--base-cooldown-s")?.parse().context("--base-cooldown-s is not a whole number of seconds")?,
base_run_usd: Decimal::from_str(need(run_usd, "--base-run-usd")?).context("--base-run-usd is not a decimal")?,
set_at: now,
set_by: set_by.to_string(),
})
}
fn need<'a>(value: Option<&'a str>, name: &str) -> Result<&'a str> {
value.ok_or_else(|| anyhow!("{name} is required"))
}
@ -245,8 +216,8 @@ fn need<'a>(value: Option<&'a str>, name: &str) -> Result<&'a str> {
/// read through `FileSource`: it opens no writer and creates no file or
/// directory. Any other reading is an error, which `main` turns into exit 2,
/// and systemd's `ExecCondition` skips the unit on 1-254.
fn check(path: &Path, instance: &Slug, out: &mut impl Write) -> Result<()> {
let effective = derive(&FileSource::new(path), instance);
fn check(root: &Path, path: &Path, instance: &Slug, out: &mut impl Write) -> Result<()> {
let effective = derive(&FileSource::new(path), &FileSource::new(swarm_path(root)), instance);
writeln!(out, "effective: {effective:?}")?;
match effective {
Effective::Released(_) => Ok(()),
@ -269,21 +240,9 @@ fn show(root: &Path, instance: &Slug, out: &mut impl Write) -> Result<()> {
)?,
None => writeln!(out, "brake: absent")?,
}
match &state.dial {
Some(dial) => writeln!(
out,
"dial: heat {} base_cooldown_s {} base_run_usd {} (set_at {}, set_by {})",
dial.heat,
dial.base_cooldown_s,
dial.base_run_usd,
dial.set_at.to_rfc3339(),
dial.set_by
)?,
None => writeln!(out, "dial: absent")?,
}
}
Err(error) => writeln!(out, "store: undecodable ({error:#})")?,
}
writeln!(out, "effective: {:?}", derive(&source, instance))?;
writeln!(out, "effective: {:?}", derive(&source, &FileSource::new(swarm_path(root)), instance))?;
Ok(())
}

View file

@ -6,7 +6,9 @@
//! `ProviderRequest` for the instance as caller, its output constrained to the
//! turn's schema, and its answer the assistant text of a `Completed` result; a
//! refusal, a failure or a stop other than the end of the turn is an error. The
//! readout is the kit's spend readout for the same caller.
//! readout is the kit's spend readout asked as this client's own caller: the
//! service answers every caller's row to any admitted asker, and the asker is
//! decrypted with its own key, so nobody asks as another caller.
//!
//! The connector reads its connection key as the file's text without the
//! trailing line break; the same rule applies here (`key`). The endpoint is the
@ -54,7 +56,7 @@ pub struct KitConnector {
impl KitConnector {
/// `key_text` is the connection key file's text (`key` is applied here);
/// `caller` is the instance the key belongs to, whose spend the readout asks for.
/// `caller` is the caller id the key belongs to: the asker of every call.
pub fn new(endpoint: SocketAddr, key_text: &str, model: impl Into<String>, caller: impl Into<String>) -> Result<Self> {
let client = ConnectorClient::new(endpoint, key(key_text), MAX_FRAME_BYTES, Some(RESPONSE_TIMEOUT)).map_err(|_| anyhow!("the connector client could not be configured"))?;
Ok(Self { client, model: model.into(), caller: caller.into() })

View file

@ -1,8 +1,8 @@
//! The operator's brake and burn-rate dial, and the one reader of them.
//! The operator's brake and the swarm's dial, and the one reader of them.
//!
//! `Released` carries a `Grant`, the only value that hands a run cap to the
//! launch: it cannot be built outside this module, so a cap exists only as the
//! reading of a released brake and an in-bounds dial. The Grant names the
//! reading of a released instance brake and a released swarm envelope. The Grant names the
//! instance whose store was read. The store is at a fixed place, `CONTROL_ROOT`,
//! and the one reader takes the instance only: no function, argument, variable
//! or flag lets a caller choose a root, a path or a source, so a Grant is the
@ -13,12 +13,13 @@
//! `/etc/gamecult/minds/<instance>/control.cc`, root-owned and world-readable.
//! The operator's CLI replaces a document whole; every organ that acts calls
//! `read_effective` before the consequence and takes its answer as given. The
//! cadence and the run cap are derived here and nowhere else.
//! run cap is derived here and nowhere else; who wakes and when is the
//! heartbeat's.
//!
//! Fail closed: the reader's only route to `Released` is a decodable store
//! holding a released brake and an in-bounds dial. Anything else (no file, no
//! brake, a held brake, no dial, a dial out of bounds, bytes that do not
//! decode, a file that cannot be read) is `Held`, with the reason.
//! Fail closed: the reader's only route to `Released` is a decodable instance
//! store holding a released brake and a released swarm envelope. Anything else
//! (no file, no brake, a held brake, a held, absent or out-of-bounds swarm dial,
//! bytes that do not decode, a file that cannot be read) is `Held`, with the reason.
//!
//! The reader opens the file read-only through CultCache's read-only snapshot
//! door: it takes no lock and creates no sibling file, so the units she runs as
@ -35,14 +36,12 @@
//! only way to narrow the envelope into a `Grant`, and it never widens one.
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use cultcache_rs::{CultCache, CultCacheEnvelope, DatabaseEntry, SingleFileMessagePackBackingStore};
use eureka_pipeline::Slug;
use rust_decimal::Decimal;
use rust_decimal::prelude::ToPrimitive;
/// Where every instance's control directory lives. The reader reads only here;
/// the operator's CLI takes its own `--root` for writing and display.
@ -79,50 +78,21 @@ pub struct Brake {
pub set_by: String,
}
/// The burn-rate dial: `heat` scales how active she is; the base values say
/// what heat 1.0 means. Cadence and run cap are derived from it, never stored.
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "eureka.control.burn_rate.v1", schema = "ControlBurnRate")]
pub struct BurnRate {
#[cultcache(key = 0)]
pub heat: Decimal,
#[cultcache(key = 1)]
pub base_cooldown_s: u32,
#[cultcache(key = 2)]
pub base_run_usd: Decimal,
#[cultcache(key = 3)]
pub set_at: DateTime<Utc>,
#[cultcache(key = 4)]
pub set_by: String,
}
/// Heat's inclusive bounds.
pub fn heat_min() -> Decimal {
Decimal::new(5, 2)
}
/// Heat's inclusive upper bound.
pub fn heat_max() -> Decimal {
Decimal::new(20, 1)
}
impl BurnRate {
/// The one bounds rule: the reader holds on it and the CLI refuses on it.
pub fn in_bounds(&self) -> bool {
self.heat >= heat_min() && self.heat <= heat_max() && self.base_cooldown_s > 0 && self.base_run_usd > Decimal::ZERO
}
/// Wake cadence: `base_cooldown_s / heat`, to the nanosecond.
fn cadence(&self) -> Option<Duration> {
let seconds = Decimal::from(self.base_cooldown_s).checked_div(self.heat)?;
let whole = seconds.trunc();
let nanos = ((seconds - whole) * Decimal::from(1_000_000_000u32)).round();
Some(Duration::new(whole.to_u64()?, nanos.to_u32()?))
}
/// Per-run cap: `base_run_usd x heat`.
fn run_cap_usd(&self) -> Option<Decimal> {
self.base_run_usd.checked_mul(self.heat)
}
/// Calibration ceilings on the swarm dial's base values, so a typo (a
/// `--base-run 10000`) is refused where it is written and held where it is read.
/// They are guards, not policy: the operator raises them here.
pub fn base_rate_max() -> Decimal {
Decimal::new(100, 0)
}
pub fn base_run_max() -> Decimal {
Decimal::new(50, 0)
}
pub const K_MAX: u32 = 16;
/// Why the reader said held.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@ -145,8 +115,8 @@ pub enum Effective {
Released(Grant),
}
/// The proof that the brake was released and the dial in bounds when read, in
/// the root-owned store of `instance`. Its fields are private and only
/// The proof that the instance brake and the swarm envelope were released when
/// read, in the root-owned stores of `instance`. Its fields are private and only
/// `read_effective` makes one; `open_and_launch` takes it by value and refuses
/// one read for another instance, so one reading opens at most one run, of the
/// instance it was read for.
@ -159,7 +129,7 @@ pub enum Effective {
/// ```compile_fail,E0451
/// use mind_body::control::Grant;
/// // a struct literal
/// let _ = Grant { instance: eureka_pipeline::Slug("eureka".into()), cadence: std::time::Duration::ZERO, run_cap_usd: rust_decimal::Decimal::ONE };
/// let _ = Grant { instance: eureka_pipeline::Slug("eureka".into()), run_cap_usd: rust_decimal::Decimal::ONE };
/// ```
///
/// ```compile_fail,E0277
@ -183,12 +153,11 @@ pub enum Effective {
/// ```compile_fail,E0277
/// // not From its parts
/// use mind_body::control::Grant;
/// let _ = Grant::from((eureka_pipeline::Slug("eureka".into()), std::time::Duration::ZERO, rust_decimal::Decimal::ONE));
/// let _ = Grant::from((eureka_pipeline::Slug("eureka".into()), rust_decimal::Decimal::ONE));
/// ```
#[derive(Debug, PartialEq, Eq)]
pub struct Grant {
instance: Slug,
cadence: Duration,
run_cap_usd: Decimal,
}
@ -198,11 +167,6 @@ impl Grant {
&self.instance
}
/// How often she may wake.
pub fn cadence(&self) -> Duration {
self.cadence
}
/// The most one run may spend.
pub fn run_cap_usd(&self) -> Decimal {
self.run_cap_usd
@ -211,22 +175,22 @@ impl Grant {
/// A Grant for a launch test that has no store. It names its instance like
/// any other, so the launch's instance check judges it.
#[cfg(test)]
pub(crate) fn for_test(instance: Slug, cadence: Duration, run_cap_usd: Decimal) -> Self {
Self { instance, cadence, run_cap_usd }
pub(crate) fn for_test(instance: Slug, run_cap_usd: Decimal) -> Self {
Self { instance, run_cap_usd }
}
}
/// The store's contents, decoded and not yet judged.
/// The instance store's contents, decoded and not yet judged.
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct ControlState {
pub brake: Option<Brake>,
pub dial: Option<BurnRate>,
}
impl ControlState {
/// The one derivation. Order: brake first, then dial; each failing rung is
/// its own reason.
pub(crate) fn effective(&self, instance: &Slug) -> Effective {
/// The one derivation. Order: the instance brake, then the swarm envelope
/// (its own order is `SwarmState::effective`'s); each failing rung is its
/// own reason. The Grant's cap is the envelope's run cap.
pub(crate) fn effective(&self, instance: &Slug, swarm: SwarmEffective) -> Effective {
let held = |reason| Effective::Held { reason };
let Some(brake) = &self.brake else {
return held(HeldReason::BrakeAbsent);
@ -234,15 +198,9 @@ impl ControlState {
if !brake.released {
return held(HeldReason::BrakeHeld);
}
let Some(dial) = &self.dial else {
return held(HeldReason::DialAbsent);
};
if !dial.in_bounds() {
return held(HeldReason::DialOutOfBounds);
}
match (dial.cadence(), dial.run_cap_usd()) {
(Some(cadence), Some(run_cap_usd)) => Effective::Released(Grant { instance: instance.clone(), cadence, run_cap_usd }),
_ => held(HeldReason::DialOutOfBounds),
match swarm {
SwarmEffective::Held(reason) => held(reason),
SwarmEffective::Released(envelope) => Effective::Released(Grant { instance: instance.clone(), run_cap_usd: envelope.run_cap_usd }),
}
}
}
@ -273,7 +231,6 @@ impl ControlSource for FileSource {
fn registered_cache() -> Result<CultCache> {
let mut cache = CultCache::new();
cache.register_entry_type::<Brake>()?;
cache.register_entry_type::<BurnRate>()?;
Ok(cache)
}
@ -285,7 +242,7 @@ fn swarm_cache() -> Result<CultCache> {
Ok(cache)
}
/// Decodes the store into its two documents. Any envelope the cache does not
/// Decodes the instance store into its brake. Any envelope the cache does not
/// know, or any payload that does not decode, is an error: a store the reader
/// cannot fully read is not one it may act on.
pub fn load_state(source: &impl ControlSource) -> Result<ControlState> {
@ -293,13 +250,13 @@ pub fn load_state(source: &impl ControlSource) -> Result<ControlState> {
for envelope in source.snapshot().context("control store cannot be read")? {
cache.put_raw_envelope(envelope)?;
}
Ok(ControlState { brake: cache.get::<Brake>(BRAKE_KEY)?, dial: cache.get::<BurnRate>(DIAL_KEY)? })
Ok(ControlState { brake: cache.get::<Brake>(BRAKE_KEY)? })
}
/// The only reader every organ uses (the waker now; the permit issuer and the
/// Persona organ later). The store is `instance`'s own at
/// `CONTROL_ROOT/<instance>/control.cc`; the caller supplies neither a root, a
/// path nor a source. An instance that is not a slug reads as held.
/// The only reader every acting organ uses (the heartbeat, the Persona organ).
/// The stores are `instance`'s own brake at `CONTROL_ROOT/<instance>/control.cc`
/// and the swarm's at `CONTROL_ROOT/swarm.cc`; the caller supplies neither a
/// root, a path nor a source. An instance that is not a slug reads as held.
///
/// Nothing outside this crate can name another place:
///
@ -326,8 +283,9 @@ pub fn load_state(source: &impl ControlSource) -> Result<ControlState> {
/// let _ = mind_body::control::read_effective(&Slug("eureka".into()));
/// ```
pub fn read_effective(instance: &Slug) -> Effective {
match control_path(&control_root(), instance) {
Ok(path) => derive(&FileSource::new(path), instance),
let root = control_root();
match control_path(&root, instance) {
Ok(path) => derive(&FileSource::new(path), &FileSource::new(swarm_path(&root)), instance),
Err(_) => Effective::Held { reason: HeldReason::Undecodable },
}
}
@ -358,12 +316,13 @@ pub(crate) fn with_root<T>(root: &Path, body: impl FnOnce() -> T) -> T {
result
}
/// The derivation over any source. The reader and the CLI's `show` (display
/// only: its output is text) are its callers; nothing public returns its result
/// for a source of the caller's.
pub(crate) fn derive(source: &impl ControlSource, instance: &Slug) -> Effective {
/// The derivation over any two sources, the instance's and the swarm's. The
/// reader, the organs' gates and the CLI's `show` (display only: its output is
/// text) are its callers; nothing public returns its result for a source of the
/// caller's.
pub(crate) fn derive(source: &impl ControlSource, swarm: &impl ControlSource, instance: &Slug) -> Effective {
match load_state(source) {
Ok(state) => state.effective(instance),
Ok(state) => state.effective(instance, derive_swarm(swarm)),
Err(_) => Effective::Held { reason: HeldReason::Undecodable },
}
}
@ -499,17 +458,6 @@ impl ControlWriter {
self.cache.put(BRAKE_KEY, &brake)?;
self.settle()
}
/// Refuses an out-of-bounds dial before anything is written, so the store
/// never holds one that the CLI put there.
pub fn set_dial(&mut self, dial: BurnRate) -> Result<()> {
if !dial.in_bounds() {
anyhow::bail!("dial refused: heat must be within {}..={}, base_cooldown_s above 0 and base_run_usd above 0", heat_min(), heat_max());
}
self.prepare()?;
self.cache.put(DIAL_KEY, &dial)?;
self.settle()
}
}
// ---------------------------------------------------------------------------
@ -542,7 +490,12 @@ impl SwarmDial {
/// CLI refuses on it. Heat 0 is in bounds (it is the stop); the reader
/// answers it as held.
pub fn in_bounds(&self) -> bool {
self.heat >= Decimal::ZERO && self.heat <= heat_max() && self.base_rate_usd_per_h > Decimal::ZERO && self.base_run_usd > Decimal::ZERO && self.k >= 1
self.heat >= Decimal::ZERO && self.heat <= heat_max() && self.base_rate_usd_per_h > Decimal::ZERO
&& self.base_rate_usd_per_h <= base_rate_max()
&& self.base_run_usd > Decimal::ZERO
&& self.base_run_usd <= base_run_max()
&& self.k >= 1
&& self.k <= K_MAX
}
}
@ -614,16 +567,16 @@ impl Envelope {
/// Narrows this envelope and the instance's own reading into the Grant for
/// one run of `budget_usd`. It is the only new way to build a Grant and it
/// never widens: `None` unless the instance's own reading is released for
/// this instance and `0 < budget_usd` is within both the swarm's run cap and
/// the instance's own. The Grant's cap is the budget.
/// this instance and `0 < budget_usd` is within the swarm's run cap. The
/// Grant's cap is the budget.
pub fn grant_for(&self, instance: &Slug, instance_effective: &Effective, budget_usd: Decimal) -> Option<Grant> {
let Effective::Released(own) = instance_effective else {
return None;
};
if own.instance != *instance || budget_usd <= Decimal::ZERO || budget_usd > self.run_cap_usd || budget_usd > own.run_cap_usd {
if own.instance != *instance || budget_usd <= Decimal::ZERO || budget_usd > self.run_cap_usd {
return None;
}
Some(Grant { instance: instance.clone(), cadence: own.cadence, run_cap_usd: budget_usd })
Some(Grant { instance: instance.clone(), run_cap_usd: budget_usd })
}
}
@ -723,8 +676,11 @@ impl SwarmWriter {
pub fn set_dial(&mut self, dial: SwarmDial) -> Result<()> {
if !dial.in_bounds() {
anyhow::bail!(
"swarm dial refused: heat must be within 0..={}, base_rate_usd_per_h and base_run_usd above 0, k at least 1",
heat_max()
"swarm dial refused: heat must be within 0..={}, base_rate_usd_per_h in (0, {}], base_run_usd in (0, {}], k in 1..={}",
heat_max(),
base_rate_max(),
base_run_max(),
K_MAX
);
}
prepare_store(&self.path)?;

View file

@ -4,7 +4,6 @@
//! touches `/etc`. They run as root in the verify container like the writer's.
use std::path::{Path, PathBuf};
use std::time::Duration;
use chrono::{DateTime, TimeZone, Utc};
use cultcache_rs::{CultCache, DatabaseEntry, SingleFileMessagePackBackingStore};
@ -22,8 +21,16 @@ fn at(seconds: i64) -> DateTime<Utc> {
Utc.timestamp_opt(1_800_000_000 + seconds, 0).unwrap()
}
fn dial(heat: &str, cooldown: u32, run_usd: &str) -> BurnRate {
BurnRate { heat: dec(heat), base_cooldown_s: cooldown, base_run_usd: dec(run_usd), set_at: at(0), set_by: "op".into() }
/// A swarm dial at `heat` whose per-run cap before heat is `run_usd`.
fn dial(heat: &str, _unused: u32, run_usd: &str) -> SwarmDial {
SwarmDial { heat: dec(heat), base_rate_usd_per_h: dec("5"), base_run_usd: dec(run_usd), k: 2, set_at: at(0), set_by: "op".into() }
}
/// The swarm store at `<root>/swarm.cc`: brake released and the given dial.
fn release_swarm(root: &Path, dial: SwarmDial) {
let mut writer = SwarmWriter::open(swarm_path(root)).unwrap();
writer.set_brake(true, at(0), "op").unwrap();
writer.set_dial(dial).unwrap();
}
/// The store of the instance `eureka` under the root `dir`.
@ -35,12 +42,11 @@ fn held(reason: HeldReason) -> Effective {
Effective::Held { reason }
}
/// A written store: brake released and the given dial, through the real writer.
fn released_store(dir: &Path, dial: BurnRate) -> PathBuf {
/// A written instance store with its brake released, and a released swarm.
fn released_store(dir: &Path, dial: SwarmDial) -> PathBuf {
let path = store_path(dir);
let mut writer = ControlWriter::open(&path).unwrap();
writer.set_brake(true, at(0), "op").unwrap();
writer.set_dial(dial).unwrap();
ControlWriter::open(&path).unwrap().set_brake(true, at(0), "op").unwrap();
release_swarm(dir, dial);
path
}
@ -52,17 +58,6 @@ fn effective(path: &Path) -> Effective {
with_root(directory.parent().unwrap(), || read_effective(&instance))
}
/// A dial written around the writer's bounds check, so the reader's own check
/// is what is under test.
fn write_unchecked_dial(path: &Path, dial: &BurnRate) {
let mut cache = CultCache::new();
cache.register_entry_type::<BurnRate>().unwrap();
cache.register_entry_type::<Brake>().unwrap();
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path)).unwrap();
cache.pull_all_backing_stores().unwrap();
cache.put(DIAL_KEY, dial).unwrap();
}
#[test]
fn a_source_that_fails_reads_as_held() {
struct Failing;
@ -71,7 +66,7 @@ fn a_source_that_fails_reads_as_held() {
Err(anyhow::anyhow!("disk gone"))
}
}
assert_eq!(derive(&Failing, &Slug("eureka".into())), Effective::Held { reason: HeldReason::Undecodable });
assert_eq!(derive(&Failing, &Failing, &Slug("eureka".into())), Effective::Held { reason: HeldReason::Undecodable });
}
#[test]
@ -82,10 +77,10 @@ fn an_absent_store_reads_as_held() {
}
#[test]
fn an_absent_brake_reads_as_held_even_with_a_dial() {
fn an_absent_brake_reads_as_held_even_with_a_released_swarm() {
let dir = tempfile::tempdir().unwrap();
let path = store_path(dir.path());
ControlWriter::open(&path).unwrap().set_dial(dial("1", 3600, "1")).unwrap();
release_swarm(dir.path(), dial("1", 0, "1"));
assert_eq!(effective(&path), held(HeldReason::BrakeAbsent));
}
@ -163,78 +158,6 @@ fn a_decode_failure_outranks_a_held_brake() {
assert_eq!(effective(&path), held(HeldReason::Undecodable));
}
#[test]
fn a_released_brake_with_no_dial_reads_held_dial_absent() {
let dir = tempfile::tempdir().unwrap();
let path = store_path(dir.path());
ControlWriter::open(&path).unwrap().set_brake(true, at(0), "op").unwrap();
assert_eq!(effective(&path), held(HeldReason::DialAbsent));
}
#[test]
fn dial_bounds_are_inclusive_at_both_ends() {
for (heat, expected_in) in [("0.05", true), ("2.0", true), ("0.0499", false), ("2.0001", false), ("0", false), ("-1", false), ("3", false)] {
let dir = tempfile::tempdir().unwrap();
let path = released_store_unchecked(dir.path(), dial(heat, 3600, "1"));
let got = effective(&path);
if expected_in {
assert!(matches!(got, Effective::Released(_)), "heat {heat}: {got:?}");
} else {
assert_eq!(got, held(HeldReason::DialOutOfBounds), "heat {heat}");
}
}
}
#[test]
fn a_zero_base_cooldown_or_a_non_positive_run_cost_is_out_of_bounds() {
for bad in [dial("1", 0, "1"), dial("1", 3600, "0"), dial("1", 3600, "-0.5")] {
let dir = tempfile::tempdir().unwrap();
let path = released_store_unchecked(dir.path(), bad.clone());
assert_eq!(effective(&path), held(HeldReason::DialOutOfBounds), "{bad:?}");
}
let dir = tempfile::tempdir().unwrap();
let smallest = released_store_unchecked(dir.path(), dial("1", 1, "0.000001"));
assert!(matches!(effective(&smallest), Effective::Released(_)));
}
#[test]
fn the_check_gate_refuses_a_released_brake_over_an_out_of_bounds_dial() {
// The writers refuse such a dial, so the store is built unchecked.
for bad in [dial("3", 3600, "1"), dial("1", 0, "1"), dial("1", 3600, "0")] {
let dir = tempfile::tempdir().unwrap();
released_store_unchecked(dir.path(), bad.clone());
let args: Vec<String> = ["--instance", "eureka", "check"].iter().map(|a| a.to_string()).collect();
let mut out = Vec::new();
let result = crate::cli::run(&args, dir.path(), "op", at(1), &mut out, &mut |_| Ok(()));
assert!(format!("{:#}", result.unwrap_err()).contains("DialOutOfBounds"), "{bad:?}");
}
}
fn released_store_unchecked(dir: &Path, bad: BurnRate) -> PathBuf {
let path = store_path(dir);
ControlWriter::open(&path).unwrap().set_brake(true, at(0), "op").unwrap();
write_unchecked_dial(&path, &bad);
path
}
#[test]
fn cadence_and_run_cap_are_the_dial_divided_and_multiplied_by_heat() {
for (heat, cooldown, run_usd, cadence, cap) in [
("0.5", 3600u32, "5", Duration::from_secs(7200), dec("2.5")),
("2.0", 3600, "5", Duration::from_secs(1800), dec("10.0")),
("1", 600, "3", Duration::from_secs(600), dec("3")),
("0.05", 90, "4", Duration::from_secs(1800), dec("0.20")),
("1.5", 10, "2", Duration::new(6, 666_666_667), dec("3.0")),
("2.0", 1, "0.25", Duration::from_millis(500), dec("0.500")),
] {
let dir = tempfile::tempdir().unwrap();
let path = released_store(dir.path(), dial(heat, cooldown, run_usd));
let Effective::Released(grant) = effective(&path) else { panic!("heat {heat} cooldown {cooldown} run {run_usd}: not released") };
assert_eq!(grant.cadence(), cadence, "heat {heat} cooldown {cooldown} run {run_usd}");
assert_eq!(grant.run_cap_usd(), cap, "heat {heat} cooldown {cooldown} run {run_usd}");
}
}
#[test]
fn the_operators_last_write_is_what_is_in_force_and_provenance_round_trips() {
let dir = tempfile::tempdir().unwrap();
@ -246,8 +169,6 @@ fn the_operators_last_write_is_what_is_in_force_and_provenance_round_trips() {
let state = load_state(&FileSource::new(&path)).unwrap();
let brake = state.brake.unwrap();
assert_eq!((brake.released, brake.set_at, brake.set_by.as_str()), (false, at(60), "alice"));
// The dial survives the brake write untouched.
assert_eq!(state.dial.unwrap().set_by, "op");
ControlWriter::open(&path).unwrap().set_brake(true, at(120), "bob").unwrap();
assert!(matches!(effective(&path), Effective::Released(_)));
@ -278,40 +199,6 @@ fn reading_writes_nothing_and_takes_no_lock_file() {
}
/// A dial document under the dial's type id whose payload is not a dial.
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "eureka.control.burn_rate.v1", schema = "ControlBurnRate")]
struct WrongDial {
#[cultcache(key = 0)]
heat: String,
}
fn write_wrong_dial(path: &Path, heat: &str) {
let mut cache = CultCache::new();
cache.register_entry_type::<WrongDial>().unwrap();
cache.register_entry_type::<crate::control::Brake>().unwrap();
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path)).unwrap();
cache.pull_all_backing_stores().unwrap();
cache.put(crate::control::DIAL_KEY, &WrongDial { heat: heat.into() }).unwrap();
}
#[test]
fn a_dial_that_does_not_decode_reads_held_whatever_the_brake_says() {
for brake_released in [true, false] {
for heat in ["not a number", "NaN", ""] {
let dir = tempfile::tempdir().unwrap();
let path = store_path(dir.path());
ControlWriter::open(&path).unwrap().set_brake(brake_released, at(0), "op").unwrap();
write_wrong_dial(&path, heat);
assert_eq!(
effective(&path),
held(HeldReason::Undecodable),
"brake released={brake_released}, dial heat {heat:?}: a dial that cannot be read is never replaced by a default"
);
}
}
}
mod grant {
//! A Grant is the proof that this instance's own control store released: the
@ -332,9 +219,8 @@ mod grant {
}
fn release(root: &Path, instance: &str) {
let mut writer = ControlWriter::open(control_path(root, &slug(instance)).unwrap()).unwrap();
writer.set_brake(true, at(0), "op").unwrap();
writer.set_dial(dial("1", 3600, "5")).unwrap();
ControlWriter::open(control_path(root, &slug(instance)).unwrap()).unwrap().set_brake(true, at(0), "op").unwrap();
release_swarm(root, dial("1", 0, "5"));
}
fn read(root: &Path, instance: &str) -> Effective {
@ -542,6 +428,24 @@ mod swarm {
assert_eq!(envelope_at(dir.path()), SwarmEffective::Held(HeldReason::Undecodable));
}
#[test]
fn the_swarm_dial_has_calibration_ceilings() {
for (what, dial) in [
("base rate over the ceiling", swarm_dial("1", "100.01", "2", 2)),
("base run over the ceiling", swarm_dial("1", "5", "50.01", 2)),
("k over the ceiling", swarm_dial("1", "5", "2", 17)),
] {
let dir = tempfile::tempdir().unwrap();
assert!(SwarmWriter::open(swarm_file(dir.path())).unwrap().set_dial(dial.clone()).is_err(), "{what}: the writer refuses it");
write_unchecked(&swarm_file(dir.path()), &dial);
SwarmWriter::open(swarm_file(dir.path())).unwrap().set_brake(true, at(0), "op").unwrap();
assert_eq!(envelope_at(dir.path()), SwarmEffective::Held(HeldReason::DialOutOfBounds), "{what}: the reader holds on it");
}
let dir = tempfile::tempdir().unwrap();
released_swarm(dir.path(), swarm_dial("2", "100", "50", 16));
assert!(matches!(envelope_at(dir.path()), SwarmEffective::Released(_)), "the ceilings are inclusive");
}
#[test]
fn envelope_scales_by_heat() {
for (heat, rate, run_cap) in [("0.5", "2.5", "1.25"), ("2", "10", "5")] {
@ -568,9 +472,8 @@ mod swarm {
fn instance_reading(instance: &str, run_usd: &str) -> Effective {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(instance).join("control.cc");
let mut writer = ControlWriter::open(&path).unwrap();
writer.set_brake(true, at(0), "op").unwrap();
writer.set_dial(dial("1", 600, run_usd)).unwrap();
ControlWriter::open(&path).unwrap().set_brake(true, at(0), "op").unwrap();
release_swarm(dir.path(), dial("1", 0, run_usd));
with_root(dir.path(), || read_effective(&slug(instance)))
}
@ -587,18 +490,6 @@ mod swarm {
}
let grant = envelope.grant_for(&eureka, &own, dec("1")).unwrap();
assert_eq!(grant.instance(), &eureka);
assert_eq!(grant.cadence(), Duration::from_secs(600), "the instance cadence is carried, not invented");
}
#[test]
fn grant_for_does_not_widen_the_instance_own_cap() {
let dir = tempfile::tempdir().unwrap();
released_swarm(dir.path(), swarm_dial("1", "5", "2", 2));
let envelope = released(dir.path());
let eureka = slug("eureka");
let own = instance_reading("eureka", "0.5");
assert!(envelope.grant_for(&eureka, &own, dec("0.51")).is_none());
assert_eq!(envelope.grant_for(&eureka, &own, dec("0.5")).unwrap().run_cap_usd(), dec("0.5"));
}
#[test]

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -17,6 +17,7 @@
//! owns no file and stops no unit through the `Launcher` port: stopping her
//! units is the operator's brake hold, through `SystemdLauncher::stop_all`.
use anyhow::{Result, anyhow};
use chrono::{DateTime, Duration, Utc};
use cultnet_rs::Selection;
@ -450,7 +451,6 @@ fn breaker_question(item: &PipelineRef, label: &Label, now: DateTime<Utc>) -> Pi
#[cfg(test)]
mod tests {
use std::cell::{Cell, RefCell};
use std::time::Duration as StdDuration;
use eureka_pipeline::PipelineKind as K;
use huginn_mind::{PipelineAdmissionOutcome as Outcome, PipelineSelectionPage, PipelineStatus};
@ -480,7 +480,7 @@ mod tests {
fn try_launch_through(&self, mind: &dyn MindPort, turn: RunTurn, claims: &[PipelineRef], cap: Decimal) -> Result<LaunchOutcome> {
let ports = Ports { mind, launcher: &self.launcher, clock: &self.clock, host: "yggdrasil-host" };
let grant = Grant::for_test(Slug(INSTANCE.into()), StdDuration::from_secs(60), cap);
let grant = Grant::for_test(Slug(INSTANCE.into()), cap);
open_and_launch(&ports, LaunchRequest { turn, claims: claims.to_vec(), agent: "agent-x".into(), grant })
}
@ -1048,7 +1048,7 @@ mod tests {
fn gated(mind: &Named, grant: &str, turn: RunTurn, claims: Vec<PipelineRef>) -> Result<LaunchOutcome> {
let (launcher, clock) = (Recording::default(), Fixed(Cell::new(at(0))));
let ports = Ports { mind, launcher: &launcher, clock: &clock, host: "yggdrasil-host" };
let grant = Grant::for_test(Slug(grant.into()), StdDuration::from_secs(60), five());
let grant = Grant::for_test(Slug(grant.into()), five());
let outcome = open_and_launch(&ports, LaunchRequest { turn, claims, agent: "agent-x".into(), grant });
assert!(launcher.started.borrow().is_empty());
outcome

View file

@ -9,7 +9,8 @@
//! repetition breaker, the one-live-Self-run rule, and the single path that
//! opens a run in the mind and only then starts its unit.
//!
//! `wake` is the waker's one cycle over them: the `mind-wake` binary.
//! `heartbeat` runs the swarm's initiative timeline over them: the one process,
//! the `mind-heartbeat` binary, that decides who wakes and when.
//!
//! `persona` is the Persona organ: one start of `mind-persona@<instance>` drains her
//! pending chat through the connector, one typed reply per turn (`chat_client` is
@ -34,12 +35,12 @@ pub mod chat_rudp;
pub mod cli;
pub mod connector;
pub mod control;
pub mod heartbeat;
pub mod launch;
pub mod lease_client;
pub mod persona;
pub mod queue;
pub mod self_run;
pub mod wake;
#[cfg(test)]
mod testkit;

View file

@ -1,4 +1,4 @@
//! `mind-control`: the operator's hand on a Mind's brake and burn-rate dial.
//! `mind-control`: the operator's hand on a Mind's brake and the swarm dial.
//! All behaviour is `mind_body::cli`; this reads the process's environment and
//! clock, the two things the library never reads, and owns the process umask.

View file

@ -2,12 +2,11 @@
//! chat.
//!
//! A start (ruling `eureka-body:ruling:persona-turn-drains-deliveries`) reads the
//! brake and the dial once, and a brake held or a dial out of bounds ends it
//! instance brake and the swarm envelope once, and a held brake or envelope ends it
//! dead before the mind, the chat or the connector is touched. Otherwise it
//! adopts the Persona run the waker opened for it, or opens one (a start rung by
//! the chat ear has none; the unit is already running, so `ThisUnit` stands in
//! for the launcher and starts nothing). Then it takes up to `max_turns` turns.
//! The first turn is taken whether or not anything is pending (an unprompted
//! adopts the Persona run the heartbeat opened for it; a start with no such run
//! opens none and ends `NoRun`, because only the heartbeat opens Persona turns.
//! Then it takes up to `max_turns` turns. The first turn is taken whether or not anything is pending (an unprompted
//! start is a wake: she may speak or start work); later turns need pending
//! deliveries. The run's own budget (the Grant's cap) bounds the start: when the
//! caller's ledger spend has grown by the cap since the first turn, it stops.
@ -51,8 +50,8 @@ use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::chat_client::{Chat, ChatDelivery, ChatSendRequest};
use crate::control::{ControlSource, Effective, HeldReason, load_state};
use crate::launch::{Clock, Declined, LaunchOutcome, LaunchRequest, Launcher, Ports, open_and_launch};
use crate::control::{ControlSource, Effective, HeldReason, derive, load_swarm_state};
use crate::launch::{Clock, LaunchOutcome, LaunchRequest, Launcher, Ports, holders, open_and_launch};
use crate::queue::{MindPort, any_of, citing, headers, in_force, of_kind};
/// The admitting agent's name on everything this organ admits.
@ -267,7 +266,7 @@ pub struct TurnRequest {
/// Names this turn of this run, so a repeat of the call is the same request
/// to the connector (its replay store answers it without a second provider call).
pub request_id: String,
/// The most this call may cost, in micro-USD: the run's own budget.
/// The most this call may cost, in micro-USD: what is left of the run's cap.
pub max_cost_micro_usd: u64,
pub instructions: String,
pub input: String,
@ -399,20 +398,6 @@ pub struct Organs<'a> {
pub trace: &'a dyn Trace,
}
/// The launcher of the run this very unit carries: its unit is running (it is
/// this process), so a holder is live and starting it is nothing to do.
struct ThisUnit;
impl Launcher for ThisUnit {
fn start(&self, _instance: &Slug, _turn: RunTurn) -> Result<()> {
Ok(())
}
fn alive(&self, _instance: &Slug, _turn: RunTurn) -> bool {
true
}
}
/// Why a turn did not complete.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Failure {
@ -451,8 +436,9 @@ impl fmt::Display for Failure {
pub enum Outcome {
/// The brake or the dial did not allow it; nothing was touched.
Dead(HeldReason),
/// No run could be opened for this start.
NoRun(Declined),
/// No Persona run is held for this unit: only the heartbeat opens one, so
/// a start the heartbeat did not make runs nothing and opens nothing.
NoRun,
Served { run: PipelineRef, answered: u32, failure: Option<Failure>, self_run: Option<PipelineRef> },
}
@ -460,7 +446,7 @@ impl fmt::Display for Outcome {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Dead(reason) => write!(f, "dead: {reason:?}"),
Self::NoRun(_) => f.write_str("no run could be opened"),
Self::NoRun => f.write_str("no run is held for this start"),
Self::Served { run, answered, failure, self_run } => {
write!(f, "served: {} answered {answered}", run.id.0)?;
if let Some(failure) = failure {
@ -483,15 +469,11 @@ enum Step {
Failed(Failure),
}
/// The control store read once: what it allows and the dial's heat.
fn read(control: &impl ControlSource, instance: &Slug) -> (Effective, Option<Decimal>) {
match load_state(control) {
Ok(state) => {
let heat = state.dial.as_ref().map(|dial| dial.heat);
(state.effective(instance), heat)
}
Err(_) => (Effective::Held { reason: HeldReason::Undecodable }, None),
}
/// The instance brake and the swarm store read once: what they allow and the
/// swarm dial's heat.
fn read(control: &impl ControlSource, swarm: &impl ControlSource, instance: &Slug) -> (Effective, Option<Decimal>) {
let heat = load_swarm_state(swarm).ok().and_then(|state| state.dial).map(|dial| dial.heat);
(derive(control, swarm, instance), heat)
}
fn label_of(run: &PipelineRef) -> &str {
@ -502,8 +484,8 @@ fn date(now: DateTime<Utc>) -> Date {
Date(now.format("%Y-%m-%d").to_string())
}
/// One start. `control` is the instance's control store.
pub fn start(organs: &Organs, control: &impl ControlSource, config: &Config) -> Result<Outcome> {
/// One start. `control` is the instance's control store and `swarm` the swarm's.
pub fn start(organs: &Organs, control: &impl ControlSource, swarm: &impl ControlSource, config: &Config) -> Result<Outcome> {
let noted = Noted { inner: organs.trace, failures: std::cell::Cell::new(0) };
let organs = &Organs {
mind: organs.mind,
@ -516,27 +498,22 @@ pub fn start(organs: &Organs, control: &impl ControlSource, config: &Config) ->
trace: &noted,
};
let instance = organs.mind.instance().clone();
let grant = match read(control, &instance).0 {
let grant = match read(control, swarm, &instance).0 {
Effective::Held { reason } => return Ok(Outcome::Dead(reason)),
Effective::Released(grant) => grant,
};
let budget_micro = (grant.run_cap_usd() * Decimal::from(1_000_000)).trunc().to_u64().unwrap_or(u64::MAX);
let own = Ports { mind: organs.mind, launcher: &ThisUnit, clock: organs.clock, host: organs.host };
let request = LaunchRequest { turn: RunTurn::PersonaTurn, claims: vec![], agent: AGENT.into(), grant };
// A holder is the run the waker opened for this unit; none means the chat ear rang.
let run = match open_and_launch(&own, request)? {
LaunchOutcome::Launched { run } | LaunchOutcome::Busy { run } => run,
LaunchOutcome::Refused(declined) => return Ok(Outcome::NoRun(declined)),
LaunchOutcome::NotQueued { .. } | LaunchOutcome::Tripped { .. } => bail!("the launch answered a Persona turn with an answer only a Self run has"),
};
// The run is the one the heartbeat opened for this unit; none held, none run.
let Some(holder) = holders(organs.mind, &instance, RunTurn::PersonaTurn)?.into_iter().next() else { return Ok(Outcome::NoRun) };
let run = holder.id;
let mut after_seq = 0;
let mut answered = 0;
let mut failure = None;
let mut self_run = None;
let mut spent_at_start = None;
let mut start = None;
for turn in 0..config.max_turns {
let budget = Budget { micro: budget_micro, spent_at_start: &mut spent_at_start };
match one_turn(organs, control, config, &run, turn, &mut after_seq, budget, &mut self_run)? {
let budget = Budget { micro: budget_micro, start: &mut start };
match one_turn(organs, control, swarm, config, &run, turn, &mut after_seq, budget, &mut self_run)? {
Step::Idle | Step::Spent => break,
Step::Answered => answered += 1,
Step::Failed(why) => {
@ -552,13 +529,15 @@ pub fn start(organs: &Organs, control: &impl ControlSource, config: &Config) ->
/// The run's budget and where the caller's ledger stood at its first turn.
struct Budget<'a> {
micro: u64,
spent_at_start: &'a mut Option<u64>,
/// The caller's total and window at the run's first turn.
start: &'a mut Option<(u64, u64)>,
}
#[allow(clippy::too_many_arguments, reason = "one turn's state is these few things, and a struct for them would be a second owner")]
fn one_turn(
organs: &Organs,
control: &impl ControlSource,
swarm: &impl ControlSource,
config: &Config,
run: &PipelineRef,
turn: u32,
@ -568,33 +547,44 @@ fn one_turn(
) -> Result<Step> {
let instance = organs.mind.instance().clone();
// The gate runs before every connector call.
let (effective, heat) = read(control, &instance);
let (effective, heat) = read(control, swarm, &instance);
if let Effective::Held { reason } = effective {
return Ok(Step::Failed(Failure::Held(reason)));
}
let Ok(deliveries) = organs.chat.pending(*after_seq) else { return Ok(Step::Failed(Failure::Chat)) };
let Ok(pending) = organs.chat.pending(*after_seq) else { return Ok(Step::Failed(Failure::Chat)) };
let deliveries = next_batch(pending);
if deliveries.is_empty() && turn > 0 {
return Ok(Step::Idle);
}
let Ok(instructions) = organs.persona.instructions() else { return Ok(Step::Failed(Failure::Persona)) };
let Ok(readout) = organs.connector.spend_readout() else { return Ok(Step::Failed(Failure::Readout)) };
let spent = readout.callers.iter().find(|row| row.caller_id == instance.0).map(|row| row.spent_micro_usd);
if let Some(spent) = spent {
let start = *budget.spent_at_start.get_or_insert(spent);
if spent.saturating_sub(start) >= budget.micro {
return Ok(Step::Spent);
}
// The ledger resets at a window boundary: a run that sees the window turn is charged its whole grant, as the heartbeat charges it.
// A readout with no row for her caller cannot say what is left of the run's cap: the run holds, no turn is taken.
let Some(row) = readout.callers.iter().find(|row| row.caller_id == instance.0) else {
let inputs = trace_inputs(config, &deliveries);
let outcome = "held: the spend readout has no row for her caller".to_string();
organs.trace.record(&TurnTrace { run: label_of(run).into(), turn, at: organs.clock.now(), inputs, outputs: vec![], outcome })?;
return Ok(Step::Failed(Failure::Readout));
};
let window = readout.at_unix_ms.checked_div(row.window_ms).unwrap_or(0);
let (spent_then, window_then) = *budget.start.get_or_insert((row.spent_micro_usd, window));
let used = row.spent_micro_usd.saturating_sub(spent_then);
if window != window_then || used >= budget.micro {
return Ok(Step::Spent);
}
// This turn may cost what is left of the run's cap, never the whole of it.
let remaining = budget.micro - used;
let live = live_runs(organs.mind, &instance)?;
let Some(burn) = burn_line(&readout, &instance.0, heat, live) else { return Ok(Step::Failed(Failure::Readout)) };
let history = recent_runs(organs.mind, &instance)?;
let rooms = reply_rooms(config, &deliveries);
let request = TurnRequest {
caller: instance.0.clone(),
request_id: format!("{}-t{turn}", label_of(run)),
max_cost_micro_usd: budget.micro.max(1),
max_cost_micro_usd: remaining,
instructions,
input: render_input(config, &deliveries, &history, &burn),
output_schema_json: reply_schema(&config.rooms),
output_schema_json: reply_schema(&rooms),
};
let inputs = trace_inputs(config, &deliveries);
let now = organs.clock.now();
@ -604,7 +594,7 @@ fn one_turn(
organs.trace.record(&record(vec![], "failed: connector".into()))?;
return Ok(Step::Failed(Failure::Connector));
};
let reply = match decode_reply(&text, config) {
let reply = match decode_reply(&text, &rooms) {
Ok(reply) => reply,
Err(why) => {
let raw = TraceItem { provenance: "her own".into(), room: String::new(), text: text.chars().take(TRACE_RAW_MAX).collect() };
@ -620,6 +610,11 @@ fn one_turn(
};
let mut outputs = Vec::new();
for (index, say) in reply.say.iter().enumerate() {
// The gate binds the send as it bound the call: a hold placed while the model was answering stops the reply.
if let Effective::Held { reason } = read(control, swarm, &instance).0 {
organs.trace.record(&record(outputs, format!("failed: {}", Failure::Held(reason))))?;
return Ok(Step::Failed(Failure::Held(reason)));
}
let text = if say.room == config.burn_room { format!("{burn}\n{}", say.text) } else { say.text.clone() };
let sent = ChatSendRequest { request_id: format!("{batch}-{index}"), persona: instance.0.clone(), room_alias: say.room.clone(), text: text.clone() };
let delivered = organs.chat.send(&sent).is_ok_and(|receipt| receipt.refusal.is_none() && receipt.event_id.is_some());
@ -631,7 +626,7 @@ fn one_turn(
}
if let Some(ask) = &reply.start_self_run {
outputs.push(TraceItem { provenance: "her own".into(), room: String::new(), text: format!("start_self_run {} {}: {}", ask.campaign, ask.repo, ask.brief) });
match start_self_run(organs, control, run, &batch, ask)? {
match start_self_run(organs, control, swarm, run, &batch, ask)? {
Ok(started) => *self_run = Some(started),
Err(why) => {
organs.trace.record(&record(outputs, format!("failed: {why}")))?;
@ -649,13 +644,38 @@ fn one_turn(
Ok(Step::Answered)
}
/// The reply as the one type it must be, with every room one of hers.
fn decode_reply(text: &str, config: &Config) -> Result<PersonaReply, Failure> {
/// The oldest deliveries that share a conversation: the public rooms together, or one direct
/// message room alone. A prefix by seq, so an ack through its last never passes an unanswered one,
/// and one member's DM text never enters another room's turn.
fn next_batch(mut deliveries: Vec<ChatDelivery>) -> Vec<ChatDelivery> {
deliveries.sort_by_key(|delivery| delivery.seq);
let Some(first) = deliveries.first() else { return deliveries };
let (direct, room) = (first.is_direct, first.room_id.clone());
let end = deliveries.iter().position(|delivery| delivery.is_direct != direct || (direct && delivery.room_id != room)).unwrap_or(deliveries.len());
deliveries.truncate(end);
deliveries
}
/// The rooms a reply to these deliveries may name: hers, and the room each
/// direct message arrived under (a DM's alias is its room id, which the mouth
/// sends into). Never a room no delivery came from and she was not given.
fn reply_rooms(config: &Config, deliveries: &[ChatDelivery]) -> Vec<String> {
let mut rooms = config.rooms.clone();
for delivery in deliveries.iter().filter(|delivery| delivery.is_direct) {
if !rooms.contains(&delivery.room_alias) {
rooms.push(delivery.room_alias.clone());
}
}
rooms
}
/// The reply as the one type it must be, with every room one of `rooms`.
fn decode_reply(text: &str, rooms: &[String]) -> Result<PersonaReply, Failure> {
let reply: PersonaReply = serde_json::from_str(text).map_err(|_| Failure::Undecodable)?;
if reply.start_self_run.as_ref().is_some_and(|ask| !start_in_bounds(ask)) {
return Err(Failure::Undecodable);
}
if reply.say.iter().any(|say| !config.rooms.contains(&say.room) || say.text.trim().is_empty()) {
if reply.say.iter().any(|say| !rooms.contains(&say.room) || say.text.trim().is_empty()) {
return Err(Failure::RoomNotAllowed);
}
Ok(reply)
@ -667,6 +687,7 @@ fn decode_reply(text: &str, config: &Config) -> Result<PersonaReply, Failure> {
fn start_self_run(
organs: &Organs,
control: &impl ControlSource,
swarm: &impl ControlSource,
run: &PipelineRef,
batch: &str,
ask: &StartSelfRun,
@ -690,7 +711,7 @@ fn start_self_run(
PipelineAdmissionOutcome::Committed { .. } | PipelineAdmissionOutcome::AlreadyAdmitted { .. } => {}
_ => return Ok(Err(Failure::SelfRun)),
}
let Effective::Released(grant) = read(control, organs.mind.instance()).0 else { return Ok(Err(Failure::SelfRun)) };
let Effective::Released(grant) = read(control, swarm, organs.mind.instance()).0 else { return Ok(Err(Failure::SelfRun)) };
let ports = Ports { mind: organs.mind, launcher: organs.launcher, clock: organs.clock, host: organs.host };
let request = LaunchRequest { turn: RunTurn::SelfRun, claims: vec![claim], agent: AGENT.into(), grant };
Ok(match open_and_launch(&ports, request)? {
@ -806,9 +827,15 @@ fn trace_inputs(config: &Config, deliveries: &[ChatDelivery]) -> Vec<TraceItem>
}
/// A string as a JSON string literal on one line: whatever it holds, it cannot
/// end its line or open another.
fn quoted(text: &str) -> String {
serde_json::to_string(text).unwrap_or_else(|_| "\"\"".into())
/// end its line or open another. JSON leaves U+2028, U+2029 and U+0085 raw; they
/// are escaped here so no line-separator reader splits the line.
pub(crate) fn quoted(text: &str) -> String {
let json = serde_json::to_string(text).unwrap_or_else(|_| "\"\"".into());
json.chars().map(|c| match c {
'\u{2028}' | '\u{2029}' | '\u{0085}' => format!("\\u{:04x}", u32::from(c)),
other => other.to_string(),
})
.collect()
}
/// One message line: the sender id and the role the organ derived from it stand

View file

@ -17,7 +17,7 @@ use tempfile::TempDir;
use super::*;
use crate::chat_client::{ChatSendReceipt, ContextLine};
use crate::control::{BurnRate, ControlWriter, FileSource};
use crate::control::{ControlWriter, FileSource, SwarmDial, SwarmWriter};
use crate::testkit::*;
type Log = Rc<RefCell<Vec<String>>>;
@ -39,6 +39,10 @@ struct FakeChat {
acked: RefCell<Vec<u64>>,
pending_calls: Cell<usize>,
fail_send_from: Cell<Option<usize>>,
/// 0 a delivered receipt, 1 a refused one, 2 one with neither an event nor a refusal.
receipt: Cell<u8>,
/// Runs after each delivered send, where the world changes between turns.
on_send: RefCell<Option<Box<dyn Fn()>>>,
}
impl Chat for FakeChat {
@ -53,7 +57,15 @@ impl Chat for FakeChat {
}
self.log.borrow_mut().push("send".into());
self.sent.borrow_mut().push(request.clone());
Ok(ChatSendReceipt { request_id: request.request_id.clone(), event_id: Some("$e".into()), refusal: None })
if let Some(on_send) = &*self.on_send.borrow() {
on_send();
}
let (event_id, refusal) = match self.receipt.get() {
0 => (Some("$e".to_string()), None),
1 => (None, Some("Brake".to_string())),
_ => (None, None),
};
Ok(ChatSendReceipt { request_id: request.request_id.clone(), event_id, refusal })
}
fn ack(&self, through_seq: u64) -> Result<()> {
@ -71,6 +83,10 @@ struct FakeConnector {
/// The caller's ledger spend, which a call may raise.
spent: Rc<Cell<u64>>,
readout_fails: Cell<bool>,
/// The readout carries no row for the caller.
no_row: Cell<bool>,
/// The readout's clock, in unix ms, which a call may advance.
at: Rc<Cell<u64>>,
/// Runs inside each call, where time passes and the world changes.
during: RefCell<Option<Box<dyn Fn()>>>,
}
@ -88,6 +104,10 @@ impl Connector for FakeConnector {
fn spend_readout(&self) -> Result<SpendReadout> {
let mut readout = readout();
readout.callers[0].spent_micro_usd = self.spent.get();
readout.at_unix_ms = self.at.get();
if self.no_row.get() {
readout.callers.clear();
}
if self.readout_fails.get() { Err(anyhow!("no readout")) } else { Ok(readout) }
}
}
@ -147,6 +167,8 @@ fn delivery(seq: u64, room: &str, sender: &str, text: &str) -> ChatDelivery {
text: text.into(),
at_unix_ms: 1_700_000_000_000,
context: vec![],
mentions: vec![],
is_direct: false,
}
}
@ -183,6 +205,8 @@ impl Rig {
acked: RefCell::default(),
pending_calls: Cell::new(0),
fail_send_from: Cell::new(None),
receipt: Cell::new(0),
on_send: RefCell::new(None),
},
connector: FakeConnector {
log: log.clone(),
@ -190,6 +214,8 @@ impl Rig {
requests: RefCell::default(),
spent: Rc::new(Cell::new(1_234_567)),
readout_fails: Cell::new(false),
no_row: Cell::new(false),
at: Rc::new(Cell::new(1)),
during: RefCell::new(None),
},
persona: FakePersona(Cell::new(false)),
@ -205,14 +231,40 @@ impl Rig {
}
fn release(&self, heat: &str) {
let mut writer = ControlWriter::open(self.store()).unwrap();
writer.set_brake(true, at(0), "op").unwrap();
writer
.set_dial(BurnRate { heat: Decimal::from_str(heat).unwrap(), base_cooldown_s: 3600, base_run_usd: Decimal::from_str("5").unwrap(), set_at: at(0), set_by: "op".into() })
.unwrap();
ControlWriter::open(self.store()).unwrap().set_brake(true, at(0), "op").unwrap();
let mut swarm = SwarmWriter::open(self.swarm_store()).unwrap();
swarm.set_brake(true, at(0), "op").unwrap();
let dial = SwarmDial {
heat: Decimal::from_str(heat).unwrap(),
base_rate_usd_per_h: Decimal::from_str("5").unwrap(),
base_run_usd: Decimal::from_str("5").unwrap(),
k: 1,
set_at: at(0),
set_by: "op".into(),
};
swarm.set_dial(dial).unwrap();
}
fn swarm_store(&self) -> PathBuf {
self.dir.path().join("swarm.cc")
}
/// The heartbeat's part: the run a Persona unit starts under.
fn open_the_run(&self) {
let (effective, _) = read(&FileSource::new(self.store()), &FileSource::new(self.swarm_store()), self.mind.instance());
if let Effective::Released(grant) = effective {
let ports = Ports { mind: &self.mind, launcher: &Recording::default(), clock: &self.clock, host: "yggdrasil-host" };
let request = LaunchRequest { turn: RunTurn::PersonaTurn, claims: vec![], agent: "mind-heartbeat".into(), grant };
assert!(matches!(open_and_launch(&ports, request).unwrap(), LaunchOutcome::Launched { .. }));
}
}
fn go_with(&self, config: &Config) -> Outcome {
self.open_the_run();
self.start_with(config)
}
fn start_with(&self, config: &Config) -> Outcome {
let organs = Organs {
mind: &self.mind,
launcher: &self.launcher,
@ -223,7 +275,7 @@ impl Rig {
persona: &self.persona,
trace: &self.trace,
};
start(&organs, &FileSource::new(self.store()), config).unwrap()
start(&organs, &FileSource::new(self.store()), &FileSource::new(self.swarm_store()), config).unwrap()
}
fn go(&self) -> Outcome {
@ -255,15 +307,13 @@ fn operator_says(seq: u64, text: &str) -> ChatDelivery {
#[test]
fn no_grant_no_connector_call() {
for setup in ["no store", "brake held", "dial absent"] {
for setup in ["no store", "brake held", "swarm absent", "swarm held"] {
let rig = Rig::new(vec![operator_says(1, "hello")], &[says(&[("operator", "hi")])]);
match setup {
"no store" => std::fs::remove_file(rig.store()).unwrap(),
"brake held" => ControlWriter::open(rig.store()).unwrap().set_brake(false, at(0), "op").unwrap(),
_ => {
std::fs::remove_file(rig.store()).unwrap();
ControlWriter::open(rig.store()).unwrap().set_brake(true, at(0), "op").unwrap();
}
"swarm absent" => std::fs::remove_file(rig.swarm_store()).unwrap(),
_ => SwarmWriter::open(rig.swarm_store()).unwrap().set_brake(false, at(0), "op").unwrap(),
}
let outcome = rig.go();
assert!(matches!(outcome, Outcome::Dead(_)), "{setup}: {outcome:?}");
@ -275,11 +325,19 @@ fn no_grant_no_connector_call() {
}
}
#[test]
fn a_start_the_heartbeat_did_not_make_opens_nothing_and_touches_nothing() {
let rig = Rig::new(vec![operator_says(1, "hello")], &[says(&[("operator", "hi")])]);
assert_eq!(rig.start_with(&config()), Outcome::NoRun);
assert!(rig.mind.ids(K::Run).is_empty() && rig.launcher.started.borrow().is_empty());
assert_eq!((rig.turns(), rig.chat.pending_calls.get()), (0, 0));
}
#[test]
fn the_gate_is_read_again_before_every_connector_call() {
let rig = Rig::new(vec![operator_says(1, "one")], &[says(&[("operator", "a")]), says(&[("operator", "b")])]);
let (store, deliveries) = (rig.store(), rig.chat.deliveries.clone());
*rig.connector.during.borrow_mut() = Some(Box::new(move || {
*rig.chat.on_send.borrow_mut() = Some(Box::new(move || {
ControlWriter::open(&store).unwrap().set_brake(false, at(0), "op").unwrap();
deliveries.borrow_mut().push(operator_says(2, "two"));
}));
@ -289,6 +347,70 @@ fn the_gate_is_read_again_before_every_connector_call() {
assert_eq!(rig.chat.acked.borrow().as_slice(), &[1], "the held turn acks nothing");
}
#[test]
fn a_refused_or_event_less_receipt_is_not_a_delivered_send() {
for receipt in [1, 2] {
let rig = Rig::new(vec![operator_says(1, "hello")], &[says(&[("operator", "hi")])]);
rig.chat.receipt.set(receipt);
let Outcome::Served { failure, .. } = rig.go() else { panic!("served") };
assert_eq!(failure, Some(Failure::Send), "receipt kind {receipt}");
assert!(rig.chat.acked.borrow().is_empty(), "a send that was not delivered is not acked");
}
}
#[test]
fn recent_runs_are_the_newest_five_newest_first() {
let rig = Rig::new(vec![], &[]);
for n in 1..=7 {
let label = format!("mind-{n}");
rig.mind.committed(vec![crate::testkit::run(&label, RunTurn::PersonaTurn, RunOperator::Mind, &[])]);
rig.mind.committed(vec![crate::testkit::close(crate::testkit::run_ref(&label), crate::testkit::recorded())]);
}
let lines = recent_runs(&rig.mind, &Slug(INSTANCE.into())).unwrap();
assert_eq!(lines.len(), 5);
assert!(lines[0].starts_with("mind-7 ") && lines[4].starts_with("mind-3 "), "{lines:?}");
}
#[test]
fn line_separators_in_member_text_cannot_end_its_line() {
let seps = [char::from_u32(0x2028).unwrap(), char::from_u32(0x2029).unwrap(), char::from_u32(0x85).unwrap()];
let text = format!("a{}b{}c{}d", seps[0], seps[1], seps[2]);
let line = message_line(&config(), "message", "swarm", "@m:chat", Some("M"), &text);
assert_eq!(line.matches('\n').count(), 1, "{line:?}");
assert!(seps.iter().all(|sep| !line.contains(*sep)), "{line:?}");
}
#[test]
fn a_held_swarm_envelope_stops_a_running_turn() {
let rig = Rig::new(vec![operator_says(1, "one")], &[says(&[("operator", "a")]), says(&[("operator", "b")])]);
let (swarm, deliveries) = (rig.swarm_store(), rig.chat.deliveries.clone());
*rig.chat.on_send.borrow_mut() = Some(Box::new(move || {
SwarmWriter::open(&swarm).unwrap().set_brake(false, at(0), "op").unwrap();
deliveries.borrow_mut().push(operator_says(2, "two"));
}));
let Outcome::Served { answered, failure, .. } = rig.go() else { panic!("served") };
assert_eq!((rig.turns(), answered), (1, 1), "the second turn makes no connector call");
assert_eq!(failure, Some(Failure::Held(HeldReason::BrakeHeld)));
}
#[test]
fn a_hold_placed_while_the_model_answers_stops_the_send_whichever_brake_it_is() {
for swarm_wide in [true, false] {
let rig = Rig::new(vec![operator_says(1, "one")], &[says(&[("operator", "a"), ("swarm", "b")])]);
let (swarm, store) = (rig.swarm_store(), rig.store());
*rig.connector.during.borrow_mut() = Some(Box::new(move || {
if swarm_wide {
SwarmWriter::open(&swarm).unwrap().set_brake(false, at(0), "op").unwrap();
} else {
ControlWriter::open(&store).unwrap().set_brake(false, at(0), "op").unwrap();
}
}));
let Outcome::Served { answered, failure, .. } = rig.go() else { panic!("served") };
assert_eq!((rig.turns(), answered, failure), (1, 0, Some(Failure::Held(HeldReason::BrakeHeld))), "swarm-wide {swarm_wide}");
assert!(rig.chat.sent.borrow().is_empty() && rig.chat.acked.borrow().is_empty(), "nothing sent, nothing acked");
}
}
#[test]
fn reply_decodes_into_sends_and_ack() {
let rig = Rig::new(vec![operator_says(1, "a"), delivery(2, "swarm", "@bot:chat", "b")], &[says(&[("operator", "first"), ("swarm", "second")])]);
@ -317,17 +439,17 @@ fn the_turn_is_one_tool_less_call_constrained_to_the_reply_schema_with_her_rooms
#[test]
fn a_reply_is_decoded_as_the_one_type_and_nothing_else_is_scanned() {
let c = config();
assert!(decode_reply(&says(&[("swarm", "x")]), &c).is_ok());
assert!(decode_reply(&says(&[("swarm", "x")]), &c.rooms).is_ok());
for bad in [
"I will start_self_run now",
r#"{"say":[],"start_self_run":null} and more"#,
r#"{"say":[],"start_self_run":null,"extra":1}"#,
"",
] {
assert_eq!(decode_reply(bad, &c), Err(Failure::Undecodable), "{bad}");
assert_eq!(decode_reply(bad, &c.rooms), Err(Failure::Undecodable), "{bad}");
}
assert_eq!(decode_reply(&says(&[("elsewhere", "x")]), &c), Err(Failure::RoomNotAllowed));
assert_eq!(decode_reply(&says(&[("swarm", " ")]), &c), Err(Failure::RoomNotAllowed));
assert_eq!(decode_reply(&says(&[("elsewhere", "x")]), &c.rooms), Err(Failure::RoomNotAllowed));
assert_eq!(decode_reply(&says(&[("swarm", " ")]), &c.rooms), Err(Failure::RoomNotAllowed));
}
#[test]
@ -350,6 +472,32 @@ fn a_room_outside_hers_sends_nothing_and_acks_nothing() {
assert!(rig.chat.sent.borrow().is_empty() && rig.chat.acked.borrow().is_empty());
}
fn direct(seq: u64, room_id: &str, sender: &str, text: &str) -> ChatDelivery {
ChatDelivery { is_direct: true, ..delivery(seq, room_id, sender, text) }
}
#[test]
fn a_members_dm_is_answered_in_its_room_and_no_other_unconfigured_room_is() {
let dm = "!abc:chat";
let rig = Rig::new(vec![direct(1, dm, "@m:chat", "hello, are you there")], &[says(&[(dm, "yes")])]);
let Outcome::Served { failure, answered, .. } = rig.go() else { panic!("served") };
assert_eq!((failure, answered), (None, 1));
assert_eq!(rig.chat.sent.borrow()[0].room_alias, dm);
let request = &rig.connector.requests.borrow()[0];
let schema: serde_json::Value = serde_json::from_str(&request.output_schema_json).unwrap();
assert_eq!(schema["properties"]["say"]["items"]["properties"]["room"]["enum"], json!(["swarm", "burn", "operator", dm]));
assert!(request.input.contains(dm), "the DM's room is among the rooms she is told she may speak in");
let rig = Rig::new(vec![direct(1, dm, "@m:chat", "hello")], &[says(&[("!other:chat", "psst")])]);
let Outcome::Served { failure, .. } = rig.go() else { panic!("served") };
assert_eq!(failure, Some(Failure::RoomNotAllowed));
assert!(rig.chat.sent.borrow().is_empty() && rig.chat.acked.borrow().is_empty());
let rig = Rig::new(vec![delivery(1, "!notdm:chat", "@m:chat", "hello")], &[says(&[("!notdm:chat", "psst")])]);
let Outcome::Served { failure, .. } = rig.go() else { panic!("served") };
assert_eq!(failure, Some(Failure::RoomNotAllowed), "a room that is not a DM is hers only when configured");
}
#[test]
fn start_self_run_admits_brief_and_launches() {
let reply = json!({
@ -387,8 +535,8 @@ fn a_start_outside_the_reply_bounds_fails_the_turn_without_an_admission_or_an_ac
#[test]
fn a_reply_without_start_self_run_is_not_a_reply_though_null_is() {
let c = config();
assert_eq!(decode_reply(r#"{"say":[]}"#, &c), Err(Failure::Undecodable), "the schema requires the field, so does the decoder");
assert!(decode_reply(r#"{"say":[],"start_self_run":null}"#, &c).is_ok());
assert_eq!(decode_reply(r#"{"say":[]}"#, &c.rooms), Err(Failure::Undecodable), "the schema requires the field, so does the decoder");
assert!(decode_reply(r#"{"say":[],"start_self_run":null}"#, &c.rooms).is_ok());
let rig = Rig::new(vec![operator_says(1, "go")], &[r#"{"say":[{"room":"operator","text":"hi"}]}"#.into()]);
let Outcome::Served { failure, .. } = rig.go() else { panic!("served") };
assert_eq!(failure, Some(Failure::Undecodable));
@ -399,7 +547,7 @@ fn a_reply_without_start_self_run_is_not_a_reply_though_null_is() {
fn the_decoder_holds_the_bounds_the_schema_states() {
let c = config();
let start = |brief: &str, repo: &str, campaign: &str| json!({ "say": [], "start_self_run": { "brief": brief, "repo": repo, "campaign": campaign } }).to_string();
let ok = |brief: &str, repo: &str, campaign: &str| decode_reply(&start(brief, repo, campaign), &c).is_ok();
let ok = |brief: &str, repo: &str, campaign: &str| decode_reply(&start(brief, repo, campaign), &c.rooms).is_ok();
assert!(ok("b", "GameCult/Huginn", "eureka-body"));
assert!(ok(&"b".repeat(1000), "a-b/c.d_e-f", &"c".repeat(64)));
assert!(ok(&"\u{e9}".repeat(1000), "a/b", "c._-"), "the brief bound counts characters, as the schema does");
@ -482,13 +630,13 @@ fn an_unprompted_start_takes_one_turn_whoever_started_it() {
let ring = Rig::new(vec![], &[says(&[("swarm", "x")]), says(&[("swarm", "y")])]);
let Outcome::Served { answered, .. } = ring.go() else { panic!("served") };
assert_eq!((ring.turns(), answered), (1, 1), "an unprompted start takes one turn and stops with nothing pending");
assert_eq!(ring.mind.ids(K::Run).len(), 1, "the ring-started unit opens its own run");
assert_eq!(ring.mind.ids(K::Run).len(), 1, "the unit carries the one run the heartbeat opened");
let waker = Rig::new(vec![], &[says(&[("swarm", "good morning")]), says(&[("swarm", "again")])]);
waker.mind.committed(vec![run("woken", RunTurn::PersonaTurn, RunOperator::Mind, &[])]);
// Past the launch grace: only the unit being this process keeps the holder live.
waker.clock.0.set(600_000);
let Outcome::Served { run: carried, answered, .. } = waker.go() else { panic!("served") };
let Outcome::Served { run: carried, answered, .. } = waker.start_with(&config()) else { panic!("served") };
assert_eq!(carried, run_ref("woken"), "the unit carries the run the waker opened");
assert_eq!((waker.turns(), answered), (1, 1), "a wake turn happens once, then nothing is pending");
assert_eq!(waker.mind.ids(K::Run).len(), 1);
@ -738,7 +886,7 @@ fn a_turn_is_named_by_its_run_and_its_number() {
deliveries.borrow_mut().push(operator_says(next.get(), "more"));
}
}));
let Outcome::Served { run: carried, answered, .. } = rig.go() else { panic!("served") };
let Outcome::Served { run: carried, answered, .. } = rig.start_with(&config()) else { panic!("served") };
assert_eq!((carried, answered), (run_ref("woken"), 2));
let ids: Vec<_> = rig.connector.requests.borrow().iter().map(|request| request.request_id.clone()).collect();
assert_eq!(ids, ["woken-t0", "woken-t1"]);
@ -764,3 +912,76 @@ fn the_burn_line_counts_the_live_runs_of_the_mind() {
busy.go();
assert!(busy.sent_texts()[0].contains("live runs 2\n"), "her Persona run and her Self run: {:?}", busy.sent_texts());
}
#[test]
fn a_run_that_crosses_a_spend_window_is_spent_though_the_ledger_reads_zero() {
// The ledger resets at the window boundary, so growth since the first reading
// would read 0 and the run's cap would start over.
let replies: Vec<_> = (0..8).map(|_| says(&[("swarm", "x")])).collect();
let rig = Rig::new(vec![operator_says(1, "a")], &replies);
let (deliveries, spent, next) = (rig.chat.deliveries.clone(), rig.connector.spent.clone(), Cell::new(1));
let clock = rig.connector.at.clone();
*rig.connector.during.borrow_mut() = Some(Box::new(move || {
next.set(next.get() + 1);
deliveries.borrow_mut().push(operator_says(next.get(), "more"));
spent.set(0);
clock.set(clock.get() + 86_400_000);
}));
let Outcome::Served { failure, .. } = rig.go_with(&Config { max_turns: 3, ..config() }) else { panic!("served") };
assert_eq!((rig.turns(), failure), (1, None));
}
#[test]
fn a_turn_asks_the_connector_for_what_is_left_of_the_run_cap_never_the_whole_cap() {
let replies: Vec<_> = (0..8).map(|_| says(&[("swarm", "x")])).collect();
let rig = Rig::new(vec![operator_says(1, "a")], &replies);
let (deliveries, spent, next) = (rig.chat.deliveries.clone(), rig.connector.spent.clone(), Cell::new(1));
*rig.connector.during.borrow_mut() = Some(Box::new(move || {
next.set(next.get() + 1);
deliveries.borrow_mut().push(operator_says(next.get(), "more"));
spent.set(spent.get() + 4_999_999);
}));
let Outcome::Served { failure, answered, .. } = rig.go_with(&Config { max_turns: 3, ..config() }) else { panic!("served") };
let asked: Vec<u64> = rig.connector.requests.borrow().iter().map(|request| request.max_cost_micro_usd).collect();
// The cap is 5 USD: the first turn may cost it all, the second the one micro-USD left, and the third finds none.
assert_eq!(asked, [5_000_000, 1]);
assert_eq!((failure, answered), (None, 2));
}
#[test]
fn a_readout_with_no_row_for_her_caller_holds_the_run_and_the_trace_says_why() {
let rig = Rig::new(vec![operator_says(1, "a")], &[says(&[("swarm", "x")])]);
rig.connector.no_row.set(true);
let Outcome::Served { failure, answered, .. } = rig.go() else { panic!("served") };
assert_eq!((rig.turns(), answered, failure), (0, 0, Some(Failure::Readout)));
let trace = rig.trace.0.borrow();
assert_eq!(trace.len(), 1);
assert_eq!(trace[0].outcome, "held: the spend readout has no row for her caller");
assert!(rig.chat.acked.borrow().is_empty() && rig.chat.sent.borrow().is_empty());
}
#[test]
fn deliveries_from_different_dm_rooms_are_never_in_one_turn() {
let (a, b) = ("!a:chat", "!b:chat");
let pending = vec![
direct(1, a, "@ann:chat", "secret-a"),
direct(2, b, "@bob:chat", "hi-b"),
delivery(3, "swarm", "@m:chat", "public-c"),
direct(4, a, "@ann:chat", "more-a"),
direct(5, a, "@ann:chat", "again-a"),
];
let replies = [says(&[(a, "1")]), says(&[(b, "2")]), says(&[("swarm", "3")]), says(&[(a, "4")])];
let rig = Rig::new(pending, &replies);
let Outcome::Served { failure, answered, .. } = rig.go() else { panic!("served") };
assert_eq!((failure, answered), (None, 4));
let inputs: Vec<String> = rig.connector.requests.borrow().iter().map(|request| request.input.clone()).collect();
let texts = ["secret-a", "hi-b", "public-c", "more-a"];
for (turn, input) in inputs.iter().enumerate() {
for (other, text) in texts.iter().enumerate() {
assert_eq!(input.contains(text), turn == other, "turn {turn} text {text}");
}
}
assert!(inputs[3].contains("again-a"), "one room's consecutive deliveries share its turn");
assert!(!inputs[0].contains("again-a"), "a later delivery waits for its turn");
assert_eq!(*rig.chat.acked.borrow(), vec![1, 2, 3, 5]);
}

View file

@ -1007,9 +1007,11 @@ mod process {
}
}
/// Whether a process of this id still exists.
/// Whether a process of this id still runs. A zombie is dead: with no init to reap
/// it (cargo as PID 1 in a one-command job) it stays in the table after it is killed.
fn alive(pid: &str) -> bool {
Command::new("kill").args(["-0", pid]).stderr(Stdio::null()).status().unwrap().success()
let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else { return false };
stat.rsplit_once(')').is_some_and(|(_, rest)| !rest.trim_start().starts_with('Z'))
}
#[test]

View file

@ -53,6 +53,11 @@ impl Recording {
self.running.borrow_mut().retain(|unit| *unit != (Slug(INSTANCE.into()), turn));
}
/// One named instance's unit ends.
pub(crate) fn stop(&self, instance: &Slug, turn: RunTurn) {
self.running.borrow_mut().retain(|unit| *unit != (instance.clone(), turn));
}
pub(crate) fn revive(&self, turn: RunTurn) {
self.running.borrow_mut().push((Slug(INSTANCE.into()), turn));
}
@ -84,10 +89,15 @@ impl TestMind {
/// A mind holding its identity, the repo's stewardship, the campaign and a
/// first target.
pub(crate) fn seeded() -> Self {
Self::seeded_as(INSTANCE)
}
/// The same, for another instance name.
pub(crate) fn seeded_as(name: &str) -> Self {
let dir = TempDir::new().unwrap();
let mind = Mind::open(dir.path(), &Slug(INSTANCE.into())).unwrap();
let this = Self { _dir: dir, instance: Slug(INSTANCE.into()), mind: RefCell::new(mind), now: Cell::new(at(0)) };
this.put(vec![instance(), stewardship(), campaign(), target()]);
let mind = Mind::open(dir.path(), &Slug(name.into())).unwrap();
let this = Self { _dir: dir, instance: Slug(name.into()), mind: RefCell::new(mind), now: Cell::new(at(0)) };
this.put(vec![instance(name), stewardship(name), campaign(), target()]);
this
}
@ -95,7 +105,7 @@ impl TestMind {
pub(crate) fn put(&self, documents: Vec<PipelineDocument>) -> PipelineAdmissionOutcome {
self.now.set(self.now.get() + Duration::seconds(1));
let batch = PipelineAdmissionBatch {
instance: Slug(INSTANCE.into()),
instance: self.instance.clone(),
provenance: PipelineProvenance { faculty: Faculty::Hands, agent: "test".into(), session: "test".into(), tool: "test".into() },
documents,
};
@ -146,7 +156,7 @@ impl MindPort for TestMind {
fn admit(&self, agent: &str, session: &str, documents: Vec<PipelineDocument>) -> Result<PipelineAdmissionOutcome> {
self.now.set(self.now.get() + Duration::seconds(1));
let batch = PipelineAdmissionBatch {
instance: Slug(INSTANCE.into()),
instance: self.instance.clone(),
provenance: PipelineProvenance { faculty: Faculty::SelfFaculty, agent: agent.into(), session: session.into(), tool: "mind-body".into() },
documents,
};
@ -178,18 +188,18 @@ fn doc_ref() -> DocRef {
DocRef { path: "docs/eureka-body-target.md".into(), start_line: 1, end_line: 9, commit: Sha("df7507b".into()) }
}
fn instance() -> PipelineDocument {
fn instance(name: &str) -> PipelineDocument {
PipelineDocument::Instance(PipelineInstance {
instance: Slug(INSTANCE.into()),
instance: Slug(name.into()),
display_name: "test mind".into(),
created_at: Date("2026-10-09".into()),
host: "test".into(),
})
}
fn stewardship() -> PipelineDocument {
fn stewardship(name: &str) -> PipelineDocument {
PipelineDocument::Stewardship(PipelineStewardship {
instance: Slug(INSTANCE.into()),
instance: Slug(name.into()),
repo: REPO.into(),
sequence: 1,
assigned_on: Date("2026-10-09".into()),
@ -326,8 +336,12 @@ pub(crate) fn run_ref(label: &str) -> PipelineRef {
}
pub(crate) fn run(label: &str, turn: RunTurn, operated_by: RunOperator, claims: &[PipelineRef]) -> PipelineDocument {
run_in(INSTANCE, label, turn, operated_by, claims)
}
pub(crate) fn run_in(instance: &str, label: &str, turn: RunTurn, operated_by: RunOperator, claims: &[PipelineRef]) -> PipelineDocument {
PipelineDocument::Run(PipelineRun {
instance: Slug(INSTANCE.into()),
instance: Slug(instance.into()),
label: label.into(),
operated_by,
turn,

View file

@ -1,117 +0,0 @@
//! The waker: one cycle, no memory between cycles.
//!
//! A cycle is three steps in a fixed order. (1) The brake and the dial are read
//! once, through `control::derive`, the derivation behind `read_effective` and
//! the unit gate; held ends the cycle before the mind, the clock or a unit is
//! touched. (2) The newest Persona turn of hers is found, whatever became of
//! it, and her cadence (the Grant's, from the dial) is waited out since its
//! admission. (3) `open_and_launch` is called once for a Persona turn with no
//! claims and the Grant, and its answer is the cycle's.
//!
//! The waker owns no file, no timer state and no cursor: the run in the mind is
//! the grant (target invariant run-is-the-grant), and whether she is awake is
//! decided by admission and answered by the launch primitive. The timer that
//! calls a cycle is a fixed poll; it decides nothing. The waker starts no Self
//! run and ends no run or unit.
use std::fmt;
use anyhow::{Result, anyhow, bail};
use chrono::{DateTime, Duration, Utc};
use cultnet_rs::Selection;
use eureka_pipeline::{PipelineKind, PipelineRef, RunOperator, RunTurn, Slug};
use huginn_mind::PipelinePageItems;
use crate::control::{ControlSource, Effective, HeldReason, derive};
use crate::launch::{Declined, LaunchOutcome, LaunchRequest, Ports, open_and_launch};
use crate::queue::{MindPort, any_of, of_kind};
/// The admitting agent's name on the receipt of every run the waker opens.
pub const AGENT: &str = "mind-wake";
/// What one cycle did. Every outcome is an answer; only a mind that cannot be
/// reached is an error.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum WakeOutcome {
/// The brake or the dial did not allow a launch; nothing else was touched.
Held { reason: HeldReason },
/// Her cadence since the newest Persona turn has not passed; `until` is when it has.
NotYet { until: DateTime<Utc> },
/// A Persona turn of hers is live.
Busy { run: PipelineRef },
Launched { run: PipelineRef },
Refused(Declined),
}
impl fmt::Display for WakeOutcome {
/// One line, with ids and times the mind gave and never a value a refusal
/// carries.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Held { reason } => write!(f, "held: {reason:?}"),
Self::NotYet { until } => write!(f, "not yet: until {}", until.to_rfc3339()),
Self::Busy { run } => write!(f, "busy: {}", run.id.0),
Self::Launched { run } => write!(f, "launched: {}", run.id.0),
Self::Refused(declined) => write!(f, "refused: {}", declined_code(declined)),
}
}
}
fn declined_code(declined: &Declined) -> &'static str {
match declined {
Declined::Admission(_) => "admission",
Declined::Conflict => "conflict",
Declined::AlreadyAdmitted => "already_admitted",
Declined::StartFailed => "start_failed",
Declined::GrantForOtherInstance => "grant_for_other_instance",
Declined::PersonaRunTakesNoClaims => "persona_run_takes_claims",
}
}
/// When the newest Persona turn of hers, of any status, was admitted. The
/// spellings of the two filters are the types' own, and one page of one row is
/// asked for: an idle wake is a query.
fn newest_persona_turn(mind: &dyn MindPort, instance: &Slug) -> Result<Option<DateTime<Utc>>> {
let turn = format!("{:?}", RunTurn::PersonaTurn);
let operator = format!("{:?}", RunOperator::Mind);
let runs = any_of(of_kind(PipelineKind::Run), "root", &[&instance.0]);
let selection = Selection { descending: true, limit: Some(1), ..any_of(any_of(runs, "turn", &[&turn]), "operated_by", &[&operator]) };
let PipelinePageItems::Headers(headers) = mind.query(&selection)?.items else {
bail!("the mind answered documents to a header selection");
};
headers
.first()
.map(|newest| {
DateTime::parse_from_rfc3339(&newest.admission.admitted_at)
.map(|at| at.with_timezone(&Utc))
.map_err(|_| anyhow!("the newest Persona turn's admission time is not a timestamp"))
})
.transpose()
}
/// One cycle. `control` is the instance's control store; the reading is taken
/// once and its Grant is the one handed to the launch.
pub fn cycle(ports: &Ports, control: &impl ControlSource) -> Result<WakeOutcome> {
let instance = ports.mind.instance().clone();
let grant = match derive(control, &instance) {
Effective::Held { reason } => return Ok(WakeOutcome::Held { reason }),
Effective::Released(grant) => grant,
};
if let Some(newest) = newest_persona_turn(ports.mind, &instance)? {
let cadence = Duration::from_std(grant.cadence()).map_err(|_| anyhow!("the dial's cadence is out of range"))?;
let until = newest + cadence;
if until > ports.clock.now() {
return Ok(WakeOutcome::NotYet { until });
}
}
let request = LaunchRequest { turn: RunTurn::PersonaTurn, claims: vec![], agent: AGENT.into(), grant };
match open_and_launch(ports, request)? {
LaunchOutcome::Launched { run } => Ok(WakeOutcome::Launched { run }),
LaunchOutcome::Busy { run } => Ok(WakeOutcome::Busy { run }),
LaunchOutcome::Refused(declined) => Ok(WakeOutcome::Refused(declined)),
LaunchOutcome::NotQueued { .. } | LaunchOutcome::Tripped { .. } => bail!("the launch answered a Persona turn with an answer only a Self run has"),
}
}
#[cfg(test)]
mod tests;

View file

@ -1,349 +0,0 @@
//! The waker's tests: a real mind over a temporary directory, the real control
//! store written by the operator's writer and read by the real derivation, a
//! recording launcher and a settable clock. A wait is observed where it is
//! decided, as the `until` the cycle returns, never after a sleep.
use std::cell::Cell;
use std::str::FromStr;
use anyhow::{Result, anyhow};
use chrono::{DateTime, Duration, Utc};
use cultnet_rs::Selection;
use eureka_pipeline::{PipelineDocument, PipelineKind as K, ResolutionOutcome, RunOperator, RunTurn, Slug};
use huginn_mind::{MindRefusal, PipelineAdmissionOutcome, PipelinePageItems, PipelineSelectionPage};
use rust_decimal::Decimal;
use tempfile::TempDir;
use super::*;
use crate::control::{BurnRate, ControlWriter, FileSource};
use crate::launch::Launcher;
use crate::testkit::*;
/// Every call the units port receives, counted over a recording launcher.
struct Units {
rec: Recording,
calls: Cell<usize>,
}
impl Launcher for Units {
fn start(&self, instance: &Slug, turn: RunTurn) -> Result<()> {
self.calls.set(self.calls.get() + 1);
self.rec.start(instance, turn)
}
fn alive(&self, instance: &Slug, turn: RunTurn) -> bool {
self.calls.set(self.calls.get() + 1);
self.rec.alive(instance, turn)
}
}
/// A mind that counts the queries and admissions it is asked for.
struct Counting<'a> {
inner: &'a TestMind,
queries: Cell<usize>,
admits: Cell<usize>,
/// The header rows the queries brought back.
rows: Cell<usize>,
}
impl MindPort for Counting<'_> {
fn instance(&self) -> &Slug {
self.inner.instance()
}
fn query(&self, selection: &Selection) -> Result<PipelineSelectionPage> {
self.queries.set(self.queries.get() + 1);
let page = self.inner.query(selection)?;
if let PipelinePageItems::Headers(headers) = &page.items {
self.rows.set(self.rows.get() + headers.len());
}
Ok(page)
}
fn admit(&self, agent: &str, session: &str, documents: Vec<PipelineDocument>) -> Result<PipelineAdmissionOutcome> {
self.admits.set(self.admits.get() + 1);
self.inner.admit(agent, session, documents)
}
}
/// A mind that cannot be reached.
struct Unreachable(Slug);
impl MindPort for Unreachable {
fn instance(&self) -> &Slug {
&self.0
}
fn query(&self, _selection: &Selection) -> Result<PipelineSelectionPage> {
Err(anyhow!("the mind could not be asked"))
}
fn admit(&self, _agent: &str, _session: &str, _documents: Vec<PipelineDocument>) -> Result<PipelineAdmissionOutcome> {
Err(anyhow!("the mind could not be asked"))
}
}
struct Rig {
mind: TestMind,
launcher: Units,
clock: Fixed,
dir: TempDir,
}
fn dec(text: &str) -> Decimal {
Decimal::from_str(text).unwrap()
}
fn seconds(whole: i64, nanos: i64) -> Duration {
Duration::seconds(whole) + Duration::nanoseconds(nanos)
}
impl Rig {
fn new() -> Self {
Self {
mind: TestMind::seeded(),
launcher: Units { rec: Recording::default(), calls: Cell::new(0) },
clock: Fixed(Cell::new(at(0))),
dir: TempDir::new().unwrap(),
}
}
fn store(&self) -> std::path::PathBuf {
self.dir.path().join(INSTANCE).join("control.cc")
}
/// The brake released and the dial set, through the operator's writer.
fn release(&self, heat: &str, cooldown_s: u32, run_usd: &str) {
let mut writer = ControlWriter::open(self.store()).unwrap();
writer.set_brake(true, at(0), "op").unwrap();
writer.set_dial(BurnRate { heat: dec(heat), base_cooldown_s: cooldown_s, base_run_usd: dec(run_usd), set_at: at(0), set_by: "op".into() }).unwrap();
}
fn cycle(&self) -> WakeOutcome {
self.cycle_through(&self.mind).unwrap()
}
fn cycle_through(&self, mind: &dyn MindPort) -> Result<WakeOutcome> {
let ports = Ports { mind, launcher: &self.launcher, clock: &self.clock, host: "yggdrasil-host" };
super::cycle(&ports, &FileSource::new(self.store()))
}
/// A Persona turn of hers, opened and then ended as given.
fn persona(&self, label: &str, ending: ResolutionOutcome) {
self.mind.committed(vec![run(label, RunTurn::PersonaTurn, RunOperator::Mind, &[])]);
self.mind.committed(vec![close(run_ref(label), ending)]);
}
fn admitted(&self, label: &str) -> DateTime<Utc> {
let stamp = self.mind.view(K::Run, &run_ref(label).id.0).unwrap().admission.admitted_at;
DateTime::parse_from_rfc3339(&stamp).unwrap().with_timezone(&Utc)
}
fn runs(&self) -> usize {
self.mind.ids(K::Run).len()
}
}
#[test]
fn with_the_brake_held_or_the_dial_absent_a_cycle_queries_nothing_admits_nothing_and_calls_no_unit() {
type Prepare = fn(&Rig);
let cases: [(&str, HeldReason, Prepare); 4] = [
("no store", HeldReason::BrakeAbsent, |_| {}),
("brake held", HeldReason::BrakeHeld, |rig| {
rig.release("1", 600, "5");
ControlWriter::open(rig.store()).unwrap().set_brake(false, at(1), "op").unwrap();
}),
("dial absent", HeldReason::DialAbsent, |rig| {
ControlWriter::open(rig.store()).unwrap().set_brake(true, at(0), "op").unwrap();
}),
("store undecodable", HeldReason::Undecodable, |rig| {
rig.release("1", 600, "5");
std::fs::write(rig.store(), b"junk").unwrap();
}),
];
for (name, reason, prepare) in cases {
// No Persona turn yet, so a released brake would launch one.
let rig = Rig::new();
prepare(&rig);
let counting = Counting { inner: &rig.mind, queries: Cell::new(0), admits: Cell::new(0), rows: Cell::new(0) };
let outcome = rig.cycle_through(&counting).unwrap();
assert_eq!(outcome, WakeOutcome::Held { reason }, "{name}");
assert_eq!((counting.queries.get(), counting.admits.get(), rig.launcher.calls.get(), rig.runs()), (0, 0, 0, 0), "{name}");
}
}
#[test]
fn the_wait_is_the_grants_cadence_after_the_newest_persona_turn_and_ends_exactly_there() {
// (heat, base cooldown, the cadence base_cooldown / heat): whole, halved,
// and one that carries nanoseconds, so an offset or scale shows. The last
// three sit beyond a second below, a day above and at the dial's maximum
// (the dial allows all three), so a floor or a ceiling on the wait shows too.
let heats = [
("1", 600, seconds(600, 0)),
("2", 600, seconds(300, 0)),
("1.5", 1000, seconds(666, 666_666_667)),
("0.05", 3600, seconds(72_000, 0)),
("2", 1, seconds(0, 500_000_000)),
("0.05", 10_000, seconds(200_000, 0)),
// The dial's maximum (base cooldown u32::MAX at heat 0.05): a ceiling
// anywhere below it, a week or a year included, changes the wait.
("0.05", 4_294_967_295, seconds(85_899_345_900, 0)),
];
for (heat, cooldown, cadence) in heats {
let rig = Rig::new();
rig.release(heat, cooldown, "5");
rig.persona("p1", recorded());
let until = rig.admitted("p1") + cadence;
rig.clock.0.set(until - Duration::milliseconds(1));
assert_eq!(rig.cycle(), WakeOutcome::NotYet { until }, "heat {heat}, a millisecond early");
assert_eq!((rig.launcher.calls.get(), rig.runs()), (0, 1), "heat {heat}: waiting touches no unit and admits nothing");
rig.clock.0.set(until);
assert!(matches!(rig.cycle(), WakeOutcome::Launched { .. }), "heat {heat}, at the boundary");
assert_eq!(rig.runs(), 2, "heat {heat}");
}
}
#[test]
fn the_same_age_waits_under_one_dial_and_launches_under_another() {
let age = |heat: &str| {
let rig = Rig::new();
rig.release(heat, 600, "5");
rig.persona("p1", recorded());
rig.clock.0.set(rig.admitted("p1") + Duration::seconds(400));
rig.cycle()
};
assert!(matches!(age("1"), WakeOutcome::NotYet { .. }), "600 s cadence at 400 s");
assert!(matches!(age("2"), WakeOutcome::Launched { .. }), "300 s cadence at 400 s");
}
#[test]
fn a_newer_self_run_or_operator_turn_does_not_move_the_boundary() {
let rig = Rig::new();
rig.release("1", 600, "5");
rig.persona("p1", recorded());
let until = rig.admitted("p1") + Duration::seconds(600);
rig.mind.committed(vec![run("s1", RunTurn::SelfRun, RunOperator::Mind, &[])]);
rig.mind.committed(vec![run("op1", RunTurn::PersonaTurn, RunOperator::Operator, &[])]);
assert!(rig.admitted("s1") > rig.admitted("p1") && rig.admitted("op1") > rig.admitted("p1"));
rig.clock.0.set(until - Duration::milliseconds(1));
assert_eq!(rig.cycle(), WakeOutcome::NotYet { until });
rig.clock.0.set(until);
assert!(matches!(rig.cycle(), WakeOutcome::Launched { .. }), "the boundary is still her Persona turn's");
}
#[test]
fn an_ended_persona_turn_sets_the_boundary_whatever_ended_it_and_the_newest_wins() {
for (name, ending) in [("recorded", recorded()), ("withdrawn", withdrawn())] {
let rig = Rig::new();
rig.release("1", 600, "5");
rig.persona("p1", recorded());
rig.persona("p2", ending);
assert!(rig.admitted("p2") > rig.admitted("p1"), "{name}");
let until = rig.admitted("p2") + Duration::seconds(600);
rig.clock.0.set(until - Duration::milliseconds(1));
assert_eq!(rig.cycle(), WakeOutcome::NotYet { until }, "{name}");
}
}
#[test]
fn finding_the_newest_persona_turn_is_one_query_that_reads_one_row_however_many_there_are() {
let rig = Rig::new();
rig.release("1", 600, "5");
for label in ["p1", "p2", "p3"] {
rig.persona(label, recorded());
}
rig.clock.0.set(rig.admitted("p3") + Duration::seconds(1));
let counting = Counting { inner: &rig.mind, queries: Cell::new(0), admits: Cell::new(0), rows: Cell::new(0) };
assert!(matches!(rig.cycle_through(&counting).unwrap(), WakeOutcome::NotYet { .. }));
assert_eq!((counting.queries.get(), counting.rows.get()), (1, 1));
}
#[test]
fn a_live_persona_turn_past_the_cadence_answers_busy_and_the_cycle_admits_nothing_itself() {
let rig = Rig::new();
rig.release("1", 600, "5");
rig.mind.committed(vec![run("p1", RunTurn::PersonaTurn, RunOperator::Mind, &[])]);
rig.launcher.rec.revive(RunTurn::PersonaTurn);
rig.clock.0.set(rig.admitted("p1") + Duration::seconds(601));
let counting = Counting { inner: &rig.mind, queries: Cell::new(0), admits: Cell::new(0), rows: Cell::new(0) };
assert_eq!(rig.cycle_through(&counting).unwrap(), WakeOutcome::Busy { run: run_ref("p1") });
assert_eq!(counting.admits.get(), 0);
assert!(rig.launcher.rec.started.borrow().is_empty());
assert_eq!(rig.runs(), 1);
}
#[test]
fn the_first_released_cycle_launches_a_persona_run_with_no_claims_and_the_grants_cap() {
let rig = Rig::new();
// Decoys: a Self run of hers and an operator's Persona turn are not her Persona turns.
rig.mind.committed(vec![run("s1", RunTurn::SelfRun, RunOperator::Mind, &[])]);
rig.mind.committed(vec![run("op1", RunTurn::PersonaTurn, RunOperator::Operator, &[])]);
rig.release("2", 600, "3.25");
let WakeOutcome::Launched { run: opened } = rig.cycle() else { panic!("not launched") };
let view = rig.mind.view(K::Run, &opened.id.0).unwrap();
let PipelineDocument::Run(held) = &view.document else { panic!("not a run") };
assert_eq!((held.operated_by, held.turn), (RunOperator::Mind, RunTurn::PersonaTurn));
assert!(held.claims.is_empty() && held.campaigns.is_empty());
assert_eq!(held.budget_usd.0, "6.5", "base_run_usd x heat");
assert_eq!(held.host.0, "yggdrasil-host");
assert_eq!(view.admission.provenance.agent.0, "mind-wake");
assert_eq!(*rig.launcher.rec.started.borrow(), vec![(Slug(INSTANCE.into()), RunTurn::PersonaTurn)]);
}
#[test]
fn two_consecutive_cycles_launch_once_and_the_second_waits_or_answers_busy() {
let rig = Rig::new();
rig.release("1", 600, "5");
let WakeOutcome::Launched { run: first } = rig.cycle() else { panic!("not launched") };
let stamp = rig.mind.view(K::Run, &first.id.0).unwrap().admission.admitted_at;
let until = DateTime::parse_from_rfc3339(&stamp).unwrap().with_timezone(&Utc) + Duration::seconds(600);
assert_eq!(rig.cycle(), WakeOutcome::NotYet { until }, "straight after: within the cadence");
rig.clock.0.set(until);
assert_eq!(rig.cycle(), WakeOutcome::Busy { run: first }, "past the cadence with her turn still live");
assert_eq!((rig.runs(), rig.launcher.rec.started.borrow().len()), (1, 1));
}
#[test]
fn a_unit_that_does_not_start_and_an_admission_that_is_refused_are_refusals_not_errors() {
let rig = Rig::new();
rig.release("1", 600, "5");
rig.launcher.rec.fail.set(true);
assert_eq!(rig.cycle(), WakeOutcome::Refused(Declined::StartFailed));
let rig = Rig::new();
rig.release("1", 600, "5");
let refusing = Answering { inner: &rig.mind, outcome: PipelineAdmissionOutcome::Conflict { identities: vec![] } };
assert_eq!(rig.cycle_through(&refusing).unwrap(), WakeOutcome::Refused(Declined::Conflict));
assert!(rig.launcher.rec.started.borrow().is_empty());
}
#[test]
fn a_mind_that_cannot_be_reached_is_an_error_and_starts_nothing() {
let rig = Rig::new();
rig.release("1", 600, "5");
assert!(rig.cycle_through(&Unreachable(Slug(INSTANCE.into()))).is_err());
assert_eq!(rig.launcher.calls.get(), 0);
}
#[test]
fn an_outcome_prints_ids_and_codes_and_never_a_refusals_values() {
let canary = "canary-8c1f";
let refused = Declined::Admission(MindRefusal::RepoNotInCampaign { repo: canary.into() });
assert_eq!(WakeOutcome::Refused(refused).to_string(), "refused: admission");
for (declined, code) in [
(Declined::Conflict, "conflict"),
(Declined::AlreadyAdmitted, "already_admitted"),
(Declined::StartFailed, "start_failed"),
(Declined::GrantForOtherInstance, "grant_for_other_instance"),
(Declined::PersonaRunTakesNoClaims, "persona_run_takes_claims"),
] {
assert_eq!(WakeOutcome::Refused(declined).to_string(), format!("refused: {code}"));
}
assert_eq!(WakeOutcome::Held { reason: HeldReason::BrakeHeld }.to_string(), "held: BrakeHeld");
assert_eq!(WakeOutcome::Launched { run: run_ref("p1") }.to_string(), format!("launched: {INSTANCE}:run:p1"));
assert_eq!(WakeOutcome::Busy { run: run_ref("p1") }.to_string(), format!("busy: {INSTANCE}:run:p1"));
assert_eq!(WakeOutcome::NotYet { until: at(0) }.to_string(), "not yet: until 2026-10-09T13:15:00+00:00");
}

View file

@ -36,18 +36,16 @@ fn show_reports_every_rung_from_an_absent_store_to_released() {
let root = tempfile::tempdir().unwrap();
let (r, out) = go(root.path(), &["--instance", "eureka", "show"], "op", 0);
r.unwrap();
assert!(out.contains("brake: absent") && out.contains("dial: absent") && out.contains("BrakeAbsent"), "{out}");
assert!(out.contains("brake: absent") && out.contains("BrakeAbsent"), "{out}");
go(root.path(), &["--instance", "eureka", "brake", "release"], "alice", 1).0.unwrap();
let (_, out) = go(root.path(), &["--instance", "eureka", "show"], "op", 2);
assert!(out.contains("brake: released") && out.contains("set_by alice") && out.contains("DialAbsent"), "{out}");
assert!(out.contains("brake: released") && out.contains("set_by alice") && out.contains("BrakeAbsent"), "the swarm is still unreleased: {out}");
go(root.path(), &["--instance", "eureka", "dial", "set", "--heat", "0.5", "--base-cooldown-s", "3600", "--base-run-usd", "5"], "bob", 3)
.0
.unwrap();
go(root.path(), &["swarm", "brake", "release"], "bob", 3).0.unwrap();
go(root.path(), &["swarm", "dial", "set", "--heat", "0.5", "--base-rate", "4", "--base-run", "5", "--k", "2"], "bob", 3).0.unwrap();
let (_, out) = go(root.path(), &["--instance", "eureka", "show"], "op", 4);
assert!(out.contains("heat 0.5") && out.contains("base_cooldown_s 3600") && out.contains("set_by bob"), "{out}");
assert!(out.contains("Released") && out.contains("7200s"), "{out}");
assert!(out.contains("Released") && out.contains("run_cap_usd: 2.5"), "{out}");
go(root.path(), &["--instance", "eureka", "brake", "hold"], "alice", 5).0.unwrap();
let (_, out) = go(root.path(), &["--instance", "eureka", "show"], "op", 6);
@ -63,27 +61,6 @@ fn instances_do_not_share_a_store() {
assert!(root.path().join("one").join("control.cc").exists());
}
#[test]
fn a_dial_set_outside_bounds_refuses_and_leaves_the_store_unchanged() {
let root = tempfile::tempdir().unwrap();
go(root.path(), &["--instance", "eureka", "brake", "release"], "op", 0).0.unwrap();
go(root.path(), &["--instance", "eureka", "dial", "set", "--heat", "1", "--base-cooldown-s", "60", "--base-run-usd", "1"], "op", 1).0.unwrap();
let path = root.path().join("eureka").join("control.cc");
let before = std::fs::read(&path).unwrap();
for heat in ["2.0001", "0.0499", "0", "-1"] {
let (r, _) =
go(root.path(), &["--instance", "eureka", "dial", "set", "--heat", heat, "--base-cooldown-s", "60", "--base-run-usd", "1"], "op", 9);
assert!(r.is_err(), "heat {heat} must be refused");
assert_eq!(std::fs::read(&path).unwrap(), before, "heat {heat} changed the store");
}
// A refusal on a store that does not exist yet must not create it.
let fresh = tempfile::tempdir().unwrap();
let (r, _) = go(fresh.path(), &["--instance", "eureka", "dial", "set", "--heat", "5", "--base-cooldown-s", "60", "--base-run-usd", "1"], "op", 0);
assert!(r.is_err());
assert!(!fresh.path().join("eureka").join("control.cc").exists());
}
#[test]
fn malformed_commands_are_refused_and_write_nothing() {
let root = tempfile::tempdir().unwrap();
@ -94,11 +71,6 @@ fn malformed_commands_are_refused_and_write_nothing() {
&["--instance", "eureka", "brake"],
&["--instance", "eureka", "brake", "toggle"],
&["--instance", "eureka", "dial", "set", "--heat", "1"],
&["--instance", "eureka", "dial", "set", "--heat", "1", "--heat", "1", "--base-cooldown-s", "60", "--base-run-usd", "1"],
&["--instance", "eureka", "dial", "set", "--heat", "x", "--base-cooldown-s", "60", "--base-run-usd", "1"],
&["--instance", "eureka", "dial", "set", "--heat", "1", "--base-cooldown-s", "1.5", "--base-run-usd", "1"],
&["--instance", "eureka", "dial", "set", "--heat", "1", "--base-cooldown-s", "60", "--base-run-usd", "1", "--extra", "1"],
&["--instance", "eureka", "dial", "set", "--heat"],
&["--instance", "../evil", "brake", "release"],
&["--instances", "eureka", "brake", "release"],
&["eureka", "eureka", "brake", "release"],
@ -129,7 +101,7 @@ fn the_binary_exits_nonzero_with_the_usage_when_refused() {
}
#[test]
fn the_binary_exits_2_for_check_while_held_and_0_after_release_with_a_dial() {
fn the_binary_exits_2_for_check_while_held_and_0_after_release_with_a_swarm() {
let root = tempfile::tempdir().unwrap();
let run = |args: &[&str]| {
std::process::Command::new(env!("CARGO_BIN_EXE_mind-control"))
@ -146,10 +118,14 @@ fn the_binary_exits_2_for_check_while_held_and_0_after_release_with_a_dial() {
assert!(String::from_utf8_lossy(&output.stderr).contains("held: BrakeAbsent"));
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 0);
// Released with no dial is still refused; with a dial it opens.
// Released with no swarm is still refused; with a released swarm it opens.
assert!(run(&["brake", "release"]).status.success());
assert_eq!(run(&["check"]).status.code(), Some(2));
assert!(run(&["dial", "set", "--heat", "0.5", "--base-cooldown-s", "3600", "--base-run-usd", "5"]).status.success());
let swarm = |args: &[&str]| {
std::process::Command::new(env!("CARGO_BIN_EXE_mind-control")).arg("--root").arg(root.path()).arg("swarm").args(args).output().unwrap()
};
assert!(swarm(&["brake", "release"]).status.success());
assert!(swarm(&["dial", "set", "--heat", "0.5", "--base-rate", "4", "--base-run", "5", "--k", "2"]).status.success());
let open = run(&["check"]);
assert_eq!(open.status.code(), Some(0));
assert!(String::from_utf8_lossy(&open.stdout).contains("Released"));
@ -212,6 +188,8 @@ fn check_refuses_every_reading_but_released() {
go(root.path(), &["--instance", "eureka", "brake", "hold"], "op", 1).0.unwrap();
held(root.path(), "BrakeHeld");
go(root.path(), &["--instance", "eureka", "brake", "release"], "op", 2).0.unwrap();
held(root.path(), "BrakeAbsent");
go(root.path(), &["swarm", "brake", "release"], "op", 3).0.unwrap();
held(root.path(), "DialAbsent");
// DialOutOfBounds cannot be written through the CLI: it is pinned in the crate's own tests.
// An undecodable store is a held reading too.
@ -222,12 +200,11 @@ fn check_refuses_every_reading_but_released() {
}
#[test]
fn check_opens_on_release_with_a_dial_and_is_per_instance() {
fn check_opens_on_release_with_a_swarm_and_is_per_instance() {
let root = tempfile::tempdir().unwrap();
go(root.path(), &["--instance", "eureka", "brake", "release"], "op", 0).0.unwrap();
go(root.path(), &["--instance", "eureka", "dial", "set", "--heat", "0.5", "--base-cooldown-s", "3600", "--base-run-usd", "5"], "op", 1)
.0
.unwrap();
go(root.path(), &["swarm", "brake", "release"], "op", 1).0.unwrap();
go(root.path(), &["swarm", "dial", "set", "--heat", "0.5", "--base-rate", "4", "--base-run", "5", "--k", "2"], "op", 1).0.unwrap();
let (result, out) = check_out(root.path());
result.unwrap();
assert!(out.contains("Released"), "{out}");
@ -240,13 +217,12 @@ fn check_opens_on_release_with_a_dial_and_is_per_instance() {
fn check_holds_for_the_named_instance_with_a_released_prefix_neighbour() {
let root = tempfile::tempdir().unwrap();
go(root.path(), &["--instance", "eureka", "brake", "hold"], "op", 0).0.unwrap();
go(root.path(), &["swarm", "brake", "release"], "op", 0).0.unwrap();
go(root.path(), &["swarm", "dial", "set", "--heat", "0.5", "--base-rate", "4", "--base-run", "5", "--k", "2"], "op", 0).0.unwrap();
// Neighbours that share the held instance's text as a prefix, or only an edge of it, are released with a dial.
for (i, neighbour) in ["eureka-two", "eureka2", "xeureka"].into_iter().enumerate() {
let at = 1 + 2 * i as i64;
go(root.path(), &["--instance", neighbour, "brake", "release"], "op", at).0.unwrap();
go(root.path(), &["--instance", neighbour, "dial", "set", "--heat", "0.5", "--base-cooldown-s", "3600", "--base-run-usd", "5"], "op", at + 1)
.0
.unwrap();
let (open, _) = go(root.path(), &["--instance", neighbour, "check"], "op", 10);
open.unwrap_or_else(|error| panic!("{neighbour} is released and must open: {error:#}"));
}
@ -304,14 +280,14 @@ fn brake_hold_stops_once_with_the_instance_and_the_store_already_held() {
#[test]
fn only_brake_hold_calls_the_stopper() {
let root = tempfile::tempdir().unwrap();
let dial = ["--instance", "eureka", "dial", "set", "--heat", "1", "--base-cooldown-s", "60", "--base-run-usd", "1"];
let dial = ["swarm", "dial", "set", "--heat", "1", "--base-rate", "4", "--base-run", "1", "--k", "2"];
let commands: &[&[&str]] = &[
&["--instance", "eureka", "brake", "release"],
&dial,
&["--instance", "eureka", "show"],
&["--instance", "eureka", "brake"],
&["--instance", "eureka", "brake", "toggle"],
&["--instance", "eureka", "dial", "set", "--heat", "99", "--base-cooldown-s", "60", "--base-run-usd", "1"],
&["swarm", "dial", "set", "--heat", "99", "--base-rate", "4", "--base-run", "1", "--k", "2"],
&["--instance", "../evil", "brake", "release"],
&["--instance", "eureka", "brake", "hold", "now"],
&[],

View file

@ -6,6 +6,8 @@
use std::net::TcpListener;
use std::sync::{Arc, Mutex};
use mind_body::heartbeat::Models;
use rust_decimal::Decimal;
use mind_body::connector::{KitConnector, endpoint, key};
use mind_body::persona::{Connector, TurnRequest};
use model_connector::{
@ -16,6 +18,8 @@ use model_connector::{
const CALLER: &str = "eureka";
const MODEL: &str = "claude-test-model";
const KEY: &str = "connector-key-for-test";
const HEARTBEAT: &str = "heartbeat";
const HEARTBEAT_KEY: &str = "the-heartbeats-own-key";
enum Behaviour {
Reply(&'static str),
@ -81,11 +85,13 @@ fn daemon(behaviour: Behaviour) -> Daemon {
let prices = PriceTable::from([(MODEL.to_string(), ModelPrice { rates: rate, long_context: None, overhead_tokens: 0 })]);
let admission =
CallerAdmission::new(CALLER, key(KEY), 1, [MODEL.to_string()], 2, 64 * 1024, 8192, SpendCeiling(1 << 40), SpendWindowMs(3_600_000)).unwrap();
let observer =
CallerAdmission::new(HEARTBEAT, key(HEARTBEAT_KEY), 1, [MODEL.to_string()], 2, 64 * 1024, 8192, SpendCeiling(1 << 40), SpendWindowMs(3_600_000)).unwrap();
let limits = ServeLimits { max_frame_bytes: 256 * 1024, max_connections: 8, socket_timeout_ms: 5_000, max_expiry_skew_ms: 300_000 };
let provider = Arc::new(FakeProvider { behaviour, seen: Mutex::default() });
let backend = provider.clone();
std::thread::spawn(move || {
let _ = serve(listener, limits, vec![admission], prices, &replay, backend);
let _ = serve(listener, limits, vec![admission, observer], prices, &replay, backend);
});
Daemon { endpoint, provider, _store: store }
}
@ -161,3 +167,14 @@ fn endpoints_are_the_connectors_loopback_tcp_address_and_a_rudp_url_is_refused()
assert_eq!(key("secret\r\n"), "secret");
assert_eq!(key("a b\n\n"), "a b");
}
#[test]
fn the_heartbeat_reads_a_personas_spend_as_itself_and_picks_her_row() {
let daemon = daemon(Behaviour::Reply("{}"));
connector(&daemon, MODEL).turn(&turn_request()).unwrap();
let heartbeat = KitConnector::new(endpoint(&daemon.endpoint).unwrap(), HEARTBEAT_KEY, MODEL, HEARTBEAT).unwrap();
let spent = Models::spent(&heartbeat, CALLER).expect("a persona's row, asked under the heartbeat's own key");
assert_eq!(spent.usd, Decimal::new(13, 6));
assert_eq!(Models::spent(&heartbeat, HEARTBEAT).unwrap().usd, Decimal::ZERO, "its own row is there too");
assert!(Models::spent(&heartbeat, "nobody").is_err(), "a caller with no row is an error, not zero");
}

View file

@ -19,60 +19,12 @@ use std::time::Duration;
use chrono::{DateTime, TimeZone, Utc};
use eureka_pipeline::Slug;
use mind_body::control::{BurnRate, ControlWriter, FileSource, SwarmWriter, control_path, load_state, swarm_path};
use rust_decimal::Decimal;
use std::str::FromStr;
fn dec(text: &str) -> Decimal {
Decimal::from_str(text).unwrap()
}
use mind_body::control::{ControlWriter, FileSource, SwarmWriter, control_path, load_state, swarm_path};
fn at(seconds: i64) -> DateTime<Utc> {
Utc.timestamp_opt(1_800_000_000 + seconds, 0).unwrap()
}
fn dial(heat: &str, cooldown: u32, run_usd: &str) -> BurnRate {
BurnRate { heat: dec(heat), base_cooldown_s: cooldown, base_run_usd: dec(run_usd), set_at: at(0), set_by: "op".into() }
}
/// The store of the instance `eureka` under the root `dir`.
fn store_path(dir: &Path) -> PathBuf {
dir.join("eureka").join("control.cc")
}
/// A written store: brake released and the given dial, through the real writer.
fn released_store(dir: &Path, dial: BurnRate) -> PathBuf {
let path = store_path(dir);
let mut writer = ControlWriter::open(&path).unwrap();
writer.set_brake(true, at(0), "op").unwrap();
writer.set_dial(dial).unwrap();
path
}
#[test]
fn setting_the_dial_replaces_it_whole_and_keeps_the_brake() {
let dir = tempfile::tempdir().unwrap();
let path = released_store(dir.path(), dial("1", 3600, "1"));
let mut next = dial("0.5", 100, "8");
next.set_at = at(30);
next.set_by = "carol".into();
ControlWriter::open(&path).unwrap().set_dial(next.clone()).unwrap();
let state = load_state(&FileSource::new(&path)).unwrap();
assert_eq!(state.dial, Some(next));
assert!(state.brake.unwrap().released);
}
#[test]
fn the_writer_refuses_an_out_of_bounds_dial_and_leaves_the_store_unchanged() {
let dir = tempfile::tempdir().unwrap();
let path = released_store(dir.path(), dial("1", 3600, "1"));
let before = std::fs::read(&path).unwrap();
for bad in [dial("2.0001", 3600, "1"), dial("0.0499", 3600, "1"), dial("1", 0, "1"), dial("1", 3600, "0")] {
assert!(ControlWriter::open(&path).unwrap().set_dial(bad).is_err());
assert_eq!(std::fs::read(&path).unwrap(), before);
}
}
#[test]
fn the_control_path_is_derived_from_a_valid_instance_only() {
let root = Path::new(mind_body::control::CONTROL_ROOT);
@ -107,7 +59,6 @@ mod modes {
"write" => {
let mut writer = ControlWriter::open(&path).unwrap();
writer.set_brake(true, at(0), "op").unwrap();
writer.set_dial(dial("1", 3600, "1")).unwrap();
}
"brake" => {
ControlWriter::open(&path).unwrap().set_brake(true, at(0), "op").unwrap();
@ -339,11 +290,7 @@ mod unsafe_installs {
.unwrap();
let bin = env!("CARGO_BIN_EXE_mind-control");
for round in 0..100 {
let verb: &[&str] = if round % 2 == 0 {
&["brake", "release"]
} else {
&["dial", "set", "--heat", "1", "--base-cooldown-s", "60", "--base-run-usd", "1"]
};
let verb: &[&str] = if round % 2 == 0 { &["brake", "release"] } else { &["show"] };
let status = Command::new("sh")
.args(["-c", "umask 000; exec \"$@\"", "sh", bin, "--root", minds.to_str().unwrap(), "--instance", &instance])
.args(verb)

View file

@ -0,0 +1 @@
’¥ghost®UnknownPersona

View file

@ -15,8 +15,7 @@ use std::path::Path;
use chrono::{TimeZone, Utc};
use eureka_pipeline::Slug;
use mind_body::control::{BurnRate, ControlWriter, Effective, HeldReason, control_path, read_effective};
use rust_decimal::Decimal;
use mind_body::control::{ControlWriter, Effective, HeldReason, control_path, read_effective};
/// A name no real install has a store for under the real root.
const FORGED: &str = "forge-probe-instance";
@ -26,9 +25,6 @@ fn release(root: &Path, instance: &str) {
let mut writer = ControlWriter::open(control_path(root, &Slug(instance.into())).unwrap()).unwrap();
let at = Utc.timestamp_opt(1_800_000_000, 0).unwrap();
writer.set_brake(true, at, "forger").unwrap();
writer
.set_dial(BurnRate { heat: Decimal::ONE, base_cooldown_s: 3600, base_run_usd: Decimal::new(5, 0), set_at: at, set_by: "forger".into() })
.unwrap();
}
fn reads(instance: &str) -> Effective {

View file

@ -3,24 +3,31 @@ mind_body: mod chat_rudp
mind_body: mod cli
mind_body: mod connector
mind_body: mod control
mind_body: mod heartbeat
mind_body: mod launch
mind_body: mod lease_client
mind_body: mod persona
mind_body: mod queue
mind_body: mod self_run
mind_body: mod wake
mind_body::chat_client: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum Refusal { Brake , PersonaMismatch , UnknownRoom , EmptyText , SendFailed , UnknownPersona , }
mind_body::chat_client: const ACK : & str
mind_body::chat_client: const PENDING : & str
mind_body::chat_client: const PENDING_REQUEST : & str
mind_body::chat_client: const REFUSAL : & str
mind_body::chat_client: const SEND_RECEIPT : & str
mind_body::chat_client: const SEND_REQUEST : & str
mind_body::chat_client: fn decode < T : DeserializeOwned > (bytes : & [u8]) -> Result < T >
mind_body::chat_client: fn encode < T : Serialize > (document : & T) -> Result < Vec < u8 > >
mind_body::chat_client: impl Refusal
mind_body::chat_client: impl Refusal: fn name (self) -> & 'static str
mind_body::chat_client: impl Serialize for Refusal
mind_body::chat_client: impl< 'de > Deserialize < 'de > for Refusal
mind_body::chat_client: pub trait Chat { fn pending (& self , after_seq : u64) -> Result < Vec < ChatDelivery > > ; fn send (& self , request : & ChatSendRequest) -> Result < ChatSendReceipt > ; fn ack (& self , through_seq : u64) -> Result < () > ; }
mind_body::chat_client: struct ChatAck # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[persona: String, through_seq: u64]
mind_body::chat_client: struct ChatDelivery # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[seq: u64, room_alias: String, room_id: String, event_id: String, sender: String, sender_display: String, text: String, at_unix_ms: u64, context: Vec < ContextLine >]
mind_body::chat_client: struct ChatDelivery # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[seq: u64, room_alias: String, room_id: String, event_id: String, sender: String, sender_display: String, text: String, at_unix_ms: u64, context: Vec < ContextLine >, mentions: Vec < String >, is_direct: bool]
mind_body::chat_client: struct ChatPending # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[persona: String, deliveries: Vec < ChatDelivery >]
mind_body::chat_client: struct ChatPendingRequest # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[persona: String, after_seq: u64]
mind_body::chat_client: struct ChatRefusal # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[persona: String, refusal: Refusal]
mind_body::chat_client: struct ChatSendReceipt # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[request_id: String, event_id: Option < String >, refusal: Option < String >]
mind_body::chat_client: struct ChatSendRequest # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[request_id: String, persona: String, room_alias: String, text: String]
mind_body::chat_client: struct ContextLine # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[sender: String, text: String, at_unix_ms: u64]
@ -46,23 +53,22 @@ mind_body::control: # [derive (Debug , PartialEq , Eq)] pub enum SwarmEffective
mind_body::control: const BRAKE_KEY : & str
mind_body::control: const CONTROL_ROOT : & str
mind_body::control: const DIAL_KEY : & str
mind_body::control: const K_MAX : u32
mind_body::control: const SWARM_FILE : & str
mind_body::control: fn base_rate_max () -> Decimal
mind_body::control: fn base_run_max () -> Decimal
mind_body::control: fn control_path (root : & Path , instance : & Slug) -> Result < PathBuf >
mind_body::control: fn heat_max () -> Decimal
mind_body::control: fn heat_min () -> Decimal
mind_body::control: fn load_state (source : & impl ControlSource) -> Result < ControlState >
mind_body::control: fn load_swarm_state (source : & impl ControlSource) -> Result < SwarmState >
mind_body::control: fn read_effective (instance : & Slug) -> Effective
mind_body::control: fn read_envelope () -> SwarmEffective
mind_body::control: fn swarm_path (root : & Path) -> PathBuf
mind_body::control: impl BurnRate
mind_body::control: impl BurnRate: fn in_bounds (& self) -> bool
mind_body::control: impl ControlSource for FileSource
mind_body::control: impl ControlState
mind_body::control: impl ControlWriter
mind_body::control: impl ControlWriter: fn open (path : impl Into < PathBuf >) -> Result < Self >
mind_body::control: impl ControlWriter: fn set_brake (& mut self , released : bool , set_at : DateTime < Utc > , set_by : & str) -> Result < () >
mind_body::control: impl ControlWriter: fn set_dial (& mut self , dial : BurnRate) -> Result < () >
mind_body::control: impl Envelope
mind_body::control: impl Envelope: fn grant_for (& self , instance : & Slug , instance_effective : & Effective , budget_usd : Decimal) -> Option < Grant >
mind_body::control: impl Envelope: fn k (& self) -> u32
@ -72,7 +78,6 @@ mind_body::control: impl Envelope: fn share (& self , instance : & Slug) -> Deci
mind_body::control: impl FileSource
mind_body::control: impl FileSource: fn new (path : impl Into < PathBuf >) -> Self
mind_body::control: impl Grant
mind_body::control: impl Grant: fn cadence (& self) -> Duration
mind_body::control: impl Grant: fn instance (& self) -> & Slug
mind_body::control: impl Grant: fn run_cap_usd (& self) -> Decimal
mind_body::control: impl Share
@ -89,8 +94,7 @@ mind_body::control: impl std :: error :: Error for UnsafeStore
mind_body::control: impl std :: fmt :: Display for UnsafeStore
mind_body::control: pub trait ControlSource { fn snapshot (& self) -> Result < Vec < CultCacheEnvelope > > ; }
mind_body::control: struct Brake # [derive (Clone , Debug , PartialEq , Eq , DatabaseEntry)] pub-fields[released: bool, set_at: DateTime < Utc >, set_by: String]
mind_body::control: struct BurnRate # [derive (Clone , Debug , PartialEq , Eq , DatabaseEntry)] pub-fields[heat: Decimal, base_cooldown_s: u32, base_run_usd: Decimal, set_at: DateTime < Utc >, set_by: String]
mind_body::control: struct ControlState # [derive (Clone , Debug , PartialEq , Eq , Default)] pub-fields[brake: Option < Brake >, dial: Option < BurnRate >]
mind_body::control: struct ControlState # [derive (Clone , Debug , PartialEq , Eq , Default)] pub-fields[brake: Option < Brake >]
mind_body::control: struct ControlWriter pub-fields[]
mind_body::control: struct Envelope # [derive (Debug , PartialEq , Eq)] pub-fields[]
mind_body::control: struct FileSource pub-fields[]
@ -100,6 +104,46 @@ mind_body::control: struct SwarmDial # [derive (Clone , Debug , PartialEq , Eq ,
mind_body::control: struct SwarmState # [derive (Clone , Debug , PartialEq , Eq , Default)] pub-fields[brake: Option < Brake >, dial: Option < SwarmDial >, shares: Vec < Share >]
mind_body::control: struct SwarmWriter pub-fields[]
mind_body::control: struct UnsafeStore # [derive (Debug)] pub-fields[path: PathBuf, mode: u32, uid: u32]
mind_body::heartbeat: # [derive (Clone , Debug , PartialEq , Eq)] pub enum Outcome { Held (HeldReason) , Polled { launched : Vec < Slug > , } , }
mind_body::heartbeat: const AGENT : & str
mind_body::heartbeat: const CLASSIFIER_CALLER : & str
mind_body::heartbeat: fn poll (organs : & Organs , config : & Config) -> Result < Outcome >
mind_body::heartbeat: fn store_classifier_config (path : impl Into < PathBuf > , config : & ClassifierConfig) -> Result < () >
mind_body::heartbeat: impl ClassifierConfig
mind_body::heartbeat: impl ClassifierConfig: fn standard () -> Self
mind_body::heartbeat: impl ClassifierConfig: fn validate (& self) -> Result < () >
mind_body::heartbeat: impl Config
mind_body::heartbeat: impl Config: fn new (personas : Vec < PersonaId > , operators : Vec < String >) -> Result < Self >
mind_body::heartbeat: impl Control for FileControl
mind_body::heartbeat: impl FileStore
mind_body::heartbeat: impl FileStore: fn new (path : impl Into < PathBuf >) -> Self
mind_body::heartbeat: impl FileTunables
mind_body::heartbeat: impl FileTunables: fn new (path : impl Into < PathBuf >) -> Self
mind_body::heartbeat: impl Initiative
mind_body::heartbeat: impl Models for KitConnector
mind_body::heartbeat: impl PersonaId
mind_body::heartbeat: impl PersonaId: fn parse (spec : & str) -> Result < Self >
mind_body::heartbeat: impl Store for FileStore
mind_body::heartbeat: impl Tunables for FileTunables
mind_body::heartbeat: impl fmt :: Display for Outcome
mind_body::heartbeat: impl fmt :: Display for PollTrace
mind_body::heartbeat: pub trait Control { fn envelope (& self) -> SwarmEffective ; fn effective (& self , instance : & Slug) -> Effective ; }
mind_body::heartbeat: pub trait Models { fn classify (& self , request : & TurnRequest) -> Result < String > ; fn spent (& self , caller : & str) -> Result < Spend > ; }
mind_body::heartbeat: pub trait Store { fn load (& self , instance : & Slug) -> Result < Option < Initiative > > ; fn save (& self , instance : & Slug , initiative : & Initiative) -> Result < () > ; fn record (& self , trace : & PollTrace) -> Result < () > ; fn latest (& self) -> Result < Option < PollTrace > > ; }
mind_body::heartbeat: pub trait Tunables { fn classifier (& self) -> Result < ClassifierConfig > ; }
mind_body::heartbeat: struct BurnTrace # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[run: String, spend_usd: Decimal, source: String]
mind_body::heartbeat: struct ClassifierConfig # [derive (Clone , Debug , PartialEq , Eq , DatabaseEntry)] pub-fields[instructions: String, show_idle: bool, show_urgency: bool, show_completions: bool, urgency_min: Decimal, urgency_max: Decimal, urgency_ttl_s: u32]
mind_body::heartbeat: struct Config # [derive (Clone , Debug , PartialEq , Eq)] pub-fields[personas: Vec < PersonaId >, operators: Vec < String >]
mind_body::heartbeat: struct FileControl pub-fields[]
mind_body::heartbeat: struct FileStore pub-fields[]
mind_body::heartbeat: struct FileTunables pub-fields[]
mind_body::heartbeat: struct Initiative # [derive (Clone , Debug , PartialEq , Eq , DatabaseEntry)] pub-fields[initiative_usd: Decimal, as_of: DateTime < Utc >, urgency: Decimal, urgency_at: DateTime < Utc >, last_burned_run: String, filled_events: Vec < String >, classified_seq: String, launched_run: String, launch_spend_usd: Decimal, launch_spend_known: bool, completions_through: String, launch_window: u64]
mind_body::heartbeat: struct InstanceTrace # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[instance: String, initiative_usd: Decimal, rate_usd_per_h: Decimal, urgency: Decimal, urgency_age_s: i64, busy: bool, released: bool, crossing_at: String, waited_s: i64, idle_s: i64, filled_by: String, fill_reason: String, note: String]
mind_body::heartbeat: struct Organs< 'a > pub-fields[seats: & 'a [Seat < 'a >], control: & 'a dyn Control, models: & 'a dyn Models, tunables: & 'a dyn Tunables, launcher: & 'a dyn Launcher, clock: & 'a dyn Clock, host: & 'a str, store: & 'a dyn Store]
mind_body::heartbeat: struct PersonaId # [derive (Clone , Debug , PartialEq , Eq)] pub-fields[slug: Slug, public_name: String, handle: String, matrix_id: String, huginn: SocketAddr]
mind_body::heartbeat: struct PollTrace # [derive (Clone , Debug , PartialEq , Eq , DatabaseEntry)] pub-fields[at: DateTime < Utc >, instances: Vec < InstanceTrace >, launches: Vec < String >, burns: Vec < BurnTrace >, classifier: String]
mind_body::heartbeat: struct Seat< 'a > pub-fields[who: PersonaId, mind: & 'a dyn MindPort, chat: & 'a dyn Chat]
mind_body::heartbeat: struct Spend # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub-fields[usd: Decimal, window: u64]
mind_body::launch: # [derive (Clone , Debug , PartialEq , Eq)] pub enum Declined { Admission (MindRefusal) , Conflict , AlreadyAdmitted , StartFailed , GrantForOtherInstance , PersonaRunTakesNoClaims , }
mind_body::launch: # [derive (Clone , Debug , PartialEq , Eq)] pub enum LaunchOutcome { Launched { run : PipelineRef , } , NotQueued { item : PipelineRef , } , Tripped { item : PipelineRef , question : PipelineRef , } , Busy { run : PipelineRef , } , Refused (Declined) , }
mind_body::launch: const START_GRACE_S : i64
@ -144,7 +188,7 @@ mind_body::lease_client: struct TokenFd pub-fields[]
mind_body::lease_client: struct TokenUsage # [derive (Clone , Debug , Default , PartialEq , Eq , Serialize , Deserialize)] pub-fields[input_tokens: u64, cache_read_tokens: u64, cache_write_tokens: u64, output_tokens: u64, reasoning_tokens: Option < u64 >]
mind_body::lease_client::socket: impl Lease for Granted
mind_body::persona: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum Failure { Held (HeldReason) , Chat , Persona , Readout , Connector , Undecodable , RoomNotAllowed , Send , SelfRun , Ack , }
mind_body::persona: # [derive (Debug , PartialEq , Eq)] pub enum Outcome { Dead (HeldReason) , NoRun (Declined) , Served { run : PipelineRef , answered : u32 , failure : Option < Failure > , self_run : Option < PipelineRef > } , }
mind_body::persona: # [derive (Debug , PartialEq , Eq)] pub enum Outcome { Dead (HeldReason) , NoRun , Served { run : PipelineRef , answered : u32 , failure : Option < Failure > , self_run : Option < PipelineRef > } , }
mind_body::persona: const AGENT : & str
mind_body::persona: const ENV_BURN_ROOM : & str
mind_body::persona: const ENV_OPERATOR : & str
@ -153,7 +197,7 @@ mind_body::persona: const ENV_SELF_SENDER : & str
mind_body::persona: const MAX_TURNS_PER_START : u32
mind_body::persona: fn burn_line (readout : & SpendReadout , caller : & str , heat : Option < Decimal > , live_runs : usize) -> Option < String >
mind_body::persona: fn reply_schema (rooms : & [String]) -> String
mind_body::persona: fn start (organs : & Organs , control : & impl ControlSource , config : & Config) -> Result < Outcome >
mind_body::persona: fn start (organs : & Organs , control : & impl ControlSource , swarm : & impl ControlSource , config : & Config) -> Result < Outcome >
mind_body::persona: impl Config
mind_body::persona: impl Config: fn from_args (args : & [String] , usage : & str) -> Result < (Self , Vec < String >) >
mind_body::persona: impl Config: fn from_args_and_env (args : & [String] , lookup : impl Fn (& str) -> Option < String > , usage : & str) -> Result < (Self , Vec < String >) >
@ -161,7 +205,6 @@ mind_body::persona: impl FileTrace
mind_body::persona: impl FileTrace: fn new (path : impl Into < PathBuf >) -> Self
mind_body::persona: impl HuginnPersona
mind_body::persona: impl HuginnPersona: fn new (client : HuginnClient) -> Self
mind_body::persona: impl Launcher for ThisUnit
mind_body::persona: impl PersonaState for HuginnPersona
mind_body::persona: impl Sender
mind_body::persona: impl Trace for FileTrace
@ -230,7 +273,3 @@ mind_body::self_run: impl fmt :: Display for SelfRunOutcome
mind_body::self_run: pub trait Child { fn write_line (& mut self , line : & str) ; fn close_input (& mut self) ; fn next (& mut self , timeout : StdDuration) -> Read ; fn stop (& mut self , how : Stop) -> bool ; }
mind_body::self_run: pub trait Spawner { fn spawn (& self , args : & [String] , token : & TokenFd) -> Result < Box < dyn Child > > ; }
mind_body::self_run: struct ClaudeSpawner pub-fields[]
mind_body::wake: # [derive (Clone , Debug , PartialEq , Eq)] pub enum WakeOutcome { Held { reason : HeldReason } , NotYet { until : DateTime < Utc > } , Busy { run : PipelineRef } , Launched { run : PipelineRef } , Refused (Declined) , }
mind_body::wake: const AGENT : & str
mind_body::wake: fn cycle (ports : & Ports , control : & impl ControlSource) -> Result < WakeOutcome >
mind_body::wake: impl fmt :: Display for WakeOutcome

View file

@ -1,205 +0,0 @@
//! The `mind-wake` binary as a process: its arguments and its exit codes. The
//! brake's root is a constant, so a process test can only ever see the absent
//! store of an instance no host has; the cycle's behaviour is tested in the
//! library against a real mind and a real store. One test, `#[ignore]`d outside a
//! container, puts the real binary, the real daemon and a real store at the
//! brake's root together, which is where the host a run records can be seen.
use std::net::UdpSocket;
use std::path::Path;
use std::process::{Child, Command, Output, Stdio};
use std::str::FromStr;
use std::time::Duration;
use chrono::Utc;
use cultnet_rs::Selection;
use eureka_pipeline::{
Date, DocRef, PipelineCampaign, PipelineDocument, PipelineInstance, PipelineKind, PipelineStewardship, PipelineTarget, Sha, Slug, TargetInvariant,
};
use huginn_mind::{Faculty, Mind, PipelineAdmissionBatch, PipelineAdmissionOutcome, PipelinePageItems, PipelineProvenance};
use mind_body::control::{BurnRate, CONTROL_ROOT, ControlWriter, control_path};
use rust_decimal::Decimal;
use tempfile::TempDir;
const NO_SUCH_INSTANCE: &str = "wake-test-no-such-instance";
fn wake(args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_mind-wake")).args(args).env_remove("HUGINN_ENDPOINT").output().unwrap()
}
/// The binary with `HUGINN_ENDPOINT` set to `endpoint`.
fn wake_with_env_endpoint(args: &[&str], endpoint: &str) -> Output {
Command::new(env!("CARGO_BIN_EXE_mind-wake")).args(args).env("HUGINN_ENDPOINT", endpoint).output().unwrap()
}
#[test]
fn without_an_instance_or_with_a_stray_argument_it_exits_2_with_the_usage_and_echoes_nothing() {
let canary = "canary-argument-3f9a";
for args in [vec![], vec!["--endpoint", "rudp://127.0.0.1:9"], vec!["--instance", NO_SUCH_INSTANCE, canary], vec!["--instance", NO_SUCH_INSTANCE, "--instance", canary]] {
let output = wake(&args);
assert_eq!(output.status.code(), Some(2), "{args:?}");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("usage: mind-wake"), "{args:?}: {stderr}");
assert!(!stderr.contains(canary) && output.stdout.is_empty(), "{args:?}");
}
}
#[test]
fn an_instance_that_is_not_a_slug_is_exit_2_and_is_not_echoed() {
let canary = "Canary/../Instance-5e2b";
let output = wake(&["--instance", canary, "--endpoint", "rudp://127.0.0.1:9"]);
assert_eq!(output.status.code(), Some(2));
assert!(!String::from_utf8_lossy(&output.stderr).contains("anary"), "{}", String::from_utf8_lossy(&output.stderr));
}
#[test]
fn a_held_brake_ends_the_cycle_with_exit_0_before_the_mind_is_contacted() {
// The endpoint answers no one (port 9 on loopback), so a cycle that
// reached the mind would exit 2; the absent store holds the cycle first.
let output = wake(&["--instance", NO_SUCH_INSTANCE, "--endpoint", "rudp://127.0.0.1:9"]);
assert_eq!(output.status.code(), Some(0), "{}", String::from_utf8_lossy(&output.stderr));
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "held: BrakeAbsent");
}
#[test]
fn a_malformed_endpoint_or_none_is_exit_2_and_the_endpoint_is_not_echoed() {
let canary = "canary-endpoint-77c1";
let bad = wake(&["--instance", NO_SUCH_INSTANCE, "--endpoint", canary]);
assert_eq!(bad.status.code(), Some(2));
assert!(!String::from_utf8_lossy(&bad.stderr).contains(canary));
let none = wake(&["--instance", NO_SUCH_INSTANCE]);
assert_eq!(none.status.code(), Some(2));
}
#[test]
fn without_the_flag_the_daemon_is_found_through_huginn_endpoint_and_a_bad_value_is_not_echoed() {
// With no endpoint at all the cycle exits 2 (above); with the variable set it
// goes on to the brake, which holds an instance no host has, and exits 0.
let held = wake_with_env_endpoint(&["--instance", NO_SUCH_INSTANCE], "rudp://127.0.0.1:9");
assert_eq!(held.status.code(), Some(0), "{}", String::from_utf8_lossy(&held.stderr));
assert_eq!(String::from_utf8_lossy(&held.stdout).trim(), "held: BrakeAbsent");
let canary = "canary-env-endpoint-4d02";
let bad = wake_with_env_endpoint(&["--instance", NO_SUCH_INSTANCE], canary);
assert_eq!(bad.status.code(), Some(2));
assert!(!String::from_utf8_lossy(&bad.stderr).contains(canary) && bad.stdout.is_empty());
// The flag wins over the variable: a valid flag with a bad variable still holds.
let flag = wake_with_env_endpoint(&["--instance", NO_SUCH_INSTANCE, "--endpoint", "rudp://127.0.0.1:9"], canary);
assert_eq!(flag.status.code(), Some(0));
}
const INSTANCE: &str = "yggdrasil";
const CAMPAIGN: &str = "eureka-body";
const REPO: &str = "GameCult/Huginn";
fn doc_ref() -> DocRef {
DocRef { path: "docs/eureka-body-target.md".into(), start_line: 1, end_line: 9, commit: Sha("df7507b".into()) }
}
/// The identity, stewardship, campaign and target a Persona turn's run cites.
fn seed() -> Vec<PipelineDocument> {
let date = || Date("2026-10-09".into());
vec![
PipelineDocument::Instance(PipelineInstance { instance: Slug(INSTANCE.into()), display_name: "test mind".into(), created_at: date(), host: "test".into() }),
PipelineDocument::Stewardship(PipelineStewardship { instance: Slug(INSTANCE.into()), repo: REPO.into(), sequence: 1, assigned_on: date(), note: "assigned".into() }),
PipelineDocument::Campaign(PipelineCampaign {
slug: Slug(CAMPAIGN.into()),
title: "Eureka body".into(),
repos: vec![REPO.into()],
working_branch: "main".into(),
target_doc: doc_ref(),
}),
PipelineDocument::Target(PipelineTarget {
campaign: Slug(CAMPAIGN.into()),
revision: 1,
invariants: vec![TargetInvariant { label: "run-is-the-grant".into(), statement: "A run is the grant.".into() }],
not_in_scope: vec![],
canonical_implementations: vec![],
repos: vec![],
doc: doc_ref(),
}),
]
}
/// The daemon, ended with the test whether it passes or not.
struct Daemon(Child);
impl Drop for Daemon {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
/// The real binary against the real daemon and a real store at the brake's
/// root. It writes `/etc/gamecult/minds`, so it runs in a container only
/// (`/.dockerenv`), as root, after `cargo build -p huginn-daemon` has put the
/// daemon beside the binary. `PATH` is empty so no unit is ever reached: the
/// run opens, its unit fails to start and the run is withdrawn, which is the
/// outcome printed.
///
/// `cargo test` and `cargo mutants` skip it, so the `host()` mutants survive
/// there. Run it in a container as root with the README's lane command
/// (`crates/mind-body/README.md`).
#[test]
#[ignore = "container only: root, /etc/gamecult/minds and a built huginn-daemon beside the binary"]
fn the_real_bin_against_the_real_daemon_opens_a_run_that_records_this_machines_host_and_the_agent() {
assert!(Path::new("/.dockerenv").exists(), "this test writes the brake's root and runs in a container only");
let slug = Slug(INSTANCE.into());
let state = TempDir::new().unwrap();
{
let mut mind = Mind::open(state.path(), &slug).unwrap();
let batch = PipelineAdmissionBatch {
instance: slug.clone(),
provenance: PipelineProvenance { faculty: Faculty::Hands, agent: "test".into(), session: "test".into(), tool: "test".into() },
documents: seed(),
};
assert!(matches!(mind.admit(batch, Utc::now()), PipelineAdmissionOutcome::Committed { .. }));
}
let mut writer = ControlWriter::open(control_path(Path::new(CONTROL_ROOT), &slug).unwrap()).unwrap();
writer.set_brake(true, Utc::now(), "op").unwrap();
let dial = BurnRate { heat: Decimal::from_str("1").unwrap(), base_cooldown_s: 600, base_run_usd: Decimal::from_str("5").unwrap(), set_at: Utc::now(), set_by: "op".into() };
writer.set_dial(dial).unwrap();
let port = UdpSocket::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
let endpoint = format!("rudp://127.0.0.1:{port}");
let wake_exe = Path::new(env!("CARGO_BIN_EXE_mind-wake"));
let daemon_exe = wake_exe.with_file_name("huginn-daemon");
assert!(daemon_exe.exists(), "build huginn-daemon first: {}", daemon_exe.display());
let daemon = Daemon(
Command::new(&daemon_exe)
.args(["--state-root", state.path().to_str().unwrap(), "--instance", INSTANCE, "--bind", &format!("127.0.0.1:{port}")])
.args(["--qdrant-url", "http://127.0.0.1:1", "--ollama-url", "http://127.0.0.1:1", "--embedding-model", "x"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap(),
);
// Exit 2 before the daemon answers has opened nothing; try again until it does.
let mut output = None;
for _ in 0..40 {
let attempt = Command::new(wake_exe).args(["--instance", INSTANCE, "--endpoint", &endpoint]).env("PATH", "").output().unwrap();
if attempt.status.code() != Some(2) {
output = Some(attempt);
break;
}
std::thread::sleep(Duration::from_millis(500));
}
let output = output.expect("the daemon never answered");
assert_eq!(output.status.code(), Some(0), "{}", String::from_utf8_lossy(&output.stderr));
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "refused: start_failed");
drop(daemon);
let hostname = std::fs::read_to_string("/proc/sys/kernel/hostname").unwrap().trim().to_string();
assert!(!hostname.is_empty());
let mind = Mind::open(state.path(), &slug).unwrap();
let runs = Selection { schemas: Some(vec![PipelineKind::Run.type_id().to_string()]), ..Selection::default() };
let PipelinePageItems::Headers(headers) = mind.query(&runs).unwrap().items else { panic!("documents to a header selection") };
assert_eq!(headers.len(), 1);
let view = mind.view(&headers[0].id).unwrap().unwrap();
let PipelineDocument::Run(run) = &view.document else { panic!("not a run") };
assert_eq!(run.host.0, hostname);
assert_eq!(view.admission.provenance.agent.0, "mind-wake");
}

View file

@ -504,10 +504,10 @@ pipeline kinds are in Huginn `docs/eureka-substrate-map.md`.
| --- | --- | --- | --- |
| Pipeline wire ids | `epiphany.pipeline.<kind>.v2`, epoch `epiphany.pipeline.epoch.v2`, defined by the leaf, the Huginn crate `crates/eureka-pipeline` (lib `eureka_pipeline`), ids unchanged. | Renamed once to `eureka.pipeline.<kind>.v3` by one `mind.redb` migration, at the next breaking leaf change or before the first hosted mind (ruling `wire-names-rename-before-hosting`). Adding the `run` kind or the `Mind` authority variant is additive (old stored documents decode; no migration), so it does not trigger the rename; a field added with the leaf's `= absent` marker is additive too (leaf-read-anchors r2: old bytes decode, empty fields encode byte-identically, `golden/envelopes.txt` gains lines and loses none); a field added any other way would. | Huginn owns the leaf. The migration is a live-mind upgrade, which is the operator's (`mind-rules-which-forks`). |
| Leaf crate and schema catalogue | Huginn `crates/eureka-pipeline`, `schemas/cultnet/` (13 pipeline schemas, `gamecult.persona_state.v0`, index, README). `epiphany.work_organ_state.v0` dies: no reader outside Epiphany (grep, F1/F3). | Landed at Huginn `afc35f9` (leaf-into-huginn). Wire pinned by `crates/eureka-pipeline/golden/envelopes.txt` and huginn-mind's opt-in `query::tests::stored_documents_read_back`. Huginn is AGPL-3.0-only (ruling `eureka-body:ruling:leaf-relicensed-agpl`). | Huginn. Forbidden: any Cargo source resolving into GameCult/Epiphany (`nothing-runs-from-epiphany`). |
| `run` | `<instance>:run:<label>` (cut-run-kind r2). Label `mind-<YYYYMMDD>T<HHMMSS><mmm>Z` from mind-launch's `run_label`; two launches in one millisecond produce the same key with different content, which admission answers `Conflict`, so the name stays injective. Order is admission ordinal (F15), not label. | Each wake opens a PersonaTurn run with no claims (ruling `eureka-substrate:ruling:every-wake-a-run`); the Persona tool opens SelfRun runs with claims; both go through `open_and_launch`, and the unit starts only after the run commits (the run is the grant). The Self in a unit admits under its label and ends it `Recorded { reason }`. A run whose unit is not alive past a 120 s start grace is closed `Recorded { reason: "unit not active" }` by the next `open_and_launch` of the same turn, before it checks anything else. It is Recorded, not Withdrawn, so a unit that keeps dying counts as an empty ended run and trips the breaker. Only a unit whose start failed in the launch itself is Withdrawn. Cadence comes from the newest PersonaTurn run's `admitted_at` (any status), not `started_on` (a date cannot carry seconds). | Opening: `open_and_launch`, called by the waker for PersonaTurn and by the Persona tool for SelfRun, each with a `Grant` that only `read_effective` constructs. One live run of hers per turn and instance: huginn-mind admission refuses a second in-force run with `operated_by: Mind` of the same turn and instance as `AlreadyLive { run }` (ruling `one-live-self-run`, option `one-live-per-turn`; cut run-live-admission). `open_and_launch` answers `Busy` from that refusal, or earlier, when the holder it has just read is alive. Operator runs never count. `operated_by` is declared by the writer (follow-up `eureka-substrate:follow_up:operator-authority-needs-operator-credential`). Ending: the Self that worked it; a dead one is closed by the next launch of its turn (cut mind-launch r3). No sweep exists elsewhere. Forbidden: Hands, Soul, Imagination, Life. |
| Repetition breaker | No state of its own (rulings `self-waker`, `merge-gate-own-soul`). Trip condition derived from one selection: the documents citing the item, in any role. Counted runs are the item's ended SelfRun runs of hers (`operated_by: Mind`, not withdrawn) admitted after the newest question `raised_in` the item. Trip when the last 3 counted runs are empty, or 10 of the last 50. A run is empty when no citing document other than a run has an ordinal in its window (its admission up to the next run's, or now). Documents that cite something else never count, wherever they were admitted (F21). | Evaluated in `open_and_launch` when a Self run claims the item, not by the waker. When tripped, it admits one `question` labelled `breaker-<run label>` under the item's campaign, `raised_in` the item, and launches nothing. The run label is the label the refused launch would have used, so every trip has its own key. The existing blocked-spec recipe then removes the item from the queue. Answered by a ruling or withdrawn: either reopens the item, and that question is the boundary, so a reopened item starts a fresh count. | `open_and_launch` admits the question; any Self may answer it (substrate-internal, `mind-rules-which-forks`). |
| `run` | `<instance>:run:<label>` (cut-run-kind r2). Label `mind-<YYYYMMDD>T<HHMMSS><mmm>Z` from mind-launch's `run_label`; two launches in one millisecond produce the same key with different content, which admission answers `Conflict`, so the name stays injective. Order is admission ordinal (F15), not label. | The heartbeat opens a PersonaTurn run with no claims each time it launches a Persona turn (a `mind-persona` start that finds no such run opens none and runs nothing) (ruling `eureka-substrate:ruling:every-wake-a-run`); the Persona tool opens SelfRun runs with claims; both go through `open_and_launch`, and the unit starts only after the run commits (the run is the grant). The Self in a unit admits under its label and ends it `Recorded { reason }`. A run whose unit is not alive past a 120 s start grace is closed `Recorded { reason: "unit not active" }` by the next `open_and_launch` of the same turn, before it checks anything else. It is Recorded, not Withdrawn, so a unit that keeps dying counts as an empty ended run and trips the breaker. Only a unit whose start failed in the launch itself is Withdrawn. Idle time comes from the newest PersonaTurn run's `admitted_at` (any status), not `started_on` (a date cannot carry seconds); there is no cadence, the heartbeat's initiative timeline decides when a Persona wakes. | Opening: `open_and_launch`, called by the heartbeat for PersonaTurn and by the Persona tool for SelfRun, each with a `Grant` that only `read_effective` constructs. One live run of hers per turn and instance: huginn-mind admission refuses a second in-force run with `operated_by: Mind` of the same turn and instance as `AlreadyLive { run }` (ruling `one-live-self-run`, option `one-live-per-turn`; cut run-live-admission). `open_and_launch` answers `Busy` from that refusal, or earlier, when the holder it has just read is alive. Operator runs never count. `operated_by` is declared by the writer (follow-up `eureka-substrate:follow_up:operator-authority-needs-operator-credential`). Ending: the Self that worked it; a dead one is closed by the next launch of its turn (cut mind-launch r3). No sweep exists elsewhere. Forbidden: Hands, Soul, Imagination, Life. |
| Repetition breaker | No state of its own (rulings `self-waker`, `merge-gate-own-soul`). Trip condition derived from one selection: the documents citing the item, in any role. Counted runs are the item's ended SelfRun runs of hers (`operated_by: Mind`, not withdrawn) admitted after the newest question `raised_in` the item. Trip when the last 3 counted runs are empty, or 10 of the last 50. A run is empty when no citing document other than a run has an ordinal in its window (its admission up to the next run's, or now). Documents that cite something else never count, wherever they were admitted (F21). | Evaluated in `open_and_launch` when a Self run claims the item, not by the heartbeat. When tripped, it admits one `question` labelled `breaker-<run label>` under the item's campaign, `raised_in` the item, and launches nothing. The run label is the label the refused launch would have used, so every trip has its own key. The existing blocked-spec recipe then removes the item from the queue. Answered by a ruling or withdrawn: either reopens the item, and that question is the boundary, so a reopened item starts a fresh count. | `open_and_launch` admits the question; any Self may answer it (substrate-internal, `mind-rules-which-forks`). |
| Brake | One document per instance in a root-owned CultCache store `/etc/gamecult/minds/<instance>/control.cc`, type `eureka.control.brake.v1 { released: bool, set_at, set_by }`. | Replaced whole by the operator's CLI; never appended. Absent or undecodable reads as held. `mind-control brake hold` writes it held and then, in the same command, stops her running units: `systemctl stop` (blocking) over `mind-self@<i>:*` and `mind-persona@<i>:*`, through `SystemdLauncher::stop_all` (cut brake-stop; ruling `self-run-actuator`; F18-F20). | Operator only (ruling `brake-dial-root-store-now`); her units read it, and the file is not writable by any user she runs as. The hold is the one stopper of her units. `read_effective` is the one reader. Its `Released` answer carries a `Grant` (cadence and run cap, private fields, not `Clone`), which `open_and_launch` takes by value, so no run opens without a released reading (cut mind-launch r3). |
| Burn-rate dial | Same store, `eureka.control.burn_rate.v1 { heat, base_cooldown_s, base_run_usd, set_at, set_by }`; `heat` in 0.05..=2.0. | Replaced whole by the CLI. Absent or out of bounds reads as heat 0, which launches nothing (fail closed, LiteLLM scar rider of ruling `connector-owner`). Schedules and her own rest arrive later as follow-up `burn-schedule-and-rest`. | Operator. Derived (never stored): wake cadence `base_cooldown_s / heat`, per-run cap `--max-budget-usd = base_run_usd × heat`, per-request `max_cost_usd`. |
| Swarm dial (supersedes the per-instance burn-rate dial) | `CONTROL_ROOT/swarm.cc` only; the per-instance `control.cc` holds the brake and nothing else. `eureka.control.burn_rate.v1`, `Grant.cadence` and `base_cooldown_s` are deleted (cut mind-heartbeat). | Operator CLI `mind-control swarm ...`. | Ceilings: heat 2, base rate 100 USD/h, base run 50 USD, k 16. |
| Connector request and transcript items | Request: `(caller_runtime_id, request_id)`, the replay key the daemon already keeps (`replay.cc`). Items: UserText, AssistantText, ToolCall, ToolResult, plus `ReasoningState { provider, model, opaque }` (ruling `connector-opaque-state-item`). Contract ids become provider-neutral `gamecult.model.*.v3` (follow-up `connector-contract-untyped`); the connector serves Ghostlight too, so not `eureka.*`. | Invocation expires at `expires_at_unix_ms`; replay record per key; restart-era `Indeterminate`. The caller stores the transcript and replays it verbatim; foreign-tagged reasoning state inside an in-flight tool exchange is refused typed; completed-turn foreign state is settled per provider in the connector cut (follow-up `connector-reasoning-state-cheap-providers`). | The connector produces, reads or refuses reasoning bytes; callers never branch on provider. |
| Connector spend ledger | The reservation lives in the replay record itself (cut connector-spend, F33): integer micro-USD, windowed per caller (ruling `connector-spend-caller-window-ceiling`). | Reserve before the provider runs, settle to actual; refusals `CostBound` (the invocation's own `max_cost_micro_usd`) and `SpendCeiling` (the caller's window); a ledger that cannot be written refuses the request. | The kit's `ProviderCall::charge` is the one billing owner; a backend reports usage or `None` (bills the reservation). The root caller config sets the ceiling. |
| Claude connector backend (B4a, landed `200933f`) | `ClaudeCodeBackend` in `crates/claude-connector`, provider tag `claude-code`: one `claude -p` child per call in its own process group, tool-less, system prompt in a private temp file; reasoning state is a named-MessagePack `ClaudeCodeHandle { session_id, instance, prefix_sha256 }`; usage is `model_usage_since_last_result` summed over models, in list-price units from B4b's price table (ruling `claude-code-plan-quota-fuse-2`). | A handle resumes (always `--fork-session`) only when it decodes, names this instance, its session file exists and its digest matches the request's instructions, model and earlier items; anything else re-seeds from the caller's transcript (ruling `claude-code-handle-reseeds`). The CLI's session store under `config_dir` is a cache; the caller's transcript is the truth. A refusal at the door with explicitly empty usage bills zero; unknown usage, a crash or a deadline bills the reservation. | The backend owns the argv, env, process and stream mapping. It never reads `total_cost_usd`, never forwards `ANTHROPIC_API_KEY`, never retries, never spawns twice per execute. Forbidden: `--bare`, `--fallback-model`, `--mcp-config` and `--dangerously-skip-permissions` (B4d changes the MCP line, by spec). Her organs hold none of it: the subscription token lives in the connector's own environment. |
@ -515,7 +515,7 @@ pipeline kinds are in Huginn `docs/eureka-substrate-map.md`.
| Persona state | `gamecult.persona_state.v0`, one per mind, `personaId` = instance (cut-huginn-persona-state r2, eureka-substrate). | Replaced whole under CAS with a receipt; she may rewrite any field (`identity-write-all-visible`). | The Persona organ is the only writer. |
| Identity diffs | The pair of receipts of two consecutive Persona puts whose identity fields (publicName, publicDescription, presentation, values) differ. Derived, not stored. | Composed by the Persona organ at put time and sent as one delivery request through the crossing; a failed post is retried by Bifrost's delivery journal, not by her. | Persona organ composes; Bifrost posts under the permit. No rate limit. |
| Discord body surfaces | Outbound: crossing ids `epiphany.persona_discord_delivery_request.v0` and `..._permit.v0`, agent `epiphany.Persona`, runtime `epiphany-starfire` (F6), renamed to `eureka.*` and `eureka-yggdrasil` in the mouth rebinding (`mouth-crossing-on-yggdrasil`). Inbound: Bifrost persona-feedback deliveries for target `epiphany` (F6). Persona-session tools reach both (`body-includes-discord`). | Request, permit, post, receipt (Bifrost journal). Inbound deliveries: **unprobed** who records that a delivery was answered; `run-is-the-grant` forbids her organ from holding that fact. Blocks the Persona cuts only, not the cuts admitted today. | Bifrost owns posting and delivery records; her permit issuer grants while the brake is released. |
| Units and instance templates | `mind-wake@<instance>.timer/.service`, `mind-self@<instance>:<label>.service`, `mind-persona@<instance>:<label>.service` (one-shot per Persona turn; `RuntimeMaxSec` set at install bounds a hung unit), `mind-permit@<instance>`; users `mind-<instance>` (organs) and `mind-<instance>-self` (Self runs); state `/var/lib/gamecult/minds/<instance>/`; control `/etc/gamecult/minds/<instance>/`. Instance `eureka`. | Hand-installed by gamecult-ops now; Idunn targets declared before the first hosted member, which deletes the hand install (`mind-units-templated-interim`). | gamecult-ops installs; Idunn later. Starting: `open_and_launch` through `SystemdLauncher::start`, as her users, who are granted start only. The instance in a unit name is always the mind's (`MindPort::instance`), passed to every `Launcher` call; `SystemdLauncher` holds none. The instance and label are validated by the leaf's grammar before any `systemctl` argv is built. Stopping: only the operator's `brake hold`, as root (cut brake-stop). The unit-level brake gate and the start-only grant are follow-up `mind-units-brake-gate`. None of this exists on Yggdrasil yet (F19, F22). Each piece's identity, lifecycle and authority: the section "The mind units" below (cut mind-units-install; unit names pending question `mind-unit-names`). |
| Units and instance templates | `mind-heartbeat.service` (one, not per instance), `mind-self@<instance>:<label>.service`, `mind-persona@<instance>:<label>.service` (one-shot per Persona turn; `RuntimeMaxSec` set at install bounds a hung unit), `mind-permit@<instance>`; users `mind-<instance>` (organs) and `mind-<instance>-self` (Self runs); state `/var/lib/gamecult/minds/<instance>/`; control `/etc/gamecult/minds/<instance>/`. Instance `eureka`. | Hand-installed by gamecult-ops now; Idunn targets declared before the first hosted member, which deletes the hand install (`mind-units-templated-interim`). | gamecult-ops installs; Idunn later. Starting: `open_and_launch` through `SystemdLauncher::start`, as her users, who are granted start only. The instance in a unit name is always the mind's (`MindPort::instance`), passed to every `Launcher` call; `SystemdLauncher` holds none. The instance and label are validated by the leaf's grammar before any `systemctl` argv is built. Stopping: only the operator's `brake hold`, as root (cut brake-stop). The unit-level brake gate and the start-only grant are follow-up `mind-units-brake-gate`. None of this exists on Yggdrasil yet (F19, F22). Each piece's identity, lifecycle and authority: the section "The mind units" below (cut mind-units-install; unit names pending question `mind-unit-names`). |
| Self-run credential | None in her unit. The connector holds the one subscription token; a Self run borrows it as a lease over a unix socket (`CLAUDE_LEASE_SOCKET`), and the token reaches `claude` only as a descriptor it reads once (`CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR`). The child's environment is cleared and rebuilt from an allow-list (`PATH`, `LANG`, `LC_*`, `TERM`, and its own `HOME` and `CLAUDE_CONFIG_DIR`), so no credential variable in the unit can outrank the lease. | Rotated by the operator at the connector, never in her unit: no `EnvironmentFile` carries a token for `mind-self@` (rulings `self-runs-through-connector`, `self-run-credential-lease`; `self-run-token-reach` is superseded). | The connector's: one lease per unit invocation, bound to the peer; the unit holds no credential variable. Her `claude` runs her tools, so anything in the unit's environment is readable from `/proc/<claude>/environ` by her Bash (ruling `claude-code-runs-her-tools-2`). |
| Wake frame | Fixed prompt frame (provenance, jurisdiction, brake obedience) in `/etc/gamecult/minds/<instance>/wake-frame.md`, root-owned. Composed by the Persona organ from host config; the waker passes the unit only the instance and the run label. | Changed only by an install. | Host config (ruling `self-waker` rider 2); never a file her runs merge into. |
| Epiphany residue | F5, F8, F11, as they stood before the teardown. | Teardown landed (gamecult-ops `9c12adb`); what it did not name is follow-up `eureka-body:follow_up:epiphany-body-leftovers`; the repo is archived last. | gamecult-ops for host and repo tooling; Idunn for its legacy target list (follow-up). |
@ -531,13 +531,13 @@ hers per turn (`one-live-self-run`) and only then can `User=` name a per-instanc
| Thing | Identity | Lifecycle | Who may start, stop, enable or change it |
| --- | --- | --- | --- |
| Waker template | `/etc/systemd/system/mind-wake@.service` and `mind-wake@.timer`, sources `systemd/` in gamecult-ops. Instance unit `mind-wake@<i>.service`, `User=mind-%i`. | Installed and replaced by `scripts/install-mind-units-yggdrasil.sh`. The timer is a fixed poll (1 min after the last cycle ends); it decides nothing: the cadence is the dial's, read by the waker from the newest Persona run (mind-wake r3). Schedules and rest (follow-up `burn-schedule-and-rest`) belong in the dial, so they need no unit change. Each tick runs one cycle, `Type=oneshot`, `TimeoutStartSec=5min`, `Restart=no`. Not gated at the unit: the waker reads the brake itself and returns `Held` before any consequence, and a second reading would protect nothing. | Install, change: root via the script. Enable or disable `mind-wake@<i>.timer`: root only, by hand in the runbook. The script never enables it, so installing starts nothing. Start the service: the timer (systemd as root). Her users have no grant on `mind-wake@`. |
| Heartbeat unit | One `mind-heartbeat.service` (not per instance; `mind-wake@` timers and units are retired). Needs `HEARTBEAT_PERSONAS` (each entry ends with that Persona's own Huginn `rudp://<ip>:<port>`: each Mind is its own daemon, ruling `swarm-one-connector-many-callers-mind-per-daemon`), `MIND_PERSONA_OPERATOR`, `BIFROST_CHAT_ENDPOINT` and `_KEY_FILE`, `MIND_CONNECTOR_ENDPOINT`, `_KEY_FILE`, `HEARTBEAT_CLASSIFIER_MODEL`, `STATE_DIRECTORY`. | Idunn-managed, sole writer of `heartbeat.cc`. | Install, replace, restart. |
| Persona template | `/etc/systemd/system/mind-persona@.service`. Instance unit `mind-persona@<i>.service`, `User=mind-%i`, `Type=exec`, no `[Install]`, so it cannot be enabled. | Inactive until started for one Persona turn. Ends by its own exit, at `RuntimeMaxSec=30min`, or by the brake hold's stop (`TimeoutStopSec=30s`, then SIGKILL to the whole cgroup). `Restart=no`, and nothing else restarts it. Every start first runs the gate (below); while the brake is not Released the unit is skipped and ends inactive. `ExecStart` names the Persona organ's binary in the Huginn release; that cut ships the binary and owns the argv. Until then a released start fails at exec (203/EXEC), and the closing launch counts it as a dead run. | Start: `mind-<i>` only, through `open_and_launch` (the waker), by polkit's verb `start` on this one unit name. Stop, kill, restart: root only, which in practice means `mind-control brake hold` (cut brake-stop). Change: root via the script. Enable: nobody. |
| Self template | `/etc/systemd/system/mind-self@.service`. Instance unit `mind-self@<i>.service`, `User=mind-%i-self`, `Type=exec`, no `[Install]`. | As the Persona unit, with `RuntimeMaxSec=4h`. `ExecStart` names the Self-run actuator's binary in the Huginn release; that cut adds the state directory, `RuntimeDirectory=` (each child's fresh configuration), `MIND_SELF_RUN_CAP_S` equal to `RuntimeMaxSec` in seconds, `CLAUDE_LEASE_SOCKET` and the argv. It adds no token and no `EnvironmentFile` with a credential. | Start: `mind-<i>` only (the Persona's start-Self-run tool through `open_and_launch`), by polkit, verb `start`. Stop, kill, restart: root only. Change: root via the script. Enable: nobody. |
| Organ user | `mind-<i>`: system user and group, shell `/usr/sbin/nologin`, home `/nonexistent`. | Created by the install script if absent and never modified by it after that. Deleted by root at the mind's retirement. Runs the waker and Persona units. | Root creates and deletes it. It may start `mind-persona@<i>` and `mind-self@<i>`, and nothing else. It cannot write the control store (root 0644, dir 0755) or stop a unit. It can signal its own processes with `kill(2)`, as any Unix user can. That only ends spend, and the ended run is closed Recorded by the next launch, so it adds no second stopper of her units. |
| Self user | `mind-<i>-self`: system user and group, nologin, home `/nonexistent`. The install script refuses an instance slug ending in `-self`, so this name never collides with another mind's organ user. | As the organ user. | Root creates and deletes it. It starts no unit. Polkit refuses it every verb, and the rule refuses any subject whose name ends in `-self`. |
| `mind-control` | `/opt/gamecult/huginn/releases/<sha>/mind-control`, root:root 0700, reached by the `current` symlink; `/usr/local/sbin/mind-control` links to `/opt/gamecult/huginn/current/mind-control`. | Built and installed with each Huginn release by the Release step of `runbooks/huginn-yggdrasil.md`. Replaced when `current` flips. The previous release is kept until the new one verifies. | Install, replace: root via the Release step. Run: root only (mode 0700), which covers the operator's verbs and the gate, which systemd runs as root through the `+` prefix. No user of hers can execute it. |
| `mind-wake` | `/opt/gamecult/huginn/releases/<sha>/mind-wake`, root:root 0755. | Same release step and lifecycle as `mind-control`. | Install, replace: root. Run: the waker unit as `mind-<i>`. |
| `mind-heartbeat` | `/opt/gamecult/huginn/releases/<sha>/mind-heartbeat`, root:root 0755. | Owner of initiative: reads the swarm envelope, the mind (busy, idle, run ends), the mouth (typed sender, `m.mentions`, room) and the connector (classifier urgency, spend); writes `heartbeat.cc` (Initiative per instance, poll traces) and launches Persona turns through `open_and_launch`. The operator tagging or naming a Persona fills its initiative once per message; a member never does. | `mind-heartbeat timeline` prints the latest poll. |
| The gate | `ExecCondition=+/opt/gamecult/huginn/current/mind-control --instance %i check` in both spending templates. The verb is cut mind-unit-gate. | Runs at every start of a Persona or Self unit. It exits 0 only when `read_effective` is `Released`; held, absent, undecodable or out of bounds exit 2 (systemd skips on 1-254). It holds no state and writes nothing. | Owner: `read_effective`, the one derivation. Change: a Huginn release (the verb) or the script (the line). It closes the window in which a launch that read Released before a hold starts its unit after the hold's stop (follow-up `mind-units-brake-gate`). |
| Polkit rule | `/etc/polkit-1/rules.d/60-mind-units.rules`, root 0644, source `polkit/60-mind-units.rules` in gamecult-ops. Needs package `polkitd` (absent today, F22; question `mind-unit-start-grant`). | Installed and replaced by the script. Instance-generic: one file serves every mind. | Root changes it. It grants exactly one thing: action `org.freedesktop.systemd1.manage-units`, verb `start`, unit `mind-persona@<i>.service` or `mind-self@<i>.service`, to subject `mind-<i>`. Every other request falls through to systemd's default, `auth_admin`, which an unattended caller cannot satisfy. |
| Control store | `/etc/gamecult/minds/<i>/control.cc`, root:root 0644 in a 0755 dir (cut mind-control-store). | The install script writes `brake hold` once, if no store exists, so a fresh install reads `held` with a name and time rather than `absent`. Afterwards only the operator's verbs change it. | Operator only (`brake-dial-root-store-now`). |