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]
|
[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"
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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("; "))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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}");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue