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] [package]
name = "mind-body" name = "mind-body"
version = "0.1.0" version = "0.1.0"
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
publish.workspace = true publish.workspace = true
# One crate for her organs (rationale "One crate for her organs"). It holds the # One crate for her organs (rationale "One crate for her organs"). It holds the
# operator's brakes: a root-owned per-instance CultCache # operator's brakes: a root-owned per-instance CultCache
# store (`control`), the operator's CLI over it (`cli`, the `mind-control` # store (`control`), the operator's CLI over it (`cli`, the `mind-control`
# binary) and the one fail-closed reader every acting organ calls # binary) and the one fail-closed reader every acting organ calls
# (`control::read_effective`). It also holds the launch primitive (`launch`, # (`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 # `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 # (`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. # (`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 # The Persona organ (`persona`, the `mind-persona` binary) drains her pending chat
# through ports. The connector transport is ModelConnector's kit client, pinned to # 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 # 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 # 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 # 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 # the chat credential. The kit pins its own CultLib revision, so the graph holds
# two until the workspace pin and the kit's meet. # two until the workspace pin and the kit's meet.
# `rmp-serde` is also the lease wire (`lease_client`): the connector's MessagePack frames. # `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`); # `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. # `uuid`'s v5 makes a Self run's session id derivable from the run's id.
[dependencies] [dependencies]
anyhow = "1" anyhow = "1"
chrono = { version = "0.4.44", features = ["serde"] } chrono = { version = "0.4.44", features = ["serde"] }
cultcache-rs.workspace = true cultcache-rs.workspace = true
cultnet-rs.workspace = true cultnet-rs.workspace = true
eureka-pipeline = { path = "../eureka-pipeline" } eureka-pipeline = { path = "../eureka-pipeline" }
eureka-state = { path = "../eureka-state", default-features = false } eureka-state = { path = "../eureka-state", default-features = false }
huginn-mind = { path = "../huginn-mind" } huginn-mind = { path = "../huginn-mind" }
model-connector = { git = "https://eureka.gamecult.org/forge/GameCult/ModelConnector.git", rev = "c1802d85bee3ae04b0c5737563c8343c1fef515d", package = "model-connector" } model-connector = { git = "https://eureka.gamecult.org/forge/GameCult/ModelConnector.git", rev = "c1802d85bee3ae04b0c5737563c8343c1fef515d", package = "model-connector" }
rmp-serde = "1" rmp-serde = "1"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
sha2 = "0.10" sha2 = "0.10"
uuid = { version = "1", default-features = false, features = ["v5"] } uuid = { version = "1", default-features = false, features = ["v5"] }
[target.'cfg(unix)'.dependencies] [target.'cfg(unix)'.dependencies]
libc = "0.2" libc = "0.2"
# The lease socket: SCM_RIGHTS receive with MSG_CMSG_CLOEXEC and the descriptor # 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 # duplicates, as safe calls (the library forbids unsafe code). Already in the
# graph through tempfile; this adds its `net` feature only. # graph through tempfile; this adds its `net` feature only.
rustix = { version = "1", default-features = false, features = ["std", "net"] } rustix = { version = "1", default-features = false, features = ["std", "net"] }
# `syn`, `quote` and `proc-macro2` read the library's own source for # `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 # 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. # in the workspace's graph (serde's derive uses them); they are test-only.
[dev-dependencies] [dev-dependencies]
proc-macro2 = "1" proc-macro2 = "1"
quote = "1" quote = "1"
syn = { version = "2", features = ["full"] } syn = { version = "2", features = ["full"] }
tempfile = "3" tempfile = "3"
[lib] [lib]
name = "mind_body" name = "mind_body"
path = "src/lib.rs" path = "src/lib.rs"
[[bin]] [[bin]]
name = "mind-control" name = "mind-control"
path = "src/main.rs" path = "src/main.rs"
[[bin]] [[bin]]
name = "mind-self-run" name = "mind-self-run"
path = "src/bin/mind-self-run.rs" path = "src/bin/mind-self-run.rs"
[[bin]] [[bin]]
name = "mind-persona" name = "mind-persona"
path = "src/bin/mind-persona.rs" path = "src/bin/mind-persona.rs"
[[bin]] [[bin]]
name = "mind-heartbeat" name = "mind-heartbeat"
path = "src/bin/mind-heartbeat.rs" path = "src/bin/mind-heartbeat.rs"

View file

@ -1,105 +1,105 @@
//! `mind-persona --instance <slug> [--endpoint rudp://<ip>:<port>]`: //! `mind-persona --instance <slug> [--endpoint rudp://<ip>:<port>]`:
//! one start of the Persona organ (`mind_body::persona::carry`) inside //! one start of the Persona organ (`mind_body::persona::carry`) inside
//! `mind-persona@<instance>`. This reads the process's arguments and //! `mind-persona@<instance>`. This reads the process's arguments and
//! environment, the things the library never reads, and prints the outcome. It //! 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 //! 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 //! 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. //! arguments or the environment are wrong or a transport is not configured.
//! //!
//! The unit's `persona.env` and credentials name everything else: //! The unit's `persona.env` and credentials name everything else:
//! `MIND_PERSONA_ROOMS` (a,b), `MIND_PERSONA_OPERATOR` //! `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 //! (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 //! (`--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` //! 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 //! (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; //! 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_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 //! `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 //! 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 //! 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` //! unit's `RuntimeDirectory=`) holds each process's configuration and instructions; `BIFROST_CHAT_ENDPOINT`
//! (`rudp://<ip>:<port>`, the mouth) and `BIFROST_CHAT_KEY_FILE`. Without //! (`rudp://<ip>:<port>`, the mouth) and `BIFROST_CHAT_KEY_FILE`. Without
//! `--endpoint` the daemon is `HUGINN_ENDPOINT`; the trace is `persona-trace.cc` //! `--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 //! 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. //! text goes to the transport, which holds it; nothing is printed.
use std::net::SocketAddr; use std::net::SocketAddr;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::Duration; use std::time::Duration;
use anyhow::{Context, Result, anyhow, bail}; use anyhow::{Context, Result, anyhow, bail};
use eureka_state::HuginnClient; use eureka_state::HuginnClient;
use mind_body::chat_rudp::{RudpChat, connection_id}; use mind_body::chat_rudp::{RudpChat, connection_id};
use mind_body::cli::instance_and_endpoint; use mind_body::cli::instance_and_endpoint;
use mind_body::control::{CONTROL_ROOT, FileSource, control_path, swarm_path}; use mind_body::control::{CONTROL_ROOT, FileSource, control_path, swarm_path};
use mind_body::launch::{SystemClock, SystemdLauncher}; use mind_body::launch::{SystemClock, SystemdLauncher};
use mind_body::heartbeat::FileStore; use mind_body::heartbeat::FileStore;
use mind_body::lease_client::{LEASE_SOCKET_VARIABLE, SocketLender}; 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::persona::{Config, ENV_CONFIG_SEED, ENV_RUN_CAP, FileTrace, HuginnPersona, Organs, SANDBOX_DIR, carry};
use mind_body::queue::HuginnMind; use mind_body::queue::HuginnMind;
use mind_body::self_run::{ClaudeSpawner, SCRATCH_VARIABLE}; use mind_body::self_run::{ClaudeSpawner, SCRATCH_VARIABLE};
/// How long one call to the mind may take. /// How long one call to the mind may take.
const TIMEOUT: Duration = Duration::from_secs(15); const TIMEOUT: Duration = Duration::from_secs(15);
/// How long the mouth may take to answer one chat request (a send is a Matrix PUT). /// 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 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]"; 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 { 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()) 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 binary is the one place that reads the process: its arguments and
// the environment. The library reads neither (clippy.toml, `#![forbid]` in lib.rs). // 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")] #[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> { fn setting(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|value| !value.is_empty()) std::env::var(name).ok().filter(|value| !value.is_empty())
} }
fn required(name: &str) -> Result<String> { fn required(name: &str) -> Result<String> {
setting(name).ok_or_else(|| anyhow!("{name} is not set")) setting(name).ok_or_else(|| anyhow!("{name} is not set"))
} }
/// The text of the credential file named by `name`; never echoed. /// The text of the credential file named by `name`; never echoed.
fn credential(name: &str) -> Result<String> { 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")) 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")] #[allow(clippy::disallowed_methods, reason = "the binary reads its own arguments and environment, and hands them to the library")]
fn run() -> Result<String> { fn run() -> Result<String> {
let args: Vec<String> = std::env::args().skip(1).collect(); let args: Vec<String> = std::env::args().skip(1).collect();
let (config, rest) = Config::from_args_and_env(&args, setting, USAGE)?; let (config, rest) = Config::from_args_and_env(&args, setting, USAGE)?;
let (instance, address) = instance_and_endpoint(&rest, setting("HUGINN_ENDPOINT"), USAGE)?; let (instance, address) = instance_and_endpoint(&rest, setting("HUGINN_ENDPOINT"), USAGE)?;
let Some(state) = setting("STATE_DIRECTORY") else { bail!("no STATE_DIRECTORY") }; let Some(state) = setting("STATE_DIRECTORY") else { bail!("no STATE_DIRECTORY") };
let chat_endpoint: SocketAddr = required("BIFROST_CHAT_ENDPOINT")? let chat_endpoint: SocketAddr = required("BIFROST_CHAT_ENDPOINT")?
.strip_prefix("rudp://") .strip_prefix("rudp://")
.and_then(|address| address.parse().ok()) .and_then(|address| address.parse().ok())
.ok_or_else(|| anyhow!("BIFROST_CHAT_ENDPOINT is not rudp://<ip>:<port>"))?; .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 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 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 scratch = PathBuf::from(required(SCRATCH_VARIABLE)?);
let sandbox = Path::new(&state).join(SANDBOX_DIR); let sandbox = Path::new(&state).join(SANDBOX_DIR);
let lender = SocketLender::new(required(LEASE_SOCKET_VARIABLE)?); let lender = SocketLender::new(required(LEASE_SOCKET_VARIABLE)?);
let spawner = ClaudeSpawner::in_sandbox(std::env::vars_os(), ENV_CONFIG_SEED, Some(sandbox.clone()))?; 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 source = FileSource::new(control_path(Path::new(CONTROL_ROOT), &instance)?);
let mind = HuginnMind::new(HuginnClient::new(address, instance.clone(), TIMEOUT)); let mind = HuginnMind::new(HuginnClient::new(address, instance.clone(), TIMEOUT));
let persona = HuginnPersona::new(HuginnClient::new(address, instance, TIMEOUT)); let persona = HuginnPersona::new(HuginnClient::new(address, instance, TIMEOUT));
let trace = FileTrace::new(Path::new(&state).join("persona-trace.cc")); let trace = FileTrace::new(Path::new(&state).join("persona-trace.cc"));
let launcher = SystemdLauncher::new(); let launcher = SystemdLauncher::new();
let host = host(); let host = host();
let tempo = setting("MIND_HEARTBEAT_STATE").map(FileStore::new); 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 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))); let swarm = FileSource::new(swarm_path(Path::new(CONTROL_ROOT)));
Ok(carry(&organs, &source, &swarm, &config)?.to_string()) Ok(carry(&organs, &source, &swarm, &config)?.to_string())
} }
fn main() { fn main() {
match run() { match run() {
Ok(outcome) => println!("{outcome}"), Ok(outcome) => println!("{outcome}"),
Err(error) => { Err(error) => {
eprintln!("mind-persona: {error:#}"); eprintln!("mind-persona: {error:#}");
std::process::exit(2); 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 //! 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`). //! sessions, from the windows a credential lease granted (`lease_client`).
use model_connector::PlanQuota; use model_connector::PlanQuota;
/// The plan windows as one line for a Mind: each window's percent used, its /// 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 /// 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 /// 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. /// follows it states the swarm's aim and that nothing enforces it.
pub fn render_plan(windows: &[PlanQuota], now_ms: u64) -> String { 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."; 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() { if windows.is_empty() {
return format!("Plan usage: unobserved. {guidance}"); return format!("Plan usage: unobserved. {guidance}");
} }
let rendered: Vec<String> = windows let rendered: Vec<String> = windows
.iter() .iter()
.map(|plan| match plan.utilization_permille { .map(|plan| match plan.utilization_permille {
Some(permille) => format!( Some(permille) => format!(
"{} {}.{}% {}, resets at unix ms {}, read {}s ago", "{} {}.{}% {}, resets at unix ms {}, read {}s ago",
plan.window, plan.window,
permille / 10, permille / 10,
permille % 10, permille % 10,
plan.status, plan.status,
plan.resets_at_unix_ms, plan.resets_at_unix_ms,
now_ms.saturating_sub(plan.observed_at_unix_ms) / 1000 now_ms.saturating_sub(plan.observed_at_unix_ms) / 1000
), ),
None => format!("{} expired, resets at unix ms {}", plan.window, plan.resets_at_unix_ms), None => format!("{} expired, resets at unix ms {}", plan.window, plan.resets_at_unix_ms),
}) })
.collect(); .collect();
format!("Plan usage: {}. {guidance}", rendered.join("; ")) 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. //! `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 //! 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 //! 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. //! is root-owned and no organ of hers writes it.
//! //!
//! `launch` and `queue` are the launch primitive: the work queue, the //! `launch` and `queue` are the launch primitive: the work queue, the
//! repetition breaker, the one-live-Self-run rule, and the single path that //! 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. //! opens a run in the mind and only then starts its unit.
//! //!
//! `heartbeat` runs the swarm's shared heartbeat over them: the one process, //! `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 //! 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). //! `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 //! `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 //! `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 //! 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). //! port and `chat_rudp` its RUDP transport to Bifrost's mouth).
//! //!
//! `lease_client` asks the Claude connector for a session's credential and the //! `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 //! 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 //! `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. //! 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 // 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 // environment (clippy.toml lists the readers); `forbid` keeps an `allow` from
// putting one back. `forbid(unsafe_code)` closes the readers the list cannot // 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 // 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`. // Grant rule is ruling `eureka-body:ruling:mind-launch-forge-scanner-bound`.
#![forbid(clippy::disallowed_methods)] #![forbid(clippy::disallowed_methods)]
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
pub mod chat_client; pub mod chat_client;
pub mod chat_rudp; pub mod chat_rudp;
pub mod cli; pub mod cli;
pub mod connector; pub mod connector;
pub mod control; pub mod control;
pub mod heartbeat; pub mod heartbeat;
pub mod launch; pub mod launch;
pub mod lease_client; pub mod lease_client;
pub mod persona; pub mod persona;
pub mod queue; pub mod queue;
mod recall; mod recall;
pub mod self_run; pub mod self_run;
pub mod tempo; pub mod tempo;
#[cfg(test)] #[cfg(test)]
mod testkit; 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. //! The Self-run actuator's lifecycle decision, and nothing else.
//! //!
//! `decide(state, event, now) -> (state, action)` is the one owner of when the //! `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 //! 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 //! 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 //! 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 //! 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. //! 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 //! 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 //! `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 //! 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 //! 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 //! 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 //! 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 //! 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. //! waits (the briefs, the flush ask) is coalesced into the next single write.
//! Idle is derived: no turn open and no write outstanding. //! Idle is derived: no turn open and no write outstanding.
//! //!
//! The rules, in this order, while stdin is open and no signal is out: //! 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 //! (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 //! 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 //! 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) //! 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: //! 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 //! 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 //! 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`), //! 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. //! 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 //! 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 //! 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 //! after the flush write and the cap less `LADDER_S`, so the signal ladder
//! finishes before the unit's cap (ruling //! finishes before the unit's cap (ruling
//! `eureka-body:ruling:flush-always-gets-a-turn`). At most one action comes out //! `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 //! of an event; a deadline that is already due yields its action at the next
//! event, and `next_deadline` says it is now. //! event, and `next_deadline` says it is now.
use std::collections::HashSet; use std::collections::HashSet;
use chrono::{DateTime, Duration, Utc}; use chrono::{DateTime, Duration, Utc};
use super::{ 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, 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. /// Why the flush was asked: the session's own end, three ways.
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Why { pub(crate) enum Why {
/// The context reached the ceiling. /// The context reached the ceiling.
Ceiling, Ceiling,
/// The unit's cap is near. /// The unit's cap is near.
Rotation, Rotation,
/// The cache would be evicted and nothing waits. /// The cache would be evicted and nothing waits.
Idle, Idle,
} }
/// What the loop saw, already turned into a fact the decision can use. /// What the loop saw, already turned into a fact the decision can use.
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub(super) enum Event { pub(super) enum Event {
/// A request of the main thread: its message id and the context it read. /// A request of the main thread: its message id and the context it read.
Request { id: String, prefix: u64 }, Request { id: String, prefix: u64 },
/// The CLI took one of our stdin lines into a request (its replay echo). /// The CLI took one of our stdin lines into a request (its replay echo).
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. /// 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 }, Result { ok: bool, blocked: bool, ttl_1h: bool },
/// A poll of the mind: whether a brief waits to be told. /// A poll of the mind: whether a brief waits to be told.
Inbox { pending: bool }, Inbox { pending: bool },
/// `waitpid` returned: the child's exit code, none when a signal ended it. /// `waitpid` returned: the child's exit code, none when a signal ended it.
ChildExited(Option<i32>), ChildExited(Option<i32>),
StdoutEof, StdoutEof,
/// Time passed, or a line that no rule reads. /// Time passed, or a line that no rule reads.
Tick, Tick,
/// What the operating system said of the signal the last action sent. /// What the operating system said of the signal the last action sent.
Signalled { how: Stop, delivered: bool }, 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. /// 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, Yield,
} }
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub(super) enum Action { pub(super) enum Action {
None, None,
/// The one write: the waiting briefs and the flush ask, coalesced into one user line. /// The one write: the waiting briefs and the flush ask, coalesced into one user line.
Write { tells: bool, flush: Option<Why> }, Write { tells: bool, flush: Option<Why> },
CloseStdin, CloseStdin,
/// To the child's whole process group. /// To the child's whole process group.
Signal(Stop), Signal(Stop),
Finish(SelfRunOutcome), Finish(SelfRunOutcome),
} }
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
struct Flush { 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. /// 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>, seq: Option<u32>,
/// The prefix at which the flush was asked: the headroom's base. /// The prefix at which the flush was asked: the headroom's base.
prefix: u64, prefix: u64,
asked: DateTime<Utc>, asked: DateTime<Utc>,
/// Its echo has come: the CLI took it into a request. /// Its echo has come: the CLI took it into a request.
consumed: bool, consumed: bool,
/// A request after its echo reached the headroom. /// A request after its echo reached the headroom.
exhausted: bool, exhausted: bool,
why: Why, why: Why,
} }
#[derive(Clone, Debug, Default, PartialEq, Eq)] #[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(super) struct State { pub(super) struct State {
seen: HashSet<String>, seen: HashSet<String>,
pub(super) requests: u64, pub(super) requests: u64,
pub(super) prefix: u64, pub(super) prefix: u64,
/// Lines written to stdin, and lines the CLI echoed back as taken. /// Lines written to stdin, and lines the CLI echoed back as taken.
pub(super) written: u32, pub(super) written: u32,
pub(super) consumed: u32, pub(super) consumed: u32,
turn_open: bool, turn_open: bool,
/// An ok result has come: the session holds an answer worth its cache. /// An ok result has come: the session holds an answer worth its cache.
answered_once: bool, answered_once: bool,
last_result_at: Option<DateTime<Utc>>, last_result_at: Option<DateTime<Utc>>,
/// The cache lifetime the last result left, in seconds. /// The cache lifetime the last result left, in seconds.
pub(super) ttl_s: i64, pub(super) ttl_s: i64,
pub(super) inbox: bool, pub(super) inbox: bool,
/// The session is to end at its next idle (`Event::Yield`); once asked it stays asked. /// The session is to end at its next idle (`Event::Yield`); once asked it stays asked.
yielding: bool, yielding: bool,
/// When the process started and the unit's cap in seconds; none where no cap is known. /// When the process started and the unit's cap in seconds; none where no cap is known.
cap: Option<(DateTime<Utc>, i64)>, cap: Option<(DateTime<Utc>, i64)>,
stdin_closed: bool, stdin_closed: bool,
flush: Option<Flush>, flush: Option<Flush>,
/// The last signal attempted and when. /// The last signal attempted and when.
stopping: Option<(Stop, DateTime<Utc>)>, stopping: Option<(Stop, DateTime<Utc>)>,
/// The strongest signal delivered. /// The strongest signal delivered.
delivered: Option<Stop>, delivered: Option<Stop>,
exited: Option<(Option<i32>, DateTime<Utc>)>, exited: Option<(Option<i32>, DateTime<Utc>)>,
eof: bool, eof: bool,
finished: bool, finished: bool,
} }
impl State { impl State {
/// A session whose start message was just written. /// A session whose start message was just written.
pub(super) fn started(at: DateTime<Utc>, run_cap_s: u64) -> Self { 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() } 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. /// A line written whose echo has not come.
fn outstanding(&self) -> bool { fn outstanding(&self) -> bool {
self.written > self.consumed self.written > self.consumed
} }
fn idle(&self) -> bool { fn idle(&self) -> bool {
!self.turn_open && !self.outstanding() !self.turn_open && !self.outstanding()
} }
/// Seconds idle since the last result, none while a line or a turn is 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> { 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())) 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 { fn outcome(state: &State, cause: Cause, code: Option<i32>) -> SelfRunOutcome {
SelfRunOutcome::Ended { cause, code, requests: state.requests, prefix: state.prefix } 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. /// 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 { fn cause(state: &State) -> Cause {
match (state.delivered, &state.flush) { match (state.delivered, &state.flush) {
(Some(how), _) => Cause::Signalled(how), (Some(how), _) => Cause::Signalled(how),
(None, Some(_)) => Cause::FlushRequested, (None, Some(_)) => Cause::FlushRequested,
(None, None) => Cause::Own, (None, None) => Cause::Own,
} }
} }
/// When the flush's grace runs out: the grace after the write, never later than /// 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. /// the point the signal ladder needs to finish before the unit's cap.
fn grace_deadline(state: &State, flush: &Flush) -> DateTime<Utc> { fn grace_deadline(state: &State, flush: &Flush) -> DateTime<Utc> {
let grace = flush.asked + Duration::seconds(FLUSH_GRACE_S); let grace = flush.asked + Duration::seconds(FLUSH_GRACE_S);
match state.cap { match state.cap {
Some((started, cap)) => grace.min(started + Duration::seconds(cap.saturating_sub(LADDER_S))), Some((started, cap)) => grace.min(started + Duration::seconds(cap.saturating_sub(LADDER_S))),
None => grace, None => grace,
} }
} }
/// When the flush is next asked on its own, and why: the cap's margin, or the /// 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. /// cache's end while idle with no brief waiting.
fn due(state: &State) -> Option<(DateTime<Utc>, Why)> { fn due(state: &State) -> Option<(DateTime<Utc>, Why)> {
if state.stdin_closed || state.exited.is_some() || state.flush.is_some() { if state.stdin_closed || state.exited.is_some() || state.flush.is_some() {
return None; return None;
} }
let rotation = state.cap.map(|(started, cap)| (started + Duration::seconds(cap.saturating_sub(ROTATE_MARGIN_S)), Why::Rotation)); 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)); 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) [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 /// The moment the loop must next be heard at, if any. A moment already past
/// means an action is due now. /// means an action is due now.
pub(super) fn next_deadline(state: &State) -> Option<DateTime<Utc>> { pub(super) fn next_deadline(state: &State) -> Option<DateTime<Utc>> {
if state.finished { if state.finished {
return None; return None;
} }
if let Some((_, exited)) = state.exited { if let Some((_, exited)) = state.exited {
return (!state.eof).then(|| exited + Duration::seconds(EXIT_DRAIN_S)); return (!state.eof).then(|| exited + Duration::seconds(EXIT_DRAIN_S));
} }
let signals = match (&state.stopping, &state.flush) { let signals = match (&state.stopping, &state.flush) {
(Some((_, sent)), _) => return Some(*sent + Duration::seconds(ESCALATE_AFTER_S)), (Some((_, sent)), _) => return Some(*sent + Duration::seconds(ESCALATE_AFTER_S)),
(None, Some(flush)) => Some(grace_deadline(state, flush)), (None, Some(flush)) => Some(grace_deadline(state, flush)),
(None, None) => None, (None, None) => None,
}; };
signals.into_iter().chain(due(state).map(|(at, _)| at)).min() 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; /// 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 /// never to a child whose input is closed or that has exited. Asking writes
/// nothing: `send` writes it when no write is outstanding. /// nothing: `send` writes it when no write is outstanding.
fn ask_flush(state: &mut State, now: DateTime<Utc>, why: Why) -> bool { fn ask_flush(state: &mut State, now: DateTime<Utc>, why: Why) -> bool {
if state.flush.is_some() || state.stdin_closed || state.exited.is_some() { if state.flush.is_some() || state.stdin_closed || state.exited.is_some() {
return false; return false;
} }
state.flush = Some(Flush { seq: None, prefix: state.prefix, asked: now, consumed: false, exhausted: false, why }); state.flush = Some(Flush { seq: None, prefix: state.prefix, asked: now, consumed: false, exhausted: false, why });
true true
} }
/// The lifecycle rules 3 to 5: ask the flush where it is due, then write what waits. /// 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 { fn lifecycle(state: &mut State, now: DateTime<Utc>) -> Action {
if let Some((at, why)) = due(state) if let Some((at, why)) = due(state)
&& at <= now && at <= now
{ {
ask_flush(state, now, why); 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. // 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()) { 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; state.stdin_closed = true;
return Action::CloseStdin; return Action::CloseStdin;
} }
send(state) send(state)
} }
/// With nothing outstanding, the one write: the waiting briefs (until the flush /// 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. /// is written) and the flush, if it has not been written, as one line.
fn send(state: &mut State) -> Action { 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()) { 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; return Action::None;
} }
let flush = state.flush.as_ref().map(|flush| flush.why); let flush = state.flush.as_ref().map(|flush| flush.why);
if !state.inbox && flush.is_none() { if !state.inbox && flush.is_none() {
return Action::None; return Action::None;
} }
state.written += 1; state.written += 1;
let seq = state.written; let seq = state.written;
if let Some(flush) = state.flush.as_mut() { if let Some(flush) = state.flush.as_mut() {
flush.seq = Some(seq); flush.seq = Some(seq);
} }
Action::Write { tells: std::mem::take(&mut state.inbox), flush } Action::Write { tells: std::mem::take(&mut state.inbox), flush }
} }
pub(super) fn decide(mut state: State, event: Event, now: DateTime<Utc>) -> (State, Action) { pub(super) fn decide(mut state: State, event: Event, now: DateTime<Utc>) -> (State, Action) {
if state.finished { if state.finished {
return (state, Action::None); return (state, Action::None);
} }
let mut action = Action::None; let mut action = Action::None;
match event { match event {
Event::Request { id, prefix } => { Event::Request { id, prefix } => {
if state.seen.insert(id) { if state.seen.insert(id) {
state.requests += 1; state.requests += 1;
state.prefix = prefix; state.prefix = prefix;
if let Some(flush) = state.flush.as_mut() if let Some(flush) = state.flush.as_mut()
&& flush.consumed && flush.consumed
&& flush.prefix.checked_add(FLUSH_HEADROOM_TOKENS).is_some_and(|limit| prefix >= limit) && flush.prefix.checked_add(FLUSH_HEADROOM_TOKENS).is_some_and(|limit| prefix >= limit)
{ {
flush.exhausted = true; flush.exhausted = true;
} }
if prefix >= CEILING_TOKENS { if prefix >= CEILING_TOKENS {
ask_flush(&mut state, now, Why::Ceiling); ask_flush(&mut state, now, Why::Ceiling);
} }
} }
} }
Event::Echo => { Event::Echo => {
state.consumed += 1; state.consumed += 1;
state.turn_open = true; state.turn_open = true;
let consumed = state.consumed; let consumed = state.consumed;
if let Some(flush) = state.flush.as_mut() if let Some(flush) = state.flush.as_mut()
&& flush.seq.is_some_and(|seq| consumed >= seq) && flush.seq.is_some_and(|seq| consumed >= seq)
{ {
flush.consumed = true; flush.consumed = true;
} }
} }
Event::Result { ok, blocked, ttl_1h } => { Event::Result { ok, blocked, ttl_1h } => {
state.turn_open = false; state.turn_open = false;
state.last_result_at = Some(now); state.last_result_at = Some(now);
state.ttl_s = if ttl_1h { TTL_1H_S } else { TTL_5M_S }; state.ttl_s = if ttl_1h { TTL_1H_S } else { TTL_5M_S };
let no_answer = !ok && !state.answered_once; let no_answer = !ok && !state.answered_once;
state.answered_once |= ok; state.answered_once |= ok;
let flush_answered = state.flush.as_ref().is_some_and(|flush| flush.consumed); 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() { if (blocked || no_answer || flush_answered) && !state.stdin_closed && state.exited.is_none() {
state.stdin_closed = true; state.stdin_closed = true;
action = Action::CloseStdin; action = Action::CloseStdin;
} }
} }
Event::Inbox { pending } => state.inbox = pending, Event::Inbox { pending } => state.inbox = pending,
Event::Yield => state.yielding = true, Event::Yield => state.yielding = true,
Event::ChildExited(code) => { Event::ChildExited(code) => {
state.exited.get_or_insert((code, now)); state.exited.get_or_insert((code, now));
} }
Event::StdoutEof => state.eof = true, Event::StdoutEof => state.eof = true,
Event::Tick => {} Event::Tick => {}
Event::Signalled { how, delivered } => { Event::Signalled { how, delivered } => {
if delivered { if delivered {
state.delivered = Some(how); state.delivered = Some(how);
} }
} }
} }
// A child that has exited is never written to or signalled: the run is over // 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. // once its stream has ended, or once the stream has had its drain.
if let Some((code, exited)) = state.exited { if let Some((code, exited)) = state.exited {
if state.eof || now >= exited + Duration::seconds(EXIT_DRAIN_S) { if state.eof || now >= exited + Duration::seconds(EXIT_DRAIN_S) {
state.finished = true; state.finished = true;
let outcome = outcome(&state, cause(&state), code); let outcome = outcome(&state, cause(&state), code);
return (state, Action::Finish(outcome)); return (state, Action::Finish(outcome));
} }
return (state, Action::None); return (state, Action::None);
} }
if action != Action::None { if action != Action::None {
return (state, action); return (state, action);
} }
if let Some((Stop::Kill, sent)) = state.stopping { if let Some((Stop::Kill, sent)) = state.stopping {
// SIGKILL went out and the child still has not been reaped: stop waiting for it. // SIGKILL went out and the child still has not been reaped: stop waiting for it.
if now >= sent + Duration::seconds(ESCALATE_AFTER_S) { if now >= sent + Duration::seconds(ESCALATE_AFTER_S) {
state.finished = true; state.finished = true;
let cause = if state.delivered == Some(Stop::Kill) { Cause::Signalled(Stop::Kill) } else { Cause::Unstoppable }; let cause = if state.delivered == Some(Stop::Kill) { Cause::Signalled(Stop::Kill) } else { Cause::Unstoppable };
let outcome = outcome(&state, cause, None); let outcome = outcome(&state, cause, None);
return (state, Action::Finish(outcome)); return (state, Action::Finish(outcome));
} }
return (state, Action::None); return (state, Action::None);
} }
if state.stopping.is_none() { if state.stopping.is_none() {
let next = lifecycle(&mut state, now); let next = lifecycle(&mut state, now);
if next != Action::None { if next != Action::None {
return (state, next); return (state, next);
} }
} }
let how = match (&state.stopping, &state.flush) { let how = match (&state.stopping, &state.flush) {
(Some((last, sent)), _) => (now >= *sent + Duration::seconds(ESCALATE_AFTER_S)).then(|| last.next()), (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, Some(flush)) => (flush.exhausted || now >= grace_deadline(&state, flush)).then_some(Stop::Interrupt),
(None, None) => None, (None, None) => None,
}; };
match how { match how {
Some(how) => { Some(how) => {
state.stopping = Some((how, now)); state.stopping = Some((how, now));
(state, Action::Signal(how)) (state, Action::Signal(how))
} }
None => (state, Action::None), None => (state, Action::None),
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests; 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, //! 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 //! 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 //! Persona's chat are the two inboxes it serves (the [`SessionInbox`] each
//! organ implements); the loop, the one outstanding write and every timing //! 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` //! decision are the same code (rulings `eureka-body:ruling:self-session-one-outstanding-write`
//! and `eureka-body:ruling:persona-session-full-sandbox`). //! and `eureka-body:ruling:persona-session-full-sandbox`).
use std::collections::VecDeque; use std::collections::VecDeque;
use std::io::Write; use std::io::Write;
use std::time::Duration as StdDuration; use std::time::Duration as StdDuration;
use anyhow::Result; use anyhow::Result;
use chrono::Duration as ChronoDuration; use chrono::Duration as ChronoDuration;
use serde_json::json; use serde_json::json;
use super::decide::{Action, Event, State, Why, decide, next_deadline}; use super::decide::{Action, Event, State, Why, decide, next_deadline};
use super::{Child, Read, SelfRunOutcome, Usage, read_usage, write_result, writes_hour_cache}; use super::{Child, Read, SelfRunOutcome, Usage, read_usage, write_result, writes_hour_cache};
use crate::launch::Clock; use crate::launch::Clock;
/// What one poll of an inbox found. /// What one poll of an inbox found.
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct Polled { pub(crate) struct Polled {
/// Something waits to be told. /// Something waits to be told.
pub(crate) pending: bool, pub(crate) pending: bool,
/// The session should end at its next idle: what is oldest and unanswered is not its business. /// The session should end at its next idle: what is oldest and unanswered is not its business.
pub(crate) yield_now: bool, pub(crate) yield_now: bool,
} }
/// What the session is told, and what its results mean: the organ's side of the loop. /// What the session is told, and what its results mean: the organ's side of the loop.
pub(crate) trait SessionInbox { pub(crate) trait SessionInbox {
/// The first line written, whole. /// The first line written, whole.
fn start_line(&mut self) -> Result<String>; fn start_line(&mut self) -> Result<String>;
/// Seconds between polls. /// Seconds between polls.
fn poll_every_s(&self) -> i64; fn poll_every_s(&self) -> i64;
fn poll(&mut self) -> Result<Polled>; fn poll(&mut self) -> Result<Polled>;
/// The text of what waits, which is thereby told. /// The text of what waits, which is thereby told.
fn tell(&mut self) -> Result<String>; fn tell(&mut self) -> Result<String>;
fn flush_text(&self, why: Why) -> String; fn flush_text(&self, why: Why) -> String;
/// Every line of the child's stream, before it is read. /// Every line of the child's stream, before it is read.
fn line(&mut self, _line: &str) {} fn line(&mut self, _line: &str) {}
/// The CLI took one of our lines into a request. /// The CLI took one of our lines into a request.
fn echoed(&mut self) {} 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. /// 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)> { fn result(&mut self, _line: &str, ok: bool, blocked: bool) -> Result<(bool, bool)> {
Ok((ok, blocked)) Ok((ok, blocked))
} }
} }
/// The loop: facts in, one clock read per fact, actions out. The one writer into the session. /// 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> { 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(); let user_line = |text: &str| json!({"type": "user", "message": {"role": "user", "content": text}}).to_string();
child.write_line(&user_line(&inbox.start_line()?)); child.write_line(&user_line(&inbox.start_line()?));
let poll_every = ChronoDuration::seconds(inbox.poll_every_s()); 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 longest = StdDuration::from_secs(u64::try_from(inbox.poll_every_s()).unwrap_or(1));
let mut now = clock.now(); let mut now = clock.now();
let mut state = State::started(now, run_cap_s); let mut state = State::started(now, run_cap_s);
let mut polled = now; let mut polled = now;
let mut ttl_1h = false; let mut ttl_1h = false;
let mut closed = false; let mut closed = false;
loop { loop {
let poll_due = polled + poll_every; let poll_due = polled + poll_every;
let wake = next_deadline(&state).into_iter().chain((!closed).then_some(poll_due)).min(); 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 wait = wake.map_or(longest, |at| (at - now).to_std().unwrap_or_default().min(longest));
let fact = match child.next(wait) { let fact = match child.next(wait) {
Read::Line(line) => { Read::Line(line) => {
inbox.line(&line); inbox.line(&line);
write_result(&line, out)?; write_result(&line, out)?;
if let Some(hour) = writes_hour_cache(&line) { if let Some(hour) = writes_hour_cache(&line) {
ttl_1h = hour; ttl_1h = hour;
} }
match read_usage(&line) { match read_usage(&line) {
Usage::Request { id, prefix } => Event::Request { id, prefix }, Usage::Request { id, prefix } => Event::Request { id, prefix },
Usage::Echo => { Usage::Echo => {
inbox.echoed(); inbox.echoed();
Event::Echo Event::Echo
} }
Usage::Result { ok, blocked } => { Usage::Result { ok, blocked } => {
let (ok, blocked) = inbox.result(&line, ok, blocked)?; let (ok, blocked) = inbox.result(&line, ok, blocked)?;
Event::Result { ok, blocked, ttl_1h } Event::Result { ok, blocked, ttl_1h }
} }
Usage::Other | Usage::Unreadable => Event::Tick, Usage::Other | Usage::Unreadable => Event::Tick,
} }
} }
Read::Timeout => Event::Tick, Read::Timeout => Event::Tick,
Read::Eof => Event::StdoutEof, Read::Eof => Event::StdoutEof,
Read::Exited(code) => Event::ChildExited(code), Read::Exited(code) => Event::ChildExited(code),
}; };
now = clock.now(); now = clock.now();
let mut events = VecDeque::from([fact]); let mut events = VecDeque::from([fact]);
if !closed && now >= poll_due { if !closed && now >= poll_due {
polled = now; polled = now;
match inbox.poll() { match inbox.poll() {
Ok(found) => { Ok(found) => {
events.push_back(Event::Inbox { pending: found.pending }); events.push_back(Event::Inbox { pending: found.pending });
if found.yield_now { if found.yield_now {
events.push_back(Event::Yield); events.push_back(Event::Yield);
} }
} }
Err(_) => writeln!(out, "inbox poll failed")?, Err(_) => writeln!(out, "inbox poll failed")?,
} }
} }
while let Some(heard_event) = events.pop_front() { while let Some(heard_event) = events.pop_front() {
let counted = state.requests; let counted = state.requests;
// The decision's inputs, as they stood when the event came. // 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 (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); let (next, action) = decide(state, heard_event, now);
state = next; state = next;
if state.requests != counted { if state.requests != counted {
writeln!(out, "request {} prefix {}", state.requests, state.prefix)?; writeln!(out, "request {} prefix {}", state.requests, state.prefix)?;
} }
match &action { match &action {
Action::None => {} Action::None => {}
Action::Write { tells, flush } => { Action::Write { tells, flush } => {
let mut parts = Vec::new(); let mut parts = Vec::new();
if *tells { if *tells {
parts.push(inbox.tell()?); parts.push(inbox.tell()?);
} }
parts.extend(flush.map(|why| inbox.flush_text(why))); parts.extend(flush.map(|why| inbox.flush_text(why)));
child.write_line(&user_line(&parts.join("\n\n"))); child.write_line(&user_line(&parts.join("\n\n")));
} }
Action::CloseStdin => { Action::CloseStdin => {
child.close_input(); child.close_input();
closed = true; closed = true;
} }
Action::Signal(how) => events.push_front(Event::Signalled { how: *how, delivered: child.stop(*how) }), Action::Signal(how) => events.push_front(Event::Signalled { how: *how, delivered: child.stop(*how) }),
Action::Finish(_) => {} Action::Finish(_) => {}
} }
if !matches!(action, Action::None | Action::Finish(_)) { if !matches!(action, Action::None | Action::Finish(_)) {
let idle = idle_s.map_or_else(|| "working".to_string(), |seconds| seconds.to_string()); 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)?; 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 { if let Action::Finish(outcome) = action {
return Ok(outcome); 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 //! 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 //! 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 //! 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 //! dead at the gate (exit 0) before any transport is touched; a start configured
//! wrong ends 2 and names the setting, never a value. //! wrong ends 2 and names the setting, never a value.
use std::path::Path; use std::path::Path;
use std::process::{Command, Output}; use std::process::{Command, Output};
fn run(dir: &Path, drop: &[&str], extra: &[(&str, &str)], args: &[&str]) -> 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(); 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 key = |name: &str| dir.join(name).to_string_lossy().into_owned();
let mut env = vec![ let mut env = vec![
("HUGINN_ENDPOINT", "rudp://127.0.0.1:9".to_string()), ("HUGINN_ENDPOINT", "rudp://127.0.0.1:9".to_string()),
("STATE_DIRECTORY", dir.to_string_lossy().into_owned()), ("STATE_DIRECTORY", dir.to_string_lossy().into_owned()),
("MIND_PERSONA_ROOMS", "#swarm:chat,#ops:chat".to_string()), ("MIND_PERSONA_ROOMS", "#swarm:chat,#ops:chat".to_string()),
("MIND_PERSONA_OPERATOR", "@op:chat".to_string()), ("MIND_PERSONA_OPERATOR", "@op:chat".to_string()),
("MIND_PERSONA_MINDS", "@eureka:chat,@libby:chat".to_string()), ("MIND_PERSONA_MINDS", "@eureka:chat,@libby:chat".to_string()),
("MIND_PERSONA_SELF_SENDER", "@eureka:chat".to_string()), ("MIND_PERSONA_SELF_SENDER", "@eureka:chat".to_string()),
("MIND_PERSONA_MODEL", "claude-test-model".to_string()), ("MIND_PERSONA_MODEL", "claude-test-model".to_string()),
("MIND_PERSONA_RUN_CAP_S", "14400".to_string()), ("MIND_PERSONA_RUN_CAP_S", "14400".to_string()),
("RUNTIME_DIRECTORY", dir.join("runtime").to_string_lossy().into_owned()), ("RUNTIME_DIRECTORY", dir.join("runtime").to_string_lossy().into_owned()),
("CLAUDE_LEASE_SOCKET", key("lease.sock")), ("CLAUDE_LEASE_SOCKET", key("lease.sock")),
("BIFROST_CHAT_ENDPOINT", "rudp://127.0.0.1:17884".to_string()), ("BIFROST_CHAT_ENDPOINT", "rudp://127.0.0.1:17884".to_string()),
("BIFROST_CHAT_KEY_FILE", key("chat-key")), ("BIFROST_CHAT_KEY_FILE", key("chat-key")),
]; ];
env.retain(|(name, _)| !drop.contains(name) && !extra.iter().any(|(set, _)| set == name)); env.retain(|(name, _)| !drop.contains(name) && !extra.iter().any(|(set, _)| set == name));
env.extend(extra.iter().map(|(name, value)| (*name, (*value).to_string()))); 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() 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. /// 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); type Case<'a> = (&'a [&'a str], &'a [(&'a str, &'a str)], &'a str);
fn text(bytes: &[u8]) -> String { fn text(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes).into_owned() String::from_utf8_lossy(bytes).into_owned()
} }
#[test] #[test]
fn a_unit_configured_from_its_environment_alone_reaches_the_gate_and_ends_dead() { fn a_unit_configured_from_its_environment_alone_reaches_the_gate_and_ends_dead() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let output = run(dir.path(), &[], &[], &["--instance", "eureka"]); let output = run(dir.path(), &[], &[], &["--instance", "eureka"]);
assert_eq!(output.status.code(), Some(0), "{}", text(&output.stderr)); assert_eq!(output.status.code(), Some(0), "{}", text(&output.stderr));
assert!(text(&output.stdout).starts_with("dead:"), "{}", text(&output.stdout)); assert!(text(&output.stdout).starts_with("dead:"), "{}", text(&output.stdout));
} }
#[test] #[test]
fn a_flag_overrides_the_environment() { fn a_flag_overrides_the_environment() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
// The rooms flag replaces the rooms the environment names. // The rooms flag replaces the rooms the environment names.
let flagged = run(dir.path(), &[], &[], &["--instance", "eureka", "--rooms", "#swarm:chat"]); let flagged = run(dir.path(), &[], &[], &["--instance", "eureka", "--rooms", "#swarm:chat"]);
assert_eq!(flagged.status.code(), Some(0), "{}", text(&flagged.stderr)); assert_eq!(flagged.status.code(), Some(0), "{}", text(&flagged.stderr));
} }
#[test] #[test]
fn a_setting_that_is_missing_or_wrong_ends_two_naming_the_setting_and_no_value() { fn a_setting_that_is_missing_or_wrong_ends_two_naming_the_setting_and_no_value() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let cases: [Case; 9] = [ let cases: [Case; 9] = [
(&["MIND_PERSONA_ROOMS"], &[], "usage"), (&["MIND_PERSONA_ROOMS"], &[], "usage"),
(&["MIND_PERSONA_MINDS"], &[], "usage"), (&["MIND_PERSONA_MINDS"], &[], "usage"),
(&["MIND_PERSONA_MODEL"], &[], "MIND_PERSONA_MODEL"), (&["MIND_PERSONA_MODEL"], &[], "MIND_PERSONA_MODEL"),
(&["MIND_PERSONA_RUN_CAP_S"], &[], "MIND_PERSONA_RUN_CAP_S"), (&["MIND_PERSONA_RUN_CAP_S"], &[], "MIND_PERSONA_RUN_CAP_S"),
(&[], &[("MIND_PERSONA_RUN_CAP_S", "0")], "MIND_PERSONA_RUN_CAP_S"), (&[], &[("MIND_PERSONA_RUN_CAP_S", "0")], "MIND_PERSONA_RUN_CAP_S"),
(&["RUNTIME_DIRECTORY"], &[], "RUNTIME_DIRECTORY"), (&["RUNTIME_DIRECTORY"], &[], "RUNTIME_DIRECTORY"),
(&["CLAUDE_LEASE_SOCKET"], &[], "CLAUDE_LEASE_SOCKET"), (&["CLAUDE_LEASE_SOCKET"], &[], "CLAUDE_LEASE_SOCKET"),
(&[], &[("BIFROST_CHAT_ENDPOINT", "127.0.0.1:17884")], "BIFROST_CHAT_ENDPOINT"), (&[], &[("BIFROST_CHAT_ENDPOINT", "127.0.0.1:17884")], "BIFROST_CHAT_ENDPOINT"),
(&[], &[("BIFROST_CHAT_KEY_FILE", "/nonexistent/chat-key")], "BIFROST_CHAT_KEY_FILE"), (&[], &[("BIFROST_CHAT_KEY_FILE", "/nonexistent/chat-key")], "BIFROST_CHAT_KEY_FILE"),
]; ];
for (drop, extra, names) in cases { for (drop, extra, names) in cases {
let output = run(dir.path(), drop, extra, &["--instance", "eureka"]); let output = run(dir.path(), drop, extra, &["--instance", "eureka"]);
let stderr = text(&output.stderr); let stderr = text(&output.stderr);
assert_eq!(output.status.code(), Some(2), "{drop:?} {extra:?}: {stderr}"); assert_eq!(output.status.code(), Some(2), "{drop:?} {extra:?}: {stderr}");
assert!(stderr.contains(names), "{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}"); assert!(!stderr.contains("secret-text") && !stderr.contains("127.0.0.1:17884") && !stderr.contains("/nonexistent/chat-key"), "no value is echoed: {stderr}");
} }
} }
#[test] #[test]
fn the_retired_max_turns_flag_is_refused_and_its_value_is_not_echoed() { fn the_retired_max_turns_flag_is_refused_and_its_value_is_not_echoed() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let output = run(dir.path(), &[], &[], &["--instance", "eureka", "--max-turns", "canary-turns"]); let output = run(dir.path(), &[], &[], &["--instance", "eureka", "--max-turns", "canary-turns"]);
let stderr = text(&output.stderr); let stderr = text(&output.stderr);
assert_eq!(output.status.code(), Some(2), "{stderr}"); assert_eq!(output.status.code(), Some(2), "{stderr}");
assert!(stderr.contains("usage") && !stderr.contains("canary-turns"), "{stderr}"); assert!(stderr.contains("usage") && !stderr.contains("canary-turns"), "{stderr}");
} }