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:
parent
359ae784ec
commit
ffcd2666c5
12 changed files with 8017 additions and 8017 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("; "))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue