Restore LF line endings on the files the WIP commits converted to CRLF

The base is LF; the wip commits rewrote whole files as CRLF, hiding the real diff. Content unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-11-01 13:45:37 +01:00
commit ffcd2666c5
12 changed files with 8017 additions and 8017 deletions

View file

@ -1,77 +1,77 @@
[package]
name = "mind-body"
version = "0.1.0"
edition.workspace = true
license.workspace = true
publish.workspace = true
# One crate for her organs (rationale "One crate for her organs"). It holds the
# operator's brakes: a root-owned per-instance CultCache
# 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 heartbeat
# (`heartbeat`, the `mind-heartbeat` binary) runs the shared heartbeat over them. The actuator
# (`self_run`, the `mind-self-run` binary) carries her one open Self run.
#
# The Persona organ (`persona`, the `mind-persona` binary) drains her pending chat
# through ports. The connector transport is ModelConnector's kit client, pinned to
# the kit's revision (`connector`); the chat transport is cultnet-rs's RUDP client
# to Bifrost's mouth (`chat_rudp`). `rmp-serde` is the chat documents' positional
# MessagePack codec (`chat_client`); `sha2` derives the mouth's connection id from
# the chat credential. The kit pins its own CultLib revision, so the graph holds
# two until the workspace pin and the kit's meet.
# `rmp-serde` is also the lease wire (`lease_client`): the connector's MessagePack frames.
#
# `serde_json` reads Claude Code's stream-json, a foreign owner's wire (`self_run`);
# `uuid`'s v5 makes a Self run's session id derivable from the run's id.
[dependencies]
anyhow = "1"
chrono = { version = "0.4.44", features = ["serde"] }
cultcache-rs.workspace = true
cultnet-rs.workspace = true
eureka-pipeline = { path = "../eureka-pipeline" }
eureka-state = { path = "../eureka-state", default-features = false }
huginn-mind = { path = "../huginn-mind" }
model-connector = { git = "https://eureka.gamecult.org/forge/GameCult/ModelConnector.git", rev = "c1802d85bee3ae04b0c5737563c8343c1fef515d", package = "model-connector" }
rmp-serde = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
uuid = { version = "1", default-features = false, features = ["v5"] }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
# The lease socket: SCM_RIGHTS receive with MSG_CMSG_CLOEXEC and the descriptor
# duplicates, as safe calls (the library forbids unsafe code). Already in the
# graph through tempfile; this adds its `net` feature only.
rustix = { version = "1", default-features = false, features = ["std", "net"] }
# `syn`, `quote` and `proc-macro2` read the library's own source for
# tests/public_surface.rs, which pins its public surface. All three are already
# in the workspace's graph (serde's derive uses them); they are test-only.
[dev-dependencies]
proc-macro2 = "1"
quote = "1"
syn = { version = "2", features = ["full"] }
tempfile = "3"
[lib]
name = "mind_body"
path = "src/lib.rs"
[[bin]]
name = "mind-control"
path = "src/main.rs"
[[bin]]
name = "mind-self-run"
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"
[package]
name = "mind-body"
version = "0.1.0"
edition.workspace = true
license.workspace = true
publish.workspace = true
# One crate for her organs (rationale "One crate for her organs"). It holds the
# operator's brakes: a root-owned per-instance CultCache
# 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 heartbeat
# (`heartbeat`, the `mind-heartbeat` binary) runs the shared heartbeat over them. The actuator
# (`self_run`, the `mind-self-run` binary) carries her one open Self run.
#
# The Persona organ (`persona`, the `mind-persona` binary) drains her pending chat
# through ports. The connector transport is ModelConnector's kit client, pinned to
# the kit's revision (`connector`); the chat transport is cultnet-rs's RUDP client
# to Bifrost's mouth (`chat_rudp`). `rmp-serde` is the chat documents' positional
# MessagePack codec (`chat_client`); `sha2` derives the mouth's connection id from
# the chat credential. The kit pins its own CultLib revision, so the graph holds
# two until the workspace pin and the kit's meet.
# `rmp-serde` is also the lease wire (`lease_client`): the connector's MessagePack frames.
#
# `serde_json` reads Claude Code's stream-json, a foreign owner's wire (`self_run`);
# `uuid`'s v5 makes a Self run's session id derivable from the run's id.
[dependencies]
anyhow = "1"
chrono = { version = "0.4.44", features = ["serde"] }
cultcache-rs.workspace = true
cultnet-rs.workspace = true
eureka-pipeline = { path = "../eureka-pipeline" }
eureka-state = { path = "../eureka-state", default-features = false }
huginn-mind = { path = "../huginn-mind" }
model-connector = { git = "https://eureka.gamecult.org/forge/GameCult/ModelConnector.git", rev = "c1802d85bee3ae04b0c5737563c8343c1fef515d", package = "model-connector" }
rmp-serde = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
uuid = { version = "1", default-features = false, features = ["v5"] }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
# The lease socket: SCM_RIGHTS receive with MSG_CMSG_CLOEXEC and the descriptor
# duplicates, as safe calls (the library forbids unsafe code). Already in the
# graph through tempfile; this adds its `net` feature only.
rustix = { version = "1", default-features = false, features = ["std", "net"] }
# `syn`, `quote` and `proc-macro2` read the library's own source for
# tests/public_surface.rs, which pins its public surface. All three are already
# in the workspace's graph (serde's derive uses them); they are test-only.
[dev-dependencies]
proc-macro2 = "1"
quote = "1"
syn = { version = "2", features = ["full"] }
tempfile = "3"
[lib]
name = "mind_body"
path = "src/lib.rs"
[[bin]]
name = "mind-control"
path = "src/main.rs"
[[bin]]
name = "mind-self-run"
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,105 +1,105 @@
//! `mind-persona --instance <slug> [--endpoint rudp://<ip>:<port>]`:
//! one start of the Persona organ (`mind_body::persona::carry`) inside
//! `mind-persona@<instance>`. This reads the process's arguments and
//! environment, the things the library never reads, and prints the outcome. It
//! exits 0 for every outcome, including a unit that is dead because the brake is
//! held or the dial out of bounds, and 2 when the mind cannot be reached, the
//! arguments or the environment are wrong or a transport is not configured.
//!
//! The unit's `persona.env` and credentials name everything else:
//! `MIND_PERSONA_ROOMS` (a,b), `MIND_PERSONA_OPERATOR`
//! (id[,id]), `MIND_PERSONA_MINDS` (id[,id]: every Mind of the swarm, required) and `MIND_PERSONA_SELF_SENDER`, each also a flag that overrides it
//! (`--rooms`, `--operator`, `--minds`, `--self-sender`), and `MIND_HEARTBEAT_STATE` (optional: the path of the
//! heartbeat's `heartbeat.cc`, which the unit reads and never writes, for the tempo in her input); `MIND_PERSONA_DOCTRINE`
//! (optional, absolute paths separated by `:`) names the doctrine files read at each
//! process start and put ahead of her persona; `MIND_PERSONA_MODEL` is the model her session runs;
//! `MIND_PERSONA_RUN_CAP_S` is the unit's `RuntimeMaxSec` in seconds, the one length of a start;
//! `MIND_PERSONA_CONFIG_SEED` optionally names a root-owned directory copied into each
//! process's fresh configuration; `CLAUDE_LEASE_SOCKET` is the connector's lease socket (the token itself
//! is never in this process's environment, only the descriptor a lease grants); `RUNTIME_DIRECTORY` (the
//! unit's `RuntimeDirectory=`) holds each process's configuration and instructions; `BIFROST_CHAT_ENDPOINT`
//! (`rudp://<ip>:<port>`, the mouth) and `BIFROST_CHAT_KEY_FILE`. Without
//! `--endpoint` the daemon is `HUGINN_ENDPOINT`; the trace is `persona-trace.cc`
//! under `STATE_DIRECTORY`, and her sandbox is `STATE_DIRECTORY/sandbox`. The key file is read here and its
//! text goes to the transport, which holds it; nothing is printed.
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
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::cli::instance_and_endpoint;
use mind_body::control::{CONTROL_ROOT, FileSource, control_path, swarm_path};
use mind_body::launch::{SystemClock, SystemdLauncher};
use mind_body::heartbeat::FileStore;
use mind_body::lease_client::{LEASE_SOCKET_VARIABLE, SocketLender};
use mind_body::persona::{Config, ENV_CONFIG_SEED, ENV_RUN_CAP, FileTrace, HuginnPersona, Organs, SANDBOX_DIR, carry};
use mind_body::queue::HuginnMind;
use mind_body::self_run::{ClaudeSpawner, SCRATCH_VARIABLE};
/// How long one call to the mind may take.
const TIMEOUT: Duration = Duration::from_secs(15);
/// How long the mouth may take to answer one chat request (a send is a Matrix PUT).
const CHAT_TIMEOUT: Duration = Duration::from_secs(20);
const USAGE: &str = "usage: mind-persona --instance <slug> [--endpoint rudp://<ip>:<port>] [--rooms a,b] [--operator id[,id]] [--minds id[,id]] [--self-sender id]";
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 environment. The library reads neither (clippy.toml, `#![forbid]` in lib.rs).
#[allow(clippy::disallowed_methods, reason = "the binary reads its own arguments and environment, and hands them 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"))
}
/// The text of the credential file named by `name`; never echoed.
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"))
}
#[allow(clippy::disallowed_methods, reason = "the binary reads its own arguments and environment, and hands them to the library")]
fn run() -> Result<String> {
let args: Vec<String> = std::env::args().skip(1).collect();
let (config, rest) = Config::from_args_and_env(&args, setting, USAGE)?;
let (instance, address) = instance_and_endpoint(&rest, setting("HUGINN_ENDPOINT"), USAGE)?;
let Some(state) = setting("STATE_DIRECTORY") else { bail!("no STATE_DIRECTORY") };
let chat_endpoint: SocketAddr = required("BIFROST_CHAT_ENDPOINT")?
.strip_prefix("rudp://")
.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 run_cap_s: u64 = setting(ENV_RUN_CAP).and_then(|seconds| seconds.parse().ok()).filter(|seconds| *seconds > 0).ok_or_else(|| anyhow!("{ENV_RUN_CAP} is not set to the unit's RuntimeMaxSec in seconds"))?;
let scratch = PathBuf::from(required(SCRATCH_VARIABLE)?);
let sandbox = Path::new(&state).join(SANDBOX_DIR);
let lender = SocketLender::new(required(LEASE_SOCKET_VARIABLE)?);
let spawner = ClaudeSpawner::in_sandbox(std::env::vars_os(), ENV_CONFIG_SEED, Some(sandbox.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));
let trace = FileTrace::new(Path::new(&state).join("persona-trace.cc"));
let launcher = SystemdLauncher::new();
let host = host();
let tempo = setting("MIND_HEARTBEAT_STATE").map(FileStore::new);
let organs = Organs { mind: &mind, launcher: &launcher, clock: &SystemClock, host: &host, chat: &chat, persona: &persona, trace: &trace, tempo: tempo.as_ref().map(|store| store as &dyn mind_body::heartbeat::TempoView), lender: &lender, spawner: &spawner, run_cap_s, sandbox: &sandbox, scratch: &scratch };
let swarm = FileSource::new(swarm_path(Path::new(CONTROL_ROOT)));
Ok(carry(&organs, &source, &swarm, &config)?.to_string())
}
fn main() {
match run() {
Ok(outcome) => println!("{outcome}"),
Err(error) => {
eprintln!("mind-persona: {error:#}");
std::process::exit(2);
}
}
}
//! `mind-persona --instance <slug> [--endpoint rudp://<ip>:<port>]`:
//! one start of the Persona organ (`mind_body::persona::carry`) inside
//! `mind-persona@<instance>`. This reads the process's arguments and
//! environment, the things the library never reads, and prints the outcome. It
//! exits 0 for every outcome, including a unit that is dead because the brake is
//! held or the dial out of bounds, and 2 when the mind cannot be reached, the
//! arguments or the environment are wrong or a transport is not configured.
//!
//! The unit's `persona.env` and credentials name everything else:
//! `MIND_PERSONA_ROOMS` (a,b), `MIND_PERSONA_OPERATOR`
//! (id[,id]), `MIND_PERSONA_MINDS` (id[,id]: every Mind of the swarm, required) and `MIND_PERSONA_SELF_SENDER`, each also a flag that overrides it
//! (`--rooms`, `--operator`, `--minds`, `--self-sender`), and `MIND_HEARTBEAT_STATE` (optional: the path of the
//! heartbeat's `heartbeat.cc`, which the unit reads and never writes, for the tempo in her input); `MIND_PERSONA_DOCTRINE`
//! (optional, absolute paths separated by `:`) names the doctrine files read at each
//! process start and put ahead of her persona; `MIND_PERSONA_MODEL` is the model her session runs;
//! `MIND_PERSONA_RUN_CAP_S` is the unit's `RuntimeMaxSec` in seconds, the one length of a start;
//! `MIND_PERSONA_CONFIG_SEED` optionally names a root-owned directory copied into each
//! process's fresh configuration; `CLAUDE_LEASE_SOCKET` is the connector's lease socket (the token itself
//! is never in this process's environment, only the descriptor a lease grants); `RUNTIME_DIRECTORY` (the
//! unit's `RuntimeDirectory=`) holds each process's configuration and instructions; `BIFROST_CHAT_ENDPOINT`
//! (`rudp://<ip>:<port>`, the mouth) and `BIFROST_CHAT_KEY_FILE`. Without
//! `--endpoint` the daemon is `HUGINN_ENDPOINT`; the trace is `persona-trace.cc`
//! under `STATE_DIRECTORY`, and her sandbox is `STATE_DIRECTORY/sandbox`. The key file is read here and its
//! text goes to the transport, which holds it; nothing is printed.
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
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::cli::instance_and_endpoint;
use mind_body::control::{CONTROL_ROOT, FileSource, control_path, swarm_path};
use mind_body::launch::{SystemClock, SystemdLauncher};
use mind_body::heartbeat::FileStore;
use mind_body::lease_client::{LEASE_SOCKET_VARIABLE, SocketLender};
use mind_body::persona::{Config, ENV_CONFIG_SEED, ENV_RUN_CAP, FileTrace, HuginnPersona, Organs, SANDBOX_DIR, carry};
use mind_body::queue::HuginnMind;
use mind_body::self_run::{ClaudeSpawner, SCRATCH_VARIABLE};
/// How long one call to the mind may take.
const TIMEOUT: Duration = Duration::from_secs(15);
/// How long the mouth may take to answer one chat request (a send is a Matrix PUT).
const CHAT_TIMEOUT: Duration = Duration::from_secs(20);
const USAGE: &str = "usage: mind-persona --instance <slug> [--endpoint rudp://<ip>:<port>] [--rooms a,b] [--operator id[,id]] [--minds id[,id]] [--self-sender id]";
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 environment. The library reads neither (clippy.toml, `#![forbid]` in lib.rs).
#[allow(clippy::disallowed_methods, reason = "the binary reads its own arguments and environment, and hands them 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"))
}
/// The text of the credential file named by `name`; never echoed.
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"))
}
#[allow(clippy::disallowed_methods, reason = "the binary reads its own arguments and environment, and hands them to the library")]
fn run() -> Result<String> {
let args: Vec<String> = std::env::args().skip(1).collect();
let (config, rest) = Config::from_args_and_env(&args, setting, USAGE)?;
let (instance, address) = instance_and_endpoint(&rest, setting("HUGINN_ENDPOINT"), USAGE)?;
let Some(state) = setting("STATE_DIRECTORY") else { bail!("no STATE_DIRECTORY") };
let chat_endpoint: SocketAddr = required("BIFROST_CHAT_ENDPOINT")?
.strip_prefix("rudp://")
.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 run_cap_s: u64 = setting(ENV_RUN_CAP).and_then(|seconds| seconds.parse().ok()).filter(|seconds| *seconds > 0).ok_or_else(|| anyhow!("{ENV_RUN_CAP} is not set to the unit's RuntimeMaxSec in seconds"))?;
let scratch = PathBuf::from(required(SCRATCH_VARIABLE)?);
let sandbox = Path::new(&state).join(SANDBOX_DIR);
let lender = SocketLender::new(required(LEASE_SOCKET_VARIABLE)?);
let spawner = ClaudeSpawner::in_sandbox(std::env::vars_os(), ENV_CONFIG_SEED, Some(sandbox.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));
let trace = FileTrace::new(Path::new(&state).join("persona-trace.cc"));
let launcher = SystemdLauncher::new();
let host = host();
let tempo = setting("MIND_HEARTBEAT_STATE").map(FileStore::new);
let organs = Organs { mind: &mind, launcher: &launcher, clock: &SystemClock, host: &host, chat: &chat, persona: &persona, trace: &trace, tempo: tempo.as_ref().map(|store| store as &dyn mind_body::heartbeat::TempoView), lender: &lender, spawner: &spawner, run_cap_s, sandbox: &sandbox, scratch: &scratch };
let swarm = FileSource::new(swarm_path(Path::new(CONTROL_ROOT)));
Ok(carry(&organs, &source, &swarm, &config)?.to_string())
}
fn main() {
match run() {
Ok(outcome) => println!("{outcome}"),
Err(error) => {
eprintln!("mind-persona: {error:#}");
std::process::exit(2);
}
}
}

View file

@ -1,31 +1,31 @@
//! The plan windows as one line for a Mind: the one rendering both organs put in front of their
//! sessions, from the windows a credential lease granted (`lease_client`).
use model_connector::PlanQuota;
/// The plan windows as one line for a Mind: each window's percent used, its
/// status, when it resets and how old the reading is. Rendered from the readout
/// and nothing else, so no number in it is ever a paraphrase; the guidance that
/// follows it states the swarm's aim and that nothing enforces it.
pub fn render_plan(windows: &[PlanQuota], now_ms: u64) -> String {
let guidance = "The swarm aims to have used about 90% of the weekly allowance at reset; nothing enforces it; you and the other Minds set the tempo.";
if windows.is_empty() {
return format!("Plan usage: unobserved. {guidance}");
}
let rendered: Vec<String> = windows
.iter()
.map(|plan| match plan.utilization_permille {
Some(permille) => format!(
"{} {}.{}% {}, resets at unix ms {}, read {}s ago",
plan.window,
permille / 10,
permille % 10,
plan.status,
plan.resets_at_unix_ms,
now_ms.saturating_sub(plan.observed_at_unix_ms) / 1000
),
None => format!("{} expired, resets at unix ms {}", plan.window, plan.resets_at_unix_ms),
})
.collect();
format!("Plan usage: {}. {guidance}", rendered.join("; "))
}
//! The plan windows as one line for a Mind: the one rendering both organs put in front of their
//! sessions, from the windows a credential lease granted (`lease_client`).
use model_connector::PlanQuota;
/// The plan windows as one line for a Mind: each window's percent used, its
/// status, when it resets and how old the reading is. Rendered from the readout
/// and nothing else, so no number in it is ever a paraphrase; the guidance that
/// follows it states the swarm's aim and that nothing enforces it.
pub fn render_plan(windows: &[PlanQuota], now_ms: u64) -> String {
let guidance = "The swarm aims to have used about 90% of the weekly allowance at reset; nothing enforces it; you and the other Minds set the tempo.";
if windows.is_empty() {
return format!("Plan usage: unobserved. {guidance}");
}
let rendered: Vec<String> = windows
.iter()
.map(|plan| match plan.utilization_permille {
Some(permille) => format!(
"{} {}.{}% {}, resets at unix ms {}, read {}s ago",
plan.window,
permille / 10,
permille % 10,
plan.status,
plan.resets_at_unix_ms,
now_ms.saturating_sub(plan.observed_at_unix_ms) / 1000
),
None => format!("{} expired, resets at unix ms {}", plan.window, plan.resets_at_unix_ms),
})
.collect();
format!("Plan usage: {}. {guidance}", rendered.join("; "))
}

View file

@ -1,48 +1,48 @@
//! `mind-body`: the organs of one Mind's body that live in Huginn's workspace.
//!
//! Today that is `control`, the operator's brakes, and `cli`, the operator's
//! hand on them. The brakes are the operator's consent, not her body: the store
//! is root-owned and no organ of hers writes it.
//!
//! `launch` and `queue` are the launch primitive: the work queue, the
//! 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.
//!
//! `heartbeat` runs the swarm's shared heartbeat over them: the one process,
//! the `mind-heartbeat` binary, that decides who wakes and when, by the rule in
//! `tempo` (the Minds' votes make the beat; the pick chooses who takes it).
//!
//! `persona` is the Persona organ: one start of `mind-persona@<instance>` carries her live
//! `claude` session under bubblewrap in her sandbox, driven by the Self's own driver, with her
//! pending chat as the inbox and one typed reply per result (`chat_client` is the chat wire and
//! port and `chat_rudp` its RUDP transport to Bifrost's mouth).
//!
//! `lease_client` asks the Claude connector for a session's credential and the
//! plan's usage windows. `self_run` carries one Self run inside `mind-self@<instance>`: the
//! `mind-self-run` binary runs `claude` over stream-json for her one open Self
//! run, asks for the flush at the context ceiling and stops a child that outlives it.
// The control root is a constant, so the library reads no part of the process
// environment (clippy.toml lists the readers); `forbid` keeps an `allow` from
// putting one back. `forbid(unsafe_code)` closes the readers the list cannot
// name: a foreign `getenv` or any other FFI call needs `unsafe`. The rest of the
// Grant rule is ruling `eureka-body:ruling:mind-launch-forge-scanner-bound`.
#![forbid(clippy::disallowed_methods)]
#![forbid(unsafe_code)]
pub mod chat_client;
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;
mod recall;
pub mod self_run;
pub mod tempo;
#[cfg(test)]
mod testkit;
//! `mind-body`: the organs of one Mind's body that live in Huginn's workspace.
//!
//! Today that is `control`, the operator's brakes, and `cli`, the operator's
//! hand on them. The brakes are the operator's consent, not her body: the store
//! is root-owned and no organ of hers writes it.
//!
//! `launch` and `queue` are the launch primitive: the work queue, the
//! 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.
//!
//! `heartbeat` runs the swarm's shared heartbeat over them: the one process,
//! the `mind-heartbeat` binary, that decides who wakes and when, by the rule in
//! `tempo` (the Minds' votes make the beat; the pick chooses who takes it).
//!
//! `persona` is the Persona organ: one start of `mind-persona@<instance>` carries her live
//! `claude` session under bubblewrap in her sandbox, driven by the Self's own driver, with her
//! pending chat as the inbox and one typed reply per result (`chat_client` is the chat wire and
//! port and `chat_rudp` its RUDP transport to Bifrost's mouth).
//!
//! `lease_client` asks the Claude connector for a session's credential and the
//! plan's usage windows. `self_run` carries one Self run inside `mind-self@<instance>`: the
//! `mind-self-run` binary runs `claude` over stream-json for her one open Self
//! run, asks for the flush at the context ceiling and stops a child that outlives it.
// The control root is a constant, so the library reads no part of the process
// environment (clippy.toml lists the readers); `forbid` keeps an `allow` from
// putting one back. `forbid(unsafe_code)` closes the readers the list cannot
// name: a foreign `getenv` or any other FFI call needs `unsafe`. The rest of the
// Grant rule is ruling `eureka-body:ruling:mind-launch-forge-scanner-bound`.
#![forbid(clippy::disallowed_methods)]
#![forbid(unsafe_code)]
pub mod chat_client;
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;
mod recall;
pub mod self_run;
pub mod tempo;
#[cfg(test)]
mod testkit;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,351 +1,351 @@
//! The Self-run actuator's lifecycle decision, and nothing else.
//!
//! `decide(state, event, now) -> (state, action)` is the one owner of when the
//! flush is asked for, when the stdin closes, when a signal goes out and when
//! the run is over (ruling `eureka-body:ruling:self-run-decider`). It performs
//! no I/O and reads no clock: the loop in `carry` turns operating-system facts
//! into events, reads the clock once per event, and executes the action. A
//! timing rule is pinned here, by tables and by an enumeration of event orders.
//!
//! Delivery is counted, not guessed, and at most one write is outstanding (ruling
//! `eureka-body:ruling:self-session-one-outstanding-write`): the CLI joins the
//! lines queued behind a request into one echo, so a second line is never
//! written before the first one's echo. Every stdin line the actuator writes (the
//! start message, a write of tells and the flush) increments `written`; every
//! replay echo increments `consumed`. The echo is the point the CLI takes a line
//! into a request, folded into the running turn or queued for the next. What
//! waits (the briefs, the flush ask) is coalesced into the next single write.
//! Idle is derived: no turn open and no write outstanding.
//!
//! The rules, in this order, while stdin is open and no signal is out:
//! (1) a result that is blocked, or an error before any answer, closes stdin: no
//! cache is worth keeping and a flush would fail the same way; (2) the first
//! result after the flush's echo closes stdin, folded or queued alike; (3) the
//! cap less `ROTATE_MARGIN_S`: ask the flush (rotation), idle or mid-turn; (4)
//! idle with no brief waiting, at the last result's cache lifetime less a margin:
//! ask the flush (idle); (4b) a session told to yield closes stdin at idle, with nothing outstanding and no flush written; (5) with nothing outstanding, write what waits: the
//! briefs and the flush ask if there is one, as one line. The ceiling asks the
//! same flush from a request. Compaction is off in the CLI (`DISABLE_COMPACT=1`),
//! so the flush is the only way a session ends short of its cache.
//!
//! The headroom counts the requests after the flush's echo, from the prefix the
//! flush was asked at. The grace interrupt is at the earlier of the grace
//! after the flush write and the cap less `LADDER_S`, so the signal ladder
//! finishes before the unit's cap (ruling
//! `eureka-body:ruling:flush-always-gets-a-turn`). At most one action comes out
//! of an event; a deadline that is already due yields its action at the next
//! event, and `next_deadline` says it is now.
use std::collections::HashSet;
use chrono::{DateTime, Duration, Utc};
use super::{
CEILING_TOKENS, Cause, ESCALATE_AFTER_S, EXIT_DRAIN_S, FLUSH_GRACE_S, FLUSH_HEADROOM_TOKENS, LADDER_S, ROTATE_MARGIN_S, SelfRunOutcome, Stop, TTL_1H_S, TTL_5M_S, TTL_MARGIN_S,
};
/// Why the flush was asked: the session's own end, three ways.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Why {
/// The context reached the ceiling.
Ceiling,
/// The unit's cap is near.
Rotation,
/// The cache would be evicted and nothing waits.
Idle,
}
/// What the loop saw, already turned into a fact the decision can use.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) enum Event {
/// A request of the main thread: its message id and the context it read.
Request { id: String, prefix: u64 },
/// The CLI took one of our stdin lines into a request (its replay echo).
Echo,
/// A turn ended. `ok`: not an error. `blocked`: the CLI refused the context. `ttl_1h`: the last request wrote its cache for an hour, else five minutes.
Result { ok: bool, blocked: bool, ttl_1h: bool },
/// A poll of the mind: whether a brief waits to be told.
Inbox { pending: bool },
/// `waitpid` returned: the child's exit code, none when a signal ended it.
ChildExited(Option<i32>),
StdoutEof,
/// Time passed, or a line that no rule reads.
Tick,
/// What the operating system said of the signal the last action sent.
Signalled { how: Stop, delivered: bool },
/// The inbox's owner wants the session over at its next idle: what is oldest and unanswered is another conversation's. Only the Persona sends it.
Yield,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) enum Action {
None,
/// The one write: the waiting briefs and the flush ask, coalesced into one user line.
Write { tells: bool, flush: Option<Why> },
CloseStdin,
/// To the child's whole process group.
Signal(Stop),
Finish(SelfRunOutcome),
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct Flush {
/// The flush's line number once written (its echo is the `seq`-th); none while it waits for the outstanding write's echo.
seq: Option<u32>,
/// The prefix at which the flush was asked: the headroom's base.
prefix: u64,
asked: DateTime<Utc>,
/// Its echo has come: the CLI took it into a request.
consumed: bool,
/// A request after its echo reached the headroom.
exhausted: bool,
why: Why,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(super) struct State {
seen: HashSet<String>,
pub(super) requests: u64,
pub(super) prefix: u64,
/// Lines written to stdin, and lines the CLI echoed back as taken.
pub(super) written: u32,
pub(super) consumed: u32,
turn_open: bool,
/// An ok result has come: the session holds an answer worth its cache.
answered_once: bool,
last_result_at: Option<DateTime<Utc>>,
/// The cache lifetime the last result left, in seconds.
pub(super) ttl_s: i64,
pub(super) inbox: bool,
/// The session is to end at its next idle (`Event::Yield`); once asked it stays asked.
yielding: bool,
/// When the process started and the unit's cap in seconds; none where no cap is known.
cap: Option<(DateTime<Utc>, i64)>,
stdin_closed: bool,
flush: Option<Flush>,
/// The last signal attempted and when.
stopping: Option<(Stop, DateTime<Utc>)>,
/// The strongest signal delivered.
delivered: Option<Stop>,
exited: Option<(Option<i32>, DateTime<Utc>)>,
eof: bool,
finished: bool,
}
impl State {
/// A session whose start message was just written.
pub(super) fn started(at: DateTime<Utc>, run_cap_s: u64) -> Self {
Self { written: 1, cap: Some((at, i64::try_from(run_cap_s).unwrap_or(i64::MAX))), ..Self::default() }
}
/// A line written whose echo has not come.
fn outstanding(&self) -> bool {
self.written > self.consumed
}
fn idle(&self) -> bool {
!self.turn_open && !self.outstanding()
}
/// Seconds idle since the last result, none while a line or a turn is outstanding.
pub(super) fn idle_s(&self, now: DateTime<Utc>) -> Option<i64> {
self.idle().then(|| self.last_result_at.map_or(0, |at| (now - at).num_seconds()))
}
}
fn outcome(state: &State, cause: Cause, code: Option<i32>) -> SelfRunOutcome {
SelfRunOutcome::Ended { cause, code, requests: state.requests, prefix: state.prefix }
}
/// How the run ended, by what was done to the child: the strongest signal delivered, else the flush, else nothing.
fn cause(state: &State) -> Cause {
match (state.delivered, &state.flush) {
(Some(how), _) => Cause::Signalled(how),
(None, Some(_)) => Cause::FlushRequested,
(None, None) => Cause::Own,
}
}
/// When the flush's grace runs out: the grace after the write, never later than
/// the point the signal ladder needs to finish before the unit's cap.
fn grace_deadline(state: &State, flush: &Flush) -> DateTime<Utc> {
let grace = flush.asked + Duration::seconds(FLUSH_GRACE_S);
match state.cap {
Some((started, cap)) => grace.min(started + Duration::seconds(cap.saturating_sub(LADDER_S))),
None => grace,
}
}
/// When the flush is next asked on its own, and why: the cap's margin, or the
/// cache's end while idle with no brief waiting.
fn due(state: &State) -> Option<(DateTime<Utc>, Why)> {
if state.stdin_closed || state.exited.is_some() || state.flush.is_some() {
return None;
}
let rotation = state.cap.map(|(started, cap)| (started + Duration::seconds(cap.saturating_sub(ROTATE_MARGIN_S)), Why::Rotation));
let idle = (state.idle() && !state.inbox).then_some(state.last_result_at).flatten().map(|last| (last + Duration::seconds(state.ttl_s - TTL_MARGIN_S), Why::Idle));
[rotation, idle].into_iter().flatten().min_by_key(|(at, _)| *at)
}
/// The moment the loop must next be heard at, if any. A moment already past
/// means an action is due now.
pub(super) fn next_deadline(state: &State) -> Option<DateTime<Utc>> {
if state.finished {
return None;
}
if let Some((_, exited)) = state.exited {
return (!state.eof).then(|| exited + Duration::seconds(EXIT_DRAIN_S));
}
let signals = match (&state.stopping, &state.flush) {
(Some((_, sent)), _) => return Some(*sent + Duration::seconds(ESCALATE_AFTER_S)),
(None, Some(flush)) => Some(grace_deadline(state, flush)),
(None, None) => None,
};
signals.into_iter().chain(due(state).map(|(at, _)| at)).min()
}
/// The flush is asked once, by the ceiling, the cap's margin or the cache's end;
/// never to a child whose input is closed or that has exited. Asking writes
/// nothing: `send` writes it when no write is outstanding.
fn ask_flush(state: &mut State, now: DateTime<Utc>, why: Why) -> bool {
if state.flush.is_some() || state.stdin_closed || state.exited.is_some() {
return false;
}
state.flush = Some(Flush { seq: None, prefix: state.prefix, asked: now, consumed: false, exhausted: false, why });
true
}
/// The lifecycle rules 3 to 5: ask the flush where it is due, then write what waits.
fn lifecycle(state: &mut State, now: DateTime<Utc>) -> Action {
if let Some((at, why)) = due(state)
&& at <= now
{
ask_flush(state, now, why);
}
// A yielding session ends at idle: its own text waits for the next one. A flush already written keeps its own end.
if state.yielding && state.idle() && !state.stdin_closed && state.exited.is_none() && state.flush.as_ref().is_none_or(|flush| flush.seq.is_none()) {
state.stdin_closed = true;
return Action::CloseStdin;
}
send(state)
}
/// With nothing outstanding, the one write: the waiting briefs (until the flush
/// is written) and the flush, if it has not been written, as one line.
fn send(state: &mut State) -> Action {
if state.stdin_closed || state.exited.is_some() || state.outstanding() || state.flush.as_ref().is_some_and(|flush| flush.seq.is_some()) {
return Action::None;
}
let flush = state.flush.as_ref().map(|flush| flush.why);
if !state.inbox && flush.is_none() {
return Action::None;
}
state.written += 1;
let seq = state.written;
if let Some(flush) = state.flush.as_mut() {
flush.seq = Some(seq);
}
Action::Write { tells: std::mem::take(&mut state.inbox), flush }
}
pub(super) fn decide(mut state: State, event: Event, now: DateTime<Utc>) -> (State, Action) {
if state.finished {
return (state, Action::None);
}
let mut action = Action::None;
match event {
Event::Request { id, prefix } => {
if state.seen.insert(id) {
state.requests += 1;
state.prefix = prefix;
if let Some(flush) = state.flush.as_mut()
&& flush.consumed
&& flush.prefix.checked_add(FLUSH_HEADROOM_TOKENS).is_some_and(|limit| prefix >= limit)
{
flush.exhausted = true;
}
if prefix >= CEILING_TOKENS {
ask_flush(&mut state, now, Why::Ceiling);
}
}
}
Event::Echo => {
state.consumed += 1;
state.turn_open = true;
let consumed = state.consumed;
if let Some(flush) = state.flush.as_mut()
&& flush.seq.is_some_and(|seq| consumed >= seq)
{
flush.consumed = true;
}
}
Event::Result { ok, blocked, ttl_1h } => {
state.turn_open = false;
state.last_result_at = Some(now);
state.ttl_s = if ttl_1h { TTL_1H_S } else { TTL_5M_S };
let no_answer = !ok && !state.answered_once;
state.answered_once |= ok;
let flush_answered = state.flush.as_ref().is_some_and(|flush| flush.consumed);
if (blocked || no_answer || flush_answered) && !state.stdin_closed && state.exited.is_none() {
state.stdin_closed = true;
action = Action::CloseStdin;
}
}
Event::Inbox { pending } => state.inbox = pending,
Event::Yield => state.yielding = true,
Event::ChildExited(code) => {
state.exited.get_or_insert((code, now));
}
Event::StdoutEof => state.eof = true,
Event::Tick => {}
Event::Signalled { how, delivered } => {
if delivered {
state.delivered = Some(how);
}
}
}
// A child that has exited is never written to or signalled: the run is over
// once its stream has ended, or once the stream has had its drain.
if let Some((code, exited)) = state.exited {
if state.eof || now >= exited + Duration::seconds(EXIT_DRAIN_S) {
state.finished = true;
let outcome = outcome(&state, cause(&state), code);
return (state, Action::Finish(outcome));
}
return (state, Action::None);
}
if action != Action::None {
return (state, action);
}
if let Some((Stop::Kill, sent)) = state.stopping {
// SIGKILL went out and the child still has not been reaped: stop waiting for it.
if now >= sent + Duration::seconds(ESCALATE_AFTER_S) {
state.finished = true;
let cause = if state.delivered == Some(Stop::Kill) { Cause::Signalled(Stop::Kill) } else { Cause::Unstoppable };
let outcome = outcome(&state, cause, None);
return (state, Action::Finish(outcome));
}
return (state, Action::None);
}
if state.stopping.is_none() {
let next = lifecycle(&mut state, now);
if next != Action::None {
return (state, next);
}
}
let how = match (&state.stopping, &state.flush) {
(Some((last, sent)), _) => (now >= *sent + Duration::seconds(ESCALATE_AFTER_S)).then(|| last.next()),
(None, Some(flush)) => (flush.exhausted || now >= grace_deadline(&state, flush)).then_some(Stop::Interrupt),
(None, None) => None,
};
match how {
Some(how) => {
state.stopping = Some((how, now));
(state, Action::Signal(how))
}
None => (state, Action::None),
}
}
#[cfg(test)]
mod tests;
//! The Self-run actuator's lifecycle decision, and nothing else.
//!
//! `decide(state, event, now) -> (state, action)` is the one owner of when the
//! flush is asked for, when the stdin closes, when a signal goes out and when
//! the run is over (ruling `eureka-body:ruling:self-run-decider`). It performs
//! no I/O and reads no clock: the loop in `carry` turns operating-system facts
//! into events, reads the clock once per event, and executes the action. A
//! timing rule is pinned here, by tables and by an enumeration of event orders.
//!
//! Delivery is counted, not guessed, and at most one write is outstanding (ruling
//! `eureka-body:ruling:self-session-one-outstanding-write`): the CLI joins the
//! lines queued behind a request into one echo, so a second line is never
//! written before the first one's echo. Every stdin line the actuator writes (the
//! start message, a write of tells and the flush) increments `written`; every
//! replay echo increments `consumed`. The echo is the point the CLI takes a line
//! into a request, folded into the running turn or queued for the next. What
//! waits (the briefs, the flush ask) is coalesced into the next single write.
//! Idle is derived: no turn open and no write outstanding.
//!
//! The rules, in this order, while stdin is open and no signal is out:
//! (1) a result that is blocked, or an error before any answer, closes stdin: no
//! cache is worth keeping and a flush would fail the same way; (2) the first
//! result after the flush's echo closes stdin, folded or queued alike; (3) the
//! cap less `ROTATE_MARGIN_S`: ask the flush (rotation), idle or mid-turn; (4)
//! idle with no brief waiting, at the last result's cache lifetime less a margin:
//! ask the flush (idle); (4b) a session told to yield closes stdin at idle, with nothing outstanding and no flush written; (5) with nothing outstanding, write what waits: the
//! briefs and the flush ask if there is one, as one line. The ceiling asks the
//! same flush from a request. Compaction is off in the CLI (`DISABLE_COMPACT=1`),
//! so the flush is the only way a session ends short of its cache.
//!
//! The headroom counts the requests after the flush's echo, from the prefix the
//! flush was asked at. The grace interrupt is at the earlier of the grace
//! after the flush write and the cap less `LADDER_S`, so the signal ladder
//! finishes before the unit's cap (ruling
//! `eureka-body:ruling:flush-always-gets-a-turn`). At most one action comes out
//! of an event; a deadline that is already due yields its action at the next
//! event, and `next_deadline` says it is now.
use std::collections::HashSet;
use chrono::{DateTime, Duration, Utc};
use super::{
CEILING_TOKENS, Cause, ESCALATE_AFTER_S, EXIT_DRAIN_S, FLUSH_GRACE_S, FLUSH_HEADROOM_TOKENS, LADDER_S, ROTATE_MARGIN_S, SelfRunOutcome, Stop, TTL_1H_S, TTL_5M_S, TTL_MARGIN_S,
};
/// Why the flush was asked: the session's own end, three ways.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Why {
/// The context reached the ceiling.
Ceiling,
/// The unit's cap is near.
Rotation,
/// The cache would be evicted and nothing waits.
Idle,
}
/// What the loop saw, already turned into a fact the decision can use.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) enum Event {
/// A request of the main thread: its message id and the context it read.
Request { id: String, prefix: u64 },
/// The CLI took one of our stdin lines into a request (its replay echo).
Echo,
/// A turn ended. `ok`: not an error. `blocked`: the CLI refused the context. `ttl_1h`: the last request wrote its cache for an hour, else five minutes.
Result { ok: bool, blocked: bool, ttl_1h: bool },
/// A poll of the mind: whether a brief waits to be told.
Inbox { pending: bool },
/// `waitpid` returned: the child's exit code, none when a signal ended it.
ChildExited(Option<i32>),
StdoutEof,
/// Time passed, or a line that no rule reads.
Tick,
/// What the operating system said of the signal the last action sent.
Signalled { how: Stop, delivered: bool },
/// The inbox's owner wants the session over at its next idle: what is oldest and unanswered is another conversation's. Only the Persona sends it.
Yield,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) enum Action {
None,
/// The one write: the waiting briefs and the flush ask, coalesced into one user line.
Write { tells: bool, flush: Option<Why> },
CloseStdin,
/// To the child's whole process group.
Signal(Stop),
Finish(SelfRunOutcome),
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct Flush {
/// The flush's line number once written (its echo is the `seq`-th); none while it waits for the outstanding write's echo.
seq: Option<u32>,
/// The prefix at which the flush was asked: the headroom's base.
prefix: u64,
asked: DateTime<Utc>,
/// Its echo has come: the CLI took it into a request.
consumed: bool,
/// A request after its echo reached the headroom.
exhausted: bool,
why: Why,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(super) struct State {
seen: HashSet<String>,
pub(super) requests: u64,
pub(super) prefix: u64,
/// Lines written to stdin, and lines the CLI echoed back as taken.
pub(super) written: u32,
pub(super) consumed: u32,
turn_open: bool,
/// An ok result has come: the session holds an answer worth its cache.
answered_once: bool,
last_result_at: Option<DateTime<Utc>>,
/// The cache lifetime the last result left, in seconds.
pub(super) ttl_s: i64,
pub(super) inbox: bool,
/// The session is to end at its next idle (`Event::Yield`); once asked it stays asked.
yielding: bool,
/// When the process started and the unit's cap in seconds; none where no cap is known.
cap: Option<(DateTime<Utc>, i64)>,
stdin_closed: bool,
flush: Option<Flush>,
/// The last signal attempted and when.
stopping: Option<(Stop, DateTime<Utc>)>,
/// The strongest signal delivered.
delivered: Option<Stop>,
exited: Option<(Option<i32>, DateTime<Utc>)>,
eof: bool,
finished: bool,
}
impl State {
/// A session whose start message was just written.
pub(super) fn started(at: DateTime<Utc>, run_cap_s: u64) -> Self {
Self { written: 1, cap: Some((at, i64::try_from(run_cap_s).unwrap_or(i64::MAX))), ..Self::default() }
}
/// A line written whose echo has not come.
fn outstanding(&self) -> bool {
self.written > self.consumed
}
fn idle(&self) -> bool {
!self.turn_open && !self.outstanding()
}
/// Seconds idle since the last result, none while a line or a turn is outstanding.
pub(super) fn idle_s(&self, now: DateTime<Utc>) -> Option<i64> {
self.idle().then(|| self.last_result_at.map_or(0, |at| (now - at).num_seconds()))
}
}
fn outcome(state: &State, cause: Cause, code: Option<i32>) -> SelfRunOutcome {
SelfRunOutcome::Ended { cause, code, requests: state.requests, prefix: state.prefix }
}
/// How the run ended, by what was done to the child: the strongest signal delivered, else the flush, else nothing.
fn cause(state: &State) -> Cause {
match (state.delivered, &state.flush) {
(Some(how), _) => Cause::Signalled(how),
(None, Some(_)) => Cause::FlushRequested,
(None, None) => Cause::Own,
}
}
/// When the flush's grace runs out: the grace after the write, never later than
/// the point the signal ladder needs to finish before the unit's cap.
fn grace_deadline(state: &State, flush: &Flush) -> DateTime<Utc> {
let grace = flush.asked + Duration::seconds(FLUSH_GRACE_S);
match state.cap {
Some((started, cap)) => grace.min(started + Duration::seconds(cap.saturating_sub(LADDER_S))),
None => grace,
}
}
/// When the flush is next asked on its own, and why: the cap's margin, or the
/// cache's end while idle with no brief waiting.
fn due(state: &State) -> Option<(DateTime<Utc>, Why)> {
if state.stdin_closed || state.exited.is_some() || state.flush.is_some() {
return None;
}
let rotation = state.cap.map(|(started, cap)| (started + Duration::seconds(cap.saturating_sub(ROTATE_MARGIN_S)), Why::Rotation));
let idle = (state.idle() && !state.inbox).then_some(state.last_result_at).flatten().map(|last| (last + Duration::seconds(state.ttl_s - TTL_MARGIN_S), Why::Idle));
[rotation, idle].into_iter().flatten().min_by_key(|(at, _)| *at)
}
/// The moment the loop must next be heard at, if any. A moment already past
/// means an action is due now.
pub(super) fn next_deadline(state: &State) -> Option<DateTime<Utc>> {
if state.finished {
return None;
}
if let Some((_, exited)) = state.exited {
return (!state.eof).then(|| exited + Duration::seconds(EXIT_DRAIN_S));
}
let signals = match (&state.stopping, &state.flush) {
(Some((_, sent)), _) => return Some(*sent + Duration::seconds(ESCALATE_AFTER_S)),
(None, Some(flush)) => Some(grace_deadline(state, flush)),
(None, None) => None,
};
signals.into_iter().chain(due(state).map(|(at, _)| at)).min()
}
/// The flush is asked once, by the ceiling, the cap's margin or the cache's end;
/// never to a child whose input is closed or that has exited. Asking writes
/// nothing: `send` writes it when no write is outstanding.
fn ask_flush(state: &mut State, now: DateTime<Utc>, why: Why) -> bool {
if state.flush.is_some() || state.stdin_closed || state.exited.is_some() {
return false;
}
state.flush = Some(Flush { seq: None, prefix: state.prefix, asked: now, consumed: false, exhausted: false, why });
true
}
/// The lifecycle rules 3 to 5: ask the flush where it is due, then write what waits.
fn lifecycle(state: &mut State, now: DateTime<Utc>) -> Action {
if let Some((at, why)) = due(state)
&& at <= now
{
ask_flush(state, now, why);
}
// A yielding session ends at idle: its own text waits for the next one. A flush already written keeps its own end.
if state.yielding && state.idle() && !state.stdin_closed && state.exited.is_none() && state.flush.as_ref().is_none_or(|flush| flush.seq.is_none()) {
state.stdin_closed = true;
return Action::CloseStdin;
}
send(state)
}
/// With nothing outstanding, the one write: the waiting briefs (until the flush
/// is written) and the flush, if it has not been written, as one line.
fn send(state: &mut State) -> Action {
if state.stdin_closed || state.exited.is_some() || state.outstanding() || state.flush.as_ref().is_some_and(|flush| flush.seq.is_some()) {
return Action::None;
}
let flush = state.flush.as_ref().map(|flush| flush.why);
if !state.inbox && flush.is_none() {
return Action::None;
}
state.written += 1;
let seq = state.written;
if let Some(flush) = state.flush.as_mut() {
flush.seq = Some(seq);
}
Action::Write { tells: std::mem::take(&mut state.inbox), flush }
}
pub(super) fn decide(mut state: State, event: Event, now: DateTime<Utc>) -> (State, Action) {
if state.finished {
return (state, Action::None);
}
let mut action = Action::None;
match event {
Event::Request { id, prefix } => {
if state.seen.insert(id) {
state.requests += 1;
state.prefix = prefix;
if let Some(flush) = state.flush.as_mut()
&& flush.consumed
&& flush.prefix.checked_add(FLUSH_HEADROOM_TOKENS).is_some_and(|limit| prefix >= limit)
{
flush.exhausted = true;
}
if prefix >= CEILING_TOKENS {
ask_flush(&mut state, now, Why::Ceiling);
}
}
}
Event::Echo => {
state.consumed += 1;
state.turn_open = true;
let consumed = state.consumed;
if let Some(flush) = state.flush.as_mut()
&& flush.seq.is_some_and(|seq| consumed >= seq)
{
flush.consumed = true;
}
}
Event::Result { ok, blocked, ttl_1h } => {
state.turn_open = false;
state.last_result_at = Some(now);
state.ttl_s = if ttl_1h { TTL_1H_S } else { TTL_5M_S };
let no_answer = !ok && !state.answered_once;
state.answered_once |= ok;
let flush_answered = state.flush.as_ref().is_some_and(|flush| flush.consumed);
if (blocked || no_answer || flush_answered) && !state.stdin_closed && state.exited.is_none() {
state.stdin_closed = true;
action = Action::CloseStdin;
}
}
Event::Inbox { pending } => state.inbox = pending,
Event::Yield => state.yielding = true,
Event::ChildExited(code) => {
state.exited.get_or_insert((code, now));
}
Event::StdoutEof => state.eof = true,
Event::Tick => {}
Event::Signalled { how, delivered } => {
if delivered {
state.delivered = Some(how);
}
}
}
// A child that has exited is never written to or signalled: the run is over
// once its stream has ended, or once the stream has had its drain.
if let Some((code, exited)) = state.exited {
if state.eof || now >= exited + Duration::seconds(EXIT_DRAIN_S) {
state.finished = true;
let outcome = outcome(&state, cause(&state), code);
return (state, Action::Finish(outcome));
}
return (state, Action::None);
}
if action != Action::None {
return (state, action);
}
if let Some((Stop::Kill, sent)) = state.stopping {
// SIGKILL went out and the child still has not been reaped: stop waiting for it.
if now >= sent + Duration::seconds(ESCALATE_AFTER_S) {
state.finished = true;
let cause = if state.delivered == Some(Stop::Kill) { Cause::Signalled(Stop::Kill) } else { Cause::Unstoppable };
let outcome = outcome(&state, cause, None);
return (state, Action::Finish(outcome));
}
return (state, Action::None);
}
if state.stopping.is_none() {
let next = lifecycle(&mut state, now);
if next != Action::None {
return (state, next);
}
}
let how = match (&state.stopping, &state.flush) {
(Some((last, sent)), _) => (now >= *sent + Duration::seconds(ESCALATE_AFTER_S)).then(|| last.next()),
(None, Some(flush)) => (flush.exhausted || now >= grace_deadline(&state, flush)).then_some(Stop::Interrupt),
(None, None) => None,
};
match how {
Some(how) => {
state.stopping = Some((how, now));
(state, Action::Signal(how))
}
None => (state, Action::None),
}
}
#[cfg(test)]
mod tests;

File diff suppressed because it is too large Load diff

View file

@ -1,138 +1,138 @@
//! The one session driver, for both organs: it writes a live `claude` child's stdin,
//! reads its stream and executes what `decide` says. The Self's briefs and the
//! Persona's chat are the two inboxes it serves (the [`SessionInbox`] each
//! organ implements); the loop, the one outstanding write and every timing
//! decision are the same code (rulings `eureka-body:ruling:self-session-one-outstanding-write`
//! and `eureka-body:ruling:persona-session-full-sandbox`).
use std::collections::VecDeque;
use std::io::Write;
use std::time::Duration as StdDuration;
use anyhow::Result;
use chrono::Duration as ChronoDuration;
use serde_json::json;
use super::decide::{Action, Event, State, Why, decide, next_deadline};
use super::{Child, Read, SelfRunOutcome, Usage, read_usage, write_result, writes_hour_cache};
use crate::launch::Clock;
/// What one poll of an inbox found.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct Polled {
/// Something waits to be told.
pub(crate) pending: bool,
/// The session should end at its next idle: what is oldest and unanswered is not its business.
pub(crate) yield_now: bool,
}
/// What the session is told, and what its results mean: the organ's side of the loop.
pub(crate) trait SessionInbox {
/// The first line written, whole.
fn start_line(&mut self) -> Result<String>;
/// Seconds between polls.
fn poll_every_s(&self) -> i64;
fn poll(&mut self) -> Result<Polled>;
/// The text of what waits, which is thereby told.
fn tell(&mut self) -> Result<String>;
fn flush_text(&self, why: Why) -> String;
/// Every line of the child's stream, before it is read.
fn line(&mut self, _line: &str) {}
/// The CLI took one of our lines into a request.
fn echoed(&mut self) {}
/// A turn ended with `line`; the `(ok, blocked)` the decision sees. An error is the organ's world failing, not the session's.
fn result(&mut self, _line: &str, ok: bool, blocked: bool) -> Result<(bool, bool)> {
Ok((ok, blocked))
}
}
/// The loop: facts in, one clock read per fact, actions out. The one writer into the session.
pub(crate) fn drive(clock: &dyn Clock, mut child: Box<dyn Child>, inbox: &mut dyn SessionInbox, run_cap_s: u64, out: &mut dyn Write) -> Result<SelfRunOutcome> {
let user_line = |text: &str| json!({"type": "user", "message": {"role": "user", "content": text}}).to_string();
child.write_line(&user_line(&inbox.start_line()?));
let poll_every = ChronoDuration::seconds(inbox.poll_every_s());
let longest = StdDuration::from_secs(u64::try_from(inbox.poll_every_s()).unwrap_or(1));
let mut now = clock.now();
let mut state = State::started(now, run_cap_s);
let mut polled = now;
let mut ttl_1h = false;
let mut closed = false;
loop {
let poll_due = polled + poll_every;
let wake = next_deadline(&state).into_iter().chain((!closed).then_some(poll_due)).min();
let wait = wake.map_or(longest, |at| (at - now).to_std().unwrap_or_default().min(longest));
let fact = match child.next(wait) {
Read::Line(line) => {
inbox.line(&line);
write_result(&line, out)?;
if let Some(hour) = writes_hour_cache(&line) {
ttl_1h = hour;
}
match read_usage(&line) {
Usage::Request { id, prefix } => Event::Request { id, prefix },
Usage::Echo => {
inbox.echoed();
Event::Echo
}
Usage::Result { ok, blocked } => {
let (ok, blocked) = inbox.result(&line, ok, blocked)?;
Event::Result { ok, blocked, ttl_1h }
}
Usage::Other | Usage::Unreadable => Event::Tick,
}
}
Read::Timeout => Event::Tick,
Read::Eof => Event::StdoutEof,
Read::Exited(code) => Event::ChildExited(code),
};
now = clock.now();
let mut events = VecDeque::from([fact]);
if !closed && now >= poll_due {
polled = now;
match inbox.poll() {
Ok(found) => {
events.push_back(Event::Inbox { pending: found.pending });
if found.yield_now {
events.push_back(Event::Yield);
}
}
Err(_) => writeln!(out, "inbox poll failed")?,
}
}
while let Some(heard_event) = events.pop_front() {
let counted = state.requests;
// The decision's inputs, as they stood when the event came.
let (ttl_s, inbox_pending, idle_s) = (state.ttl_s, state.inbox || matches!(&heard_event, Event::Inbox { pending: true }), state.idle_s(now));
let (next, action) = decide(state, heard_event, now);
state = next;
if state.requests != counted {
writeln!(out, "request {} prefix {}", state.requests, state.prefix)?;
}
match &action {
Action::None => {}
Action::Write { tells, flush } => {
let mut parts = Vec::new();
if *tells {
parts.push(inbox.tell()?);
}
parts.extend(flush.map(|why| inbox.flush_text(why)));
child.write_line(&user_line(&parts.join("\n\n")));
}
Action::CloseStdin => {
child.close_input();
closed = true;
}
Action::Signal(how) => events.push_front(Event::Signalled { how: *how, delivered: child.stop(*how) }),
Action::Finish(_) => {}
}
if !matches!(action, Action::None | Action::Finish(_)) {
let idle = idle_s.map_or_else(|| "working".to_string(), |seconds| seconds.to_string());
writeln!(out, "decision {action:?} prefix {} ttl_s {ttl_s} idle_s {idle} inbox {inbox_pending} written {} consumed {}", state.prefix, state.written, state.consumed)?;
}
if let Action::Finish(outcome) = action {
return Ok(outcome);
}
}
}
}
//! The one session driver, for both organs: it writes a live `claude` child's stdin,
//! reads its stream and executes what `decide` says. The Self's briefs and the
//! Persona's chat are the two inboxes it serves (the [`SessionInbox`] each
//! organ implements); the loop, the one outstanding write and every timing
//! decision are the same code (rulings `eureka-body:ruling:self-session-one-outstanding-write`
//! and `eureka-body:ruling:persona-session-full-sandbox`).
use std::collections::VecDeque;
use std::io::Write;
use std::time::Duration as StdDuration;
use anyhow::Result;
use chrono::Duration as ChronoDuration;
use serde_json::json;
use super::decide::{Action, Event, State, Why, decide, next_deadline};
use super::{Child, Read, SelfRunOutcome, Usage, read_usage, write_result, writes_hour_cache};
use crate::launch::Clock;
/// What one poll of an inbox found.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct Polled {
/// Something waits to be told.
pub(crate) pending: bool,
/// The session should end at its next idle: what is oldest and unanswered is not its business.
pub(crate) yield_now: bool,
}
/// What the session is told, and what its results mean: the organ's side of the loop.
pub(crate) trait SessionInbox {
/// The first line written, whole.
fn start_line(&mut self) -> Result<String>;
/// Seconds between polls.
fn poll_every_s(&self) -> i64;
fn poll(&mut self) -> Result<Polled>;
/// The text of what waits, which is thereby told.
fn tell(&mut self) -> Result<String>;
fn flush_text(&self, why: Why) -> String;
/// Every line of the child's stream, before it is read.
fn line(&mut self, _line: &str) {}
/// The CLI took one of our lines into a request.
fn echoed(&mut self) {}
/// A turn ended with `line`; the `(ok, blocked)` the decision sees. An error is the organ's world failing, not the session's.
fn result(&mut self, _line: &str, ok: bool, blocked: bool) -> Result<(bool, bool)> {
Ok((ok, blocked))
}
}
/// The loop: facts in, one clock read per fact, actions out. The one writer into the session.
pub(crate) fn drive(clock: &dyn Clock, mut child: Box<dyn Child>, inbox: &mut dyn SessionInbox, run_cap_s: u64, out: &mut dyn Write) -> Result<SelfRunOutcome> {
let user_line = |text: &str| json!({"type": "user", "message": {"role": "user", "content": text}}).to_string();
child.write_line(&user_line(&inbox.start_line()?));
let poll_every = ChronoDuration::seconds(inbox.poll_every_s());
let longest = StdDuration::from_secs(u64::try_from(inbox.poll_every_s()).unwrap_or(1));
let mut now = clock.now();
let mut state = State::started(now, run_cap_s);
let mut polled = now;
let mut ttl_1h = false;
let mut closed = false;
loop {
let poll_due = polled + poll_every;
let wake = next_deadline(&state).into_iter().chain((!closed).then_some(poll_due)).min();
let wait = wake.map_or(longest, |at| (at - now).to_std().unwrap_or_default().min(longest));
let fact = match child.next(wait) {
Read::Line(line) => {
inbox.line(&line);
write_result(&line, out)?;
if let Some(hour) = writes_hour_cache(&line) {
ttl_1h = hour;
}
match read_usage(&line) {
Usage::Request { id, prefix } => Event::Request { id, prefix },
Usage::Echo => {
inbox.echoed();
Event::Echo
}
Usage::Result { ok, blocked } => {
let (ok, blocked) = inbox.result(&line, ok, blocked)?;
Event::Result { ok, blocked, ttl_1h }
}
Usage::Other | Usage::Unreadable => Event::Tick,
}
}
Read::Timeout => Event::Tick,
Read::Eof => Event::StdoutEof,
Read::Exited(code) => Event::ChildExited(code),
};
now = clock.now();
let mut events = VecDeque::from([fact]);
if !closed && now >= poll_due {
polled = now;
match inbox.poll() {
Ok(found) => {
events.push_back(Event::Inbox { pending: found.pending });
if found.yield_now {
events.push_back(Event::Yield);
}
}
Err(_) => writeln!(out, "inbox poll failed")?,
}
}
while let Some(heard_event) = events.pop_front() {
let counted = state.requests;
// The decision's inputs, as they stood when the event came.
let (ttl_s, inbox_pending, idle_s) = (state.ttl_s, state.inbox || matches!(&heard_event, Event::Inbox { pending: true }), state.idle_s(now));
let (next, action) = decide(state, heard_event, now);
state = next;
if state.requests != counted {
writeln!(out, "request {} prefix {}", state.requests, state.prefix)?;
}
match &action {
Action::None => {}
Action::Write { tells, flush } => {
let mut parts = Vec::new();
if *tells {
parts.push(inbox.tell()?);
}
parts.extend(flush.map(|why| inbox.flush_text(why)));
child.write_line(&user_line(&parts.join("\n\n")));
}
Action::CloseStdin => {
child.close_input();
closed = true;
}
Action::Signal(how) => events.push_front(Event::Signalled { how: *how, delivered: child.stop(*how) }),
Action::Finish(_) => {}
}
if !matches!(action, Action::None | Action::Finish(_)) {
let idle = idle_s.map_or_else(|| "working".to_string(), |seconds| seconds.to_string());
writeln!(out, "decision {action:?} prefix {} ttl_s {ttl_s} idle_s {idle} inbox {inbox_pending} written {} consumed {}", state.prefix, state.written, state.consumed)?;
}
if let Action::Finish(outcome) = action {
return Ok(outcome);
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,85 +1,85 @@
//! The `mind-persona` binary as the unit runs it: arguments `--instance`, the rest
//! from `persona.env` and the credential files. The control root is a constant
//! that does not exist where this runs, so a start that is configured right ends
//! dead at the gate (exit 0) before any transport is touched; a start configured
//! wrong ends 2 and names the setting, never a value.
use std::path::Path;
use std::process::{Command, Output};
fn run(dir: &Path, drop: &[&str], extra: &[(&str, &str)], args: &[&str]) -> Output {
std::fs::write(dir.join("chat-key"), "chat-secret-text\n").unwrap();
let key = |name: &str| dir.join(name).to_string_lossy().into_owned();
let mut env = vec![
("HUGINN_ENDPOINT", "rudp://127.0.0.1:9".to_string()),
("STATE_DIRECTORY", dir.to_string_lossy().into_owned()),
("MIND_PERSONA_ROOMS", "#swarm:chat,#ops:chat".to_string()),
("MIND_PERSONA_OPERATOR", "@op:chat".to_string()),
("MIND_PERSONA_MINDS", "@eureka:chat,@libby:chat".to_string()),
("MIND_PERSONA_SELF_SENDER", "@eureka:chat".to_string()),
("MIND_PERSONA_MODEL", "claude-test-model".to_string()),
("MIND_PERSONA_RUN_CAP_S", "14400".to_string()),
("RUNTIME_DIRECTORY", dir.join("runtime").to_string_lossy().into_owned()),
("CLAUDE_LEASE_SOCKET", key("lease.sock")),
("BIFROST_CHAT_ENDPOINT", "rudp://127.0.0.1:17884".to_string()),
("BIFROST_CHAT_KEY_FILE", key("chat-key")),
];
env.retain(|(name, _)| !drop.contains(name) && !extra.iter().any(|(set, _)| set == name));
env.extend(extra.iter().map(|(name, value)| (*name, (*value).to_string())));
Command::new(env!("CARGO_BIN_EXE_mind-persona")).args(args).env_clear().envs(env).output().unwrap()
}
/// What to drop from the environment, what to set instead, and the setting the error must name.
type Case<'a> = (&'a [&'a str], &'a [(&'a str, &'a str)], &'a str);
fn text(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes).into_owned()
}
#[test]
fn a_unit_configured_from_its_environment_alone_reaches_the_gate_and_ends_dead() {
let dir = tempfile::tempdir().unwrap();
let output = run(dir.path(), &[], &[], &["--instance", "eureka"]);
assert_eq!(output.status.code(), Some(0), "{}", text(&output.stderr));
assert!(text(&output.stdout).starts_with("dead:"), "{}", text(&output.stdout));
}
#[test]
fn a_flag_overrides_the_environment() {
let dir = tempfile::tempdir().unwrap();
// The rooms flag replaces the rooms the environment names.
let flagged = run(dir.path(), &[], &[], &["--instance", "eureka", "--rooms", "#swarm:chat"]);
assert_eq!(flagged.status.code(), Some(0), "{}", text(&flagged.stderr));
}
#[test]
fn a_setting_that_is_missing_or_wrong_ends_two_naming_the_setting_and_no_value() {
let dir = tempfile::tempdir().unwrap();
let cases: [Case; 9] = [
(&["MIND_PERSONA_ROOMS"], &[], "usage"),
(&["MIND_PERSONA_MINDS"], &[], "usage"),
(&["MIND_PERSONA_MODEL"], &[], "MIND_PERSONA_MODEL"),
(&["MIND_PERSONA_RUN_CAP_S"], &[], "MIND_PERSONA_RUN_CAP_S"),
(&[], &[("MIND_PERSONA_RUN_CAP_S", "0")], "MIND_PERSONA_RUN_CAP_S"),
(&["RUNTIME_DIRECTORY"], &[], "RUNTIME_DIRECTORY"),
(&["CLAUDE_LEASE_SOCKET"], &[], "CLAUDE_LEASE_SOCKET"),
(&[], &[("BIFROST_CHAT_ENDPOINT", "127.0.0.1:17884")], "BIFROST_CHAT_ENDPOINT"),
(&[], &[("BIFROST_CHAT_KEY_FILE", "/nonexistent/chat-key")], "BIFROST_CHAT_KEY_FILE"),
];
for (drop, extra, names) in cases {
let output = run(dir.path(), drop, extra, &["--instance", "eureka"]);
let stderr = text(&output.stderr);
assert_eq!(output.status.code(), Some(2), "{drop:?} {extra:?}: {stderr}");
assert!(stderr.contains(names), "{drop:?} {extra:?}: {stderr}");
assert!(!stderr.contains("secret-text") && !stderr.contains("127.0.0.1:17884") && !stderr.contains("/nonexistent/chat-key"), "no value is echoed: {stderr}");
}
}
#[test]
fn the_retired_max_turns_flag_is_refused_and_its_value_is_not_echoed() {
let dir = tempfile::tempdir().unwrap();
let output = run(dir.path(), &[], &[], &["--instance", "eureka", "--max-turns", "canary-turns"]);
let stderr = text(&output.stderr);
assert_eq!(output.status.code(), Some(2), "{stderr}");
assert!(stderr.contains("usage") && !stderr.contains("canary-turns"), "{stderr}");
}
//! The `mind-persona` binary as the unit runs it: arguments `--instance`, the rest
//! from `persona.env` and the credential files. The control root is a constant
//! that does not exist where this runs, so a start that is configured right ends
//! dead at the gate (exit 0) before any transport is touched; a start configured
//! wrong ends 2 and names the setting, never a value.
use std::path::Path;
use std::process::{Command, Output};
fn run(dir: &Path, drop: &[&str], extra: &[(&str, &str)], args: &[&str]) -> Output {
std::fs::write(dir.join("chat-key"), "chat-secret-text\n").unwrap();
let key = |name: &str| dir.join(name).to_string_lossy().into_owned();
let mut env = vec![
("HUGINN_ENDPOINT", "rudp://127.0.0.1:9".to_string()),
("STATE_DIRECTORY", dir.to_string_lossy().into_owned()),
("MIND_PERSONA_ROOMS", "#swarm:chat,#ops:chat".to_string()),
("MIND_PERSONA_OPERATOR", "@op:chat".to_string()),
("MIND_PERSONA_MINDS", "@eureka:chat,@libby:chat".to_string()),
("MIND_PERSONA_SELF_SENDER", "@eureka:chat".to_string()),
("MIND_PERSONA_MODEL", "claude-test-model".to_string()),
("MIND_PERSONA_RUN_CAP_S", "14400".to_string()),
("RUNTIME_DIRECTORY", dir.join("runtime").to_string_lossy().into_owned()),
("CLAUDE_LEASE_SOCKET", key("lease.sock")),
("BIFROST_CHAT_ENDPOINT", "rudp://127.0.0.1:17884".to_string()),
("BIFROST_CHAT_KEY_FILE", key("chat-key")),
];
env.retain(|(name, _)| !drop.contains(name) && !extra.iter().any(|(set, _)| set == name));
env.extend(extra.iter().map(|(name, value)| (*name, (*value).to_string())));
Command::new(env!("CARGO_BIN_EXE_mind-persona")).args(args).env_clear().envs(env).output().unwrap()
}
/// What to drop from the environment, what to set instead, and the setting the error must name.
type Case<'a> = (&'a [&'a str], &'a [(&'a str, &'a str)], &'a str);
fn text(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes).into_owned()
}
#[test]
fn a_unit_configured_from_its_environment_alone_reaches_the_gate_and_ends_dead() {
let dir = tempfile::tempdir().unwrap();
let output = run(dir.path(), &[], &[], &["--instance", "eureka"]);
assert_eq!(output.status.code(), Some(0), "{}", text(&output.stderr));
assert!(text(&output.stdout).starts_with("dead:"), "{}", text(&output.stdout));
}
#[test]
fn a_flag_overrides_the_environment() {
let dir = tempfile::tempdir().unwrap();
// The rooms flag replaces the rooms the environment names.
let flagged = run(dir.path(), &[], &[], &["--instance", "eureka", "--rooms", "#swarm:chat"]);
assert_eq!(flagged.status.code(), Some(0), "{}", text(&flagged.stderr));
}
#[test]
fn a_setting_that_is_missing_or_wrong_ends_two_naming_the_setting_and_no_value() {
let dir = tempfile::tempdir().unwrap();
let cases: [Case; 9] = [
(&["MIND_PERSONA_ROOMS"], &[], "usage"),
(&["MIND_PERSONA_MINDS"], &[], "usage"),
(&["MIND_PERSONA_MODEL"], &[], "MIND_PERSONA_MODEL"),
(&["MIND_PERSONA_RUN_CAP_S"], &[], "MIND_PERSONA_RUN_CAP_S"),
(&[], &[("MIND_PERSONA_RUN_CAP_S", "0")], "MIND_PERSONA_RUN_CAP_S"),
(&["RUNTIME_DIRECTORY"], &[], "RUNTIME_DIRECTORY"),
(&["CLAUDE_LEASE_SOCKET"], &[], "CLAUDE_LEASE_SOCKET"),
(&[], &[("BIFROST_CHAT_ENDPOINT", "127.0.0.1:17884")], "BIFROST_CHAT_ENDPOINT"),
(&[], &[("BIFROST_CHAT_KEY_FILE", "/nonexistent/chat-key")], "BIFROST_CHAT_KEY_FILE"),
];
for (drop, extra, names) in cases {
let output = run(dir.path(), drop, extra, &["--instance", "eureka"]);
let stderr = text(&output.stderr);
assert_eq!(output.status.code(), Some(2), "{drop:?} {extra:?}: {stderr}");
assert!(stderr.contains(names), "{drop:?} {extra:?}: {stderr}");
assert!(!stderr.contains("secret-text") && !stderr.contains("127.0.0.1:17884") && !stderr.contains("/nonexistent/chat-key"), "no value is echoed: {stderr}");
}
}
#[test]
fn the_retired_max_turns_flag_is_refused_and_its_value_is_not_echoed() {
let dir = tempfile::tempdir().unwrap();
let output = run(dir.path(), &[], &[], &["--instance", "eureka", "--max-turns", "canary-turns"]);
let stderr = text(&output.stderr);
assert_eq!(output.status.code(), Some(2), "{stderr}");
assert!(stderr.contains("usage") && !stderr.contains("canary-turns"), "{stderr}");
}