huginn: --session falls back to HUGINN_SESSION, set by mind-self-run #45
11 changed files with 195 additions and 34 deletions
|
|
@ -15,6 +15,10 @@ use crate::trouble::Trouble;
|
|||
pub const TOOL: &str = "huginn";
|
||||
/// How long one call may take, connection to last byte.
|
||||
const TIMEOUT: Duration = Duration::from_secs(15);
|
||||
/// The environment variable a Self run sets to its run label: the session an admitting verb attributes to when `--session` is not given.
|
||||
pub const SESSION_VARIABLE: &str = "HUGINN_SESSION";
|
||||
/// `Short`'s bound in bytes (the leaf keeps it private); the daemon enforces it again.
|
||||
const SESSION_MAX: usize = 200;
|
||||
/// What `ask_until` leaves of its deadline for the caller to print and exit.
|
||||
const RESERVE: Duration = Duration::from_millis(100);
|
||||
|
||||
|
|
@ -39,6 +43,22 @@ impl Settings {
|
|||
self.client.as_ref().map_err(|detail| Trouble::Misconfigured(detail.clone()))
|
||||
}
|
||||
|
||||
/// The session an admission is attributed to: the flag as given; else `HUGINN_SESSION` when it
|
||||
/// is set and not empty; else a refusal. Never guessed and never any other variable.
|
||||
pub fn session(&self, flag: Option<String>) -> Result<String, Trouble> {
|
||||
if let Some(flag) = flag {
|
||||
return Ok(flag);
|
||||
}
|
||||
match variable(SESSION_VARIABLE) {
|
||||
Err(detail) => Err(Trouble::invalid(detail)),
|
||||
Ok(Some(value)) if value.len() > SESSION_MAX => {
|
||||
Err(Trouble::invalid(format!("{SESSION_VARIABLE} is longer than {SESSION_MAX} bytes")))
|
||||
}
|
||||
Ok(Some(value)) if !value.is_empty() => Ok(value),
|
||||
Ok(_) => Err(Trouble::invalid(format!("--session is required (or {SESSION_VARIABLE} set to the session's label)"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// A client for another mind, read from: `instance` at `endpoint` (`rudp://<ip>:<port>`), both
|
||||
/// given as arguments. It refuses this process's own instance before any call, so the one
|
||||
/// mind a verb writes is never also its source. Every request through it declares `instance`,
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
//! strict decode of agent-written JSON into the leaf's types (`door`), the
|
||||
//! recipe selections (`recipes`), the text rendering (`render`), the exit code
|
||||
//! and the read-back after an admission. It owns no state, writes no file and
|
||||
//! reads no configuration but two environment variables: `EUREKA_INSTANCE`
|
||||
//! reads no configuration but these environment variables: `EUREKA_INSTANCE`
|
||||
//! names the mind and `HUGINN_ENDPOINT` (`rudp://host:port`) the daemon that
|
||||
//! holds it. Each call is a new process; there is no retry inside it, the
|
||||
//! holds it. The admitting verbs take `--session`, else `HUGINN_SESSION`. Each call is a new process; there is no retry inside it, the
|
||||
//! caller's rule retries `Unavailable` once.
|
||||
//!
|
||||
//! Answers go to stdout and the exit code says what the answer was: 0 answered,
|
||||
|
|
@ -125,9 +125,9 @@ enum Command {
|
|||
/// The admitting agent's name.
|
||||
#[arg(long)]
|
||||
agent: String,
|
||||
/// The admitting session.
|
||||
/// The admitting session; else the environment's HUGINN_SESSION, which a Self run sets to its run.
|
||||
#[arg(long)]
|
||||
session: String,
|
||||
session: Option<String>,
|
||||
/// The token of the hold this exact batch got: admit it.
|
||||
#[arg(long)]
|
||||
seen: Option<String>,
|
||||
|
|
@ -152,9 +152,9 @@ enum Command {
|
|||
/// The importing agent's name.
|
||||
#[arg(long)]
|
||||
agent: String,
|
||||
/// The importing session.
|
||||
/// The importing session; else the environment's HUGINN_SESSION.
|
||||
#[arg(long)]
|
||||
session: String,
|
||||
session: Option<String>,
|
||||
},
|
||||
/// Recall into a dispatched brief.
|
||||
Recall {
|
||||
|
|
@ -198,9 +198,9 @@ enum PersonaCommand {
|
|||
/// The writing agent's name.
|
||||
#[arg(long)]
|
||||
agent: String,
|
||||
/// The writing session.
|
||||
/// The writing session; else the environment's HUGINN_SESSION.
|
||||
#[arg(long)]
|
||||
session: String,
|
||||
session: Option<String>,
|
||||
/// The stored updatedAt this put replaces; omit for the first put.
|
||||
#[arg(long)]
|
||||
expected_updated_at: Option<String>,
|
||||
|
|
@ -290,6 +290,7 @@ fn run(settings: &Settings, command: Command) -> Result<u8, Trouble> {
|
|||
}
|
||||
Command::Admit { faculty, agent, session, seen, file } => admit(settings, faculty, agent, session, seen, &file),
|
||||
Command::Import { campaign, from, from_endpoint, faculty, agent, session } => {
|
||||
let session = settings.session(session)?;
|
||||
import::run(settings, import::Flags { campaign, from, from_endpoint, faculty, agent, session })
|
||||
}
|
||||
Command::Persona { command: PersonaCommand::Get } => persona_get(settings),
|
||||
|
|
@ -489,10 +490,11 @@ fn admit(
|
|||
settings: &Settings,
|
||||
faculty: Faculty,
|
||||
agent: String,
|
||||
session: String,
|
||||
session: Option<String>,
|
||||
seen: Option<String>,
|
||||
file: &str,
|
||||
) -> Result<u8, Trouble> {
|
||||
let session = settings.session(session)?;
|
||||
let documents = door::documents(&door::file_or_stdin(file)?)?;
|
||||
if documents.is_empty() || documents.len() > BATCH_MAX {
|
||||
return Err(Trouble::invalid(format!("a batch is 1 to {BATCH_MAX} documents, this one has {}", documents.len())));
|
||||
|
|
@ -649,10 +651,11 @@ fn persona_put(
|
|||
settings: &Settings,
|
||||
faculty: Faculty,
|
||||
agent: String,
|
||||
session: String,
|
||||
session: Option<String>,
|
||||
expected_updated_at: Option<String>,
|
||||
file: &str,
|
||||
) -> Result<u8, Trouble> {
|
||||
let session = settings.session(session)?;
|
||||
let text = door::file_or_stdin(file)?;
|
||||
let state: Value = serde_json::from_str(&text)
|
||||
.map_err(|error| Trouble::invalid(format!("the persona document is not JSON: {error}")))?;
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ fn run(env: &[(&str, &str)], args: &[&str], stdin: Option<&str>) -> Run {
|
|||
command
|
||||
.env_remove("EUREKA_INSTANCE")
|
||||
.env_remove("HUGINN_ENDPOINT")
|
||||
.env_remove("HUGINN_SESSION")
|
||||
.envs(env.iter().copied())
|
||||
.args(args)
|
||||
.stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() })
|
||||
|
|
@ -2782,3 +2783,81 @@ fn import_wrong_endpoint_is_foreign_instance() {
|
|||
assert_eq!(own.code, 2, "{}{}", own.out, own.err);
|
||||
assert!(own.err.starts_with("InvalidInput: --from heimdall is this process's own instance"), "{}", own.err);
|
||||
}
|
||||
|
||||
/// Configured for the test instance against a daemon at `addr`, with `HUGINN_SESSION` as given.
|
||||
fn with_session(addr: SocketAddr, session: &str, args: &[&str], stdin: Option<&str>) -> Run {
|
||||
let endpoint = format!("rudp://{addr}");
|
||||
run(&[("EUREKA_INSTANCE", INSTANCE), ("HUGINN_ENDPOINT", &endpoint), ("HUGINN_SESSION", session)], args, stdin)
|
||||
}
|
||||
|
||||
fn admit_without_session(file: &str) -> Vec<&str> {
|
||||
vec!["admit", "--faculty", "Hands", "--agent", "claude", file]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_session_is_the_flag_else_huginn_session_else_a_refusal() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = write(&dir, "campaign.json", &json!([campaign_json()]));
|
||||
let server = live();
|
||||
|
||||
// Neither: refused before any send, nothing admitted.
|
||||
let neither = at(server.addr, &admit_without_session(&file), None);
|
||||
assert_eq!(neither.code, 2, "{}", neither.err);
|
||||
assert!(neither.err.starts_with("InvalidInput: --session is required"), "{}", neither.err);
|
||||
assert_eq!(neither.out, "");
|
||||
let count = at(server.addr, &["whoami"], None);
|
||||
assert!(count.out.contains("receipts 2 "), "{}", count.out);
|
||||
|
||||
// An empty variable is absent.
|
||||
let empty = with_session(server.addr, "", &admit_without_session(&file), None);
|
||||
assert_eq!(empty.code, 2, "{}", empty.err);
|
||||
assert!(empty.err.starts_with("InvalidInput: --session is required"), "{}", empty.err);
|
||||
assert!(at(server.addr, &["whoami"], None).out.contains("receipts 2 "));
|
||||
|
||||
// The variable alone: the receipt carries it.
|
||||
let env_only = with_session(server.addr, "mind-20261111T072944197Z", &admit_without_session(&file), None);
|
||||
assert_eq!(env_only.code, 0, "{}", env_only.err);
|
||||
let viewed = at(server.addr, &["view", &format!("{CAMPAIGN}:campaign:self")], None);
|
||||
assert!(viewed.out.contains(" Hands/claude session mind-20261111T072944197Z tool huginn "), "{}", viewed.out);
|
||||
assert!(at(server.addr, &["whoami"], None).out.contains("receipts 3 "));
|
||||
|
||||
// The persona door takes it the same way.
|
||||
let persona_file = write(&dir, "persona.json", &persona(INSTANCE, "2026-10-10T10:00:00Z"));
|
||||
let put = with_session(server.addr, "mind-run-p", &["persona", "put", "--faculty", "SelfFaculty", "--agent", "claude", &persona_file], None);
|
||||
assert_eq!(put.code, 0, "{}", put.err);
|
||||
let receipt = put.out.strip_prefix("Committed ").and_then(|rest| rest.strip_suffix('\n')).expect(&put.out);
|
||||
let got = at(server.addr, &["persona", "get"], None);
|
||||
assert!(got.out.contains(&format!("receipt {receipt}")), "{}", got.out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_flag_wins_over_huginn_session() {
|
||||
let (server, seen) = replying("admit", HuginnMindResponse::Admit(PipelineAdmissionOutcome::Committed { receipt_id: "mind-commit-x".into(), committed_at: "2026-11-11T00:00:00Z".into(), writes: vec![] }));
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = write(&dir, "campaign.json", &json!([campaign_json()]));
|
||||
let _ = with_session(server.addr, "from-env", &admit_args(&file), None);
|
||||
let seen = seen.lock().unwrap();
|
||||
let sessions: Vec<&str> = seen
|
||||
.iter()
|
||||
.filter_map(|request| if let HuginnMindRequest::Admit(batch) = request { Some(batch.provenance.session.0.as_str()) } else { None })
|
||||
.collect();
|
||||
assert_eq!(sessions, ["session-7"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_huginn_session_is_refused_naming_the_variable_without_echoing_it() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = write(&dir, "campaign.json", &json!([campaign_json()]));
|
||||
let (server, count) = counting(HuginnMindResponse::View(None), "view");
|
||||
let long = "L".repeat(201);
|
||||
let ran = with_session(server.addr, &long, &admit_without_session(&file), None);
|
||||
assert_eq!(ran.code, 2, "{}", ran.err);
|
||||
assert!(ran.err.starts_with("InvalidInput: HUGINN_SESSION is longer than 200 bytes"), "{}", ran.err);
|
||||
assert!(!ran.err.contains("LLLL"), "{}", ran.err);
|
||||
assert_eq!(*count.lock().unwrap(), 0, "nothing was sent");
|
||||
|
||||
// The bound itself is allowed.
|
||||
let live = live();
|
||||
let edge = with_session(live.addr, &"E".repeat(200), &admit_without_session(&file), None);
|
||||
assert_eq!(edge.code, 0, "{}", edge.err);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1006,7 +1006,7 @@ pub(crate) fn start_session(
|
|||
Ok(Lent::Granted(lease)) => lease,
|
||||
Ok(Lent::Refused(_)) | Err(_) => return Ok(Err(Failure::Lease)),
|
||||
};
|
||||
let Ok(child) = organs.spawner.spawn(&persona_args(session_id, &config.model, &schema, &prompt), lease.token()) else { return Ok(Err(Failure::Session)) };
|
||||
let Ok(child) = organs.spawner.spawn(&persona_args(session_id, &config.model, &schema, &prompt), lease.token(), None) else { return Ok(Err(Failure::Session)) };
|
||||
Ok(Ok(Started { child, lease, started_ms }))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -247,7 +247,7 @@ struct FakeSession {
|
|||
}
|
||||
|
||||
impl Spawner for FakeSession {
|
||||
fn spawn(&self, args: &[String], _token: &TokenFd) -> Result<Box<dyn Child>> {
|
||||
fn spawn(&self, args: &[String], _token: &TokenFd, _session: Option<&str>) -> Result<Box<dyn Child>> {
|
||||
if self.refuse.get() {
|
||||
return Err(anyhow!("refused"));
|
||||
}
|
||||
|
|
@ -2711,7 +2711,7 @@ impl Child for Timed {
|
|||
struct ScriptedSpawner<T>(RefCell<VecDeque<T>>);
|
||||
|
||||
impl<T: Child + 'static> crate::self_run::Spawner for ScriptedSpawner<T> {
|
||||
fn spawn(&self, _args: &[String], _token: &TokenFd) -> Result<Box<dyn Child>> {
|
||||
fn spawn(&self, _args: &[String], _token: &TokenFd, _session: Option<&str>) -> Result<Box<dyn Child>> {
|
||||
self.0.borrow_mut().pop_front().map(|child| Box::new(child) as Box<dyn Child>).ok_or_else(|| anyhow!("no more processes"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,6 +150,8 @@ const ENV_SCRUB_VARIABLE: &str = "CLAUDE_CODE_SUBPROCESS_ENV_SCRUB";
|
|||
/// Optional, set by the unit: `off` turns that scrub off for a run whose user holds root, where it
|
||||
/// protects nothing and its sandbox's no_new_privs would only stop `sudo`. Anything else keeps it on.
|
||||
const SCRUB_VARIABLE: &str = "MIND_SELF_SCRUB";
|
||||
/// The variable the child's `huginn` reads its `--session` from: the run's label, the id the start message names.
|
||||
const SESSION_VARIABLE: &str = "HUGINN_SESSION";
|
||||
/// The name the start message tells her to admit as.
|
||||
pub const AGENT: &str = "mind-self-run";
|
||||
/// The namespace of the session id: the run id is the name in it.
|
||||
|
|
@ -227,6 +229,12 @@ fn flush_text(why: Why) -> String {
|
|||
format!("{cause} {FLUSH_MESSAGE} Close each brief that is done by the closing rule; a brief whose change is not on its default branch stays open, or is superseded by your own follow-up when it waits on someone else, and the next session continues it.")
|
||||
}
|
||||
|
||||
/// The label of a run, the last segment of its id (`<instance>:run:<label>`): what the opening receipt of every run already carries as its session
|
||||
/// (`launch::run_label`), so a `huginn` admission and the run's own receipts name one session in one spelling.
|
||||
fn run_label(run: &PipelineRef) -> &str {
|
||||
run.id.0.rsplit(':').next().unwrap_or(&run.id.0)
|
||||
}
|
||||
|
||||
/// The session id of a run: UUIDv5 over the run's id. Derived, never stored.
|
||||
pub fn session_id(run: &PipelineRef) -> Uuid {
|
||||
Uuid::new_v5(&SESSION_NAMESPACE, run.id.0.as_bytes())
|
||||
|
|
@ -464,8 +472,10 @@ pub struct Kept {
|
|||
}
|
||||
|
||||
pub trait Spawner {
|
||||
/// Starts the child with `token` as its credential descriptor.
|
||||
fn spawn(&self, args: &[String], token: &TokenFd) -> Result<Box<dyn Child>>;
|
||||
/// Starts the child with `token` as its credential descriptor and, when `session` is given, that label
|
||||
/// as its `HUGINN_SESSION`: the session its `huginn` admissions are attributed to. A child with no run
|
||||
/// label (the Persona's) is given none.
|
||||
fn spawn(&self, args: &[String], token: &TokenFd, session: Option<&str>) -> Result<Box<dyn Child>>;
|
||||
|
||||
/// The organ's one kept conversation, if any. A spawner that keeps nothing says none.
|
||||
fn kept(&self) -> Option<Kept> {
|
||||
|
|
@ -728,7 +738,7 @@ fn lend_and_drive(mind: &dyn MindPort, clock: &dyn Clock, lender: &dyn Lender, s
|
|||
heard.resumed = Some(kept.session);
|
||||
writeln!(out, "session resumed {} warm until {}", kept.session, kept.warm_until.to_rfc3339_opts(SecondsFormat::Secs, true))?;
|
||||
}
|
||||
let child = spawner.spawn(&claude_args(session_id(run), resume.as_ref().map(|kept| kept.transcript.as_path())), lease.token())?;
|
||||
let child = spawner.spawn(&claude_args(session_id(run), resume.as_ref().map(|kept| kept.transcript.as_path())), lease.token(), Some(run_label(run)))?;
|
||||
let mut inbox = SelfInbox::new(mind, (run, claims), task, (lease.plan_quota(), started_ms), (told, conversation), heard, resume.is_some());
|
||||
let ran = drive(clock, child, &mut inbox, run_cap_s, out);
|
||||
inbox.admit_unadmitted();
|
||||
|
|
@ -1090,13 +1100,13 @@ impl Spawner for ClaudeSpawner {
|
|||
clear_kept_dir(dir, &[KEPT_RECORD.to_string(), format!("{session}.jsonl")]).context("the conversation before it could not be removed")
|
||||
}
|
||||
|
||||
fn spawn(&self, args: &[String], token: &TokenFd) -> Result<Box<dyn Child>> {
|
||||
fn spawn(&self, args: &[String], token: &TokenFd, session: Option<&str>) -> Result<Box<dyn Child>> {
|
||||
let config = FreshConfig::create(&self.scratch, self.seed.as_deref(), self.keep.clone())?;
|
||||
let Some(sandbox) = &self.sandbox else { return spawn_program(Path::new("claude"), args, token, &self.unit_environment, config) };
|
||||
let Some(sandbox) = &self.sandbox else { return spawn_program(Path::new("claude"), args, token, (&self.unit_environment, session), config) };
|
||||
let mut wrapped = sandbox_args(sandbox, &self.scratch);
|
||||
wrapped.push("claude".into());
|
||||
wrapped.extend(args.iter().cloned());
|
||||
spawn_program(Path::new("bwrap"), &wrapped, token, &self.unit_environment, config)
|
||||
spawn_program(Path::new("bwrap"), &wrapped, token, (&self.unit_environment, session), config)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1274,6 +1284,7 @@ fn copy_level(root: &Path, from: &Path, to: &Path) -> std::io::Result<()> {
|
|||
/// `CLAUDE_CONFIG_DIR` are its fresh configuration directory, set by the spawn.
|
||||
/// `EUREKA_INSTANCE` and `HUGINN_ENDPOINT` are the `huginn` CLI's whole identity
|
||||
/// (ruling `eureka-body:ruling:cli-over-mcp-agent-surface`): a name and an address, no secret.
|
||||
/// Its session, `HUGINN_SESSION`, is set by the spawn from the run, never passed through.
|
||||
fn child_may_see(name: &OsStr) -> bool {
|
||||
let name = name.to_string_lossy();
|
||||
matches!(name.as_ref(), "PATH" | "LANG" | "TERM" | "EUREKA_INSTANCE" | "HUGINN_ENDPOINT") || name.starts_with("LC_")
|
||||
|
|
@ -1295,13 +1306,17 @@ fn scrub_is_off(unit: &[(OsString, OsString)]) -> bool {
|
|||
/// token. `claude` reads it to the end once at startup, so what its own children
|
||||
/// inherit is an emptied pipe whose write end exists nowhere. The child's
|
||||
/// environment is cleared and rebuilt from `unit`'s allow-listed variables.
|
||||
fn spawn_program(program: &Path, args: &[String], token: &TokenFd, unit: &[(OsString, OsString)], config: FreshConfig) -> Result<Box<dyn Child>> {
|
||||
fn spawn_program(program: &Path, args: &[String], token: &TokenFd, (unit, session): (&[(OsString, OsString)], Option<&str>), config: FreshConfig) -> Result<Box<dyn Child>> {
|
||||
let inheritable = token.inheritable().context("the credential descriptor could not be placed")?;
|
||||
let mut command = Command::new(program);
|
||||
command.args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::inherit());
|
||||
command.env_clear().envs(child_environment(unit));
|
||||
command.env("HOME", &config.dir).env(CONFIG_DIR_VARIABLE, &config.dir);
|
||||
command.env(DISABLE_COMPACT_VARIABLE, "1").env(TOKEN_FD_VARIABLE, inheritable.number().to_string()).env(ENV_SCRUB_VARIABLE, if scrub_is_off(unit) { "0" } else { "1" });
|
||||
// The unit's own `HUGINN_SESSION` is not on the allow-list: the only value the child can hold is the one its run was given.
|
||||
if let Some(session) = session {
|
||||
command.env(SESSION_VARIABLE, session);
|
||||
}
|
||||
// Its own process group, so that a signal reaches what the child started too.
|
||||
#[cfg(unix)]
|
||||
std::os::unix::process::CommandExt::process_group(&mut command, 0);
|
||||
|
|
|
|||
|
|
@ -138,6 +138,7 @@ struct Ran {
|
|||
/// Every event, the lease's included.
|
||||
all: Vec<Event>,
|
||||
spawns: Vec<Vec<String>>,
|
||||
sessions: Vec<Option<String>>,
|
||||
admits: usize,
|
||||
asked: Vec<LeaseRequest>,
|
||||
}
|
||||
|
|
@ -215,7 +216,8 @@ fn carry_kept(seen: &Seen, steps: Vec<Step>, (code, dies_on, refuses, hangs): (O
|
|||
// The child's own events; the lease's are in `all`, in order with them.
|
||||
let events = all.iter().filter(|event| !matches!(event, Event::Lent | Event::Spawned | Event::Abandoned | Event::Kept(_))).cloned().collect();
|
||||
let spawns = fake.spawns.borrow().clone();
|
||||
Ran { outcome, out: String::from_utf8(out).unwrap(), events, all, spawns, admits: seen.admits.get(), asked: lender.asked.borrow().clone() }
|
||||
let sessions = fake.sessions.borrow().clone();
|
||||
Ran { outcome, out: String::from_utf8(out).unwrap(), events, all, spawns, sessions, admits: seen.admits.get(), asked: lender.asked.borrow().clone() }
|
||||
}
|
||||
|
||||
fn at_start() -> chrono::DateTime<chrono::Utc> {
|
||||
|
|
@ -363,6 +365,26 @@ fn the_child_is_started_for_her_run_with_the_fixed_list_whatever_the_run_record_
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_child_is_given_its_runs_label_as_huginn_session_fresh_resumed_and_in_the_next_run_only() {
|
||||
// Fresh: the bare label, the spelling the run's opening receipt carries, not the id and not the derived UUID.
|
||||
let a = with_run();
|
||||
let fresh = carry_with(&Seen::of(&a), vec![Out(result_line())], Some(0), false);
|
||||
assert_eq!(fresh.sessions, [Some("selfrun-a".to_string())]);
|
||||
// Resumed: the same child, the new run's label.
|
||||
let resumed = carry_kept(&Seen::of(&with_run()), vec![Out(result_line())], (Some(0), None, &[], false), Some(resume::kept_for(1800, true)));
|
||||
assert_eq!(resumed.sessions, [Some("selfrun-a".to_string())]);
|
||||
// The rotation's new run sets its own; nothing of the previous run is carried.
|
||||
let b = TestMind::seeded();
|
||||
b.committed(vec![self_run("selfrun-b", "5")]);
|
||||
let next = carry_kept(&Seen::of(&b), vec![Out(result_line())], (Some(0), None, &[], false), Some(resume::kept_for(1800, true)));
|
||||
assert_eq!(next.sessions, [Some("selfrun-b".to_string())]);
|
||||
for ran in [&fresh, &resumed, &next] {
|
||||
assert!(ran.sessions.iter().flatten().all(|label| !label.contains(':') && label.parse::<Uuid>().is_err()), "{:?}", ran.sessions);
|
||||
}
|
||||
assert!(!next.sessions.contains(&Some("selfrun-a".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_start_message_is_the_first_thing_written_and_names_the_skill_the_instance_and_the_run() {
|
||||
let ran = go(vec![Out(result_line())]);
|
||||
|
|
@ -962,7 +984,7 @@ mod process {
|
|||
fn start_with(program: &Path, args: &[String], token: &TokenFd) -> Box<dyn Child> {
|
||||
for _ in 0..50 {
|
||||
let config = FreshConfig::create(program.parent().unwrap(), None, None).unwrap();
|
||||
match spawn_program(program, args, token, &unit_environment(), config) {
|
||||
match spawn_program(program, args, token, (&unit_environment(), None), config) {
|
||||
Ok(child) => return child,
|
||||
Err(error) if format!("{error:#}").contains("busy") => std::thread::sleep(StdDuration::from_millis(100)),
|
||||
Err(error) => panic!("{error:#}"),
|
||||
|
|
@ -1034,7 +1056,7 @@ mod process {
|
|||
let canary = "canary-argument-6b0d";
|
||||
let scratch = tempfile::tempdir().unwrap();
|
||||
let config = FreshConfig::create(scratch.path(), None, None).unwrap();
|
||||
let error = spawn_program(Path::new("/nonexistent/claude"), &[canary.to_string()], &token_holding("tok-canary"), &[], config).err().unwrap();
|
||||
let error = spawn_program(Path::new("/nonexistent/claude"), &[canary.to_string()], &token_holding("tok-canary"), (&[], None), config).err().unwrap();
|
||||
assert!(!format!("{error:#}").contains(canary) && !format!("{error:#}").contains("nonexistent"), "{error:#}");
|
||||
}
|
||||
|
||||
|
|
@ -1061,7 +1083,7 @@ mod process {
|
|||
|
||||
struct Spawn(PathBuf);
|
||||
impl Spawner for Spawn {
|
||||
fn spawn(&self, args: &[String], token: &TokenFd) -> Result<Box<dyn Child>> {
|
||||
fn spawn(&self, args: &[String], token: &TokenFd, _session: Option<&str>) -> Result<Box<dyn Child>> {
|
||||
Ok(Box::new(Prompt(start_with(&self.0, args, token))))
|
||||
}
|
||||
}
|
||||
|
|
@ -1087,7 +1109,7 @@ mod process {
|
|||
|
||||
struct Patient(PathBuf);
|
||||
impl Spawner for Patient {
|
||||
fn spawn(&self, args: &[String], token: &TokenFd) -> Result<Box<dyn Child>> {
|
||||
fn spawn(&self, args: &[String], token: &TokenFd, _session: Option<&str>) -> Result<Box<dyn Child>> {
|
||||
Ok(Box::new(Bounded(start_with(&self.0, args, token), std::time::Instant::now())))
|
||||
}
|
||||
}
|
||||
|
|
@ -1942,7 +1964,7 @@ mod sandbox {
|
|||
unit.push(("PATH".into(), format!("{}:/usr/bin:/bin", bin.display()).into()));
|
||||
unit.push((SCRATCH_VARIABLE.into(), scratch.clone().into_os_string()));
|
||||
let spawner = ClaudeSpawner::in_sandbox(unit, "MIND_PERSONA_CONFIG_SEED", Some(sandbox.clone())).unwrap();
|
||||
let mut child = spawner.spawn(&["--flag".to_string(), "value".to_string()], &token_holding("tok-canary")).unwrap();
|
||||
let mut child = spawner.spawn(&["--flag".to_string(), "value".to_string()], &token_holding("tok-canary"), None).unwrap();
|
||||
while !matches!(child.next(StdDuration::from_secs(20)), Read::Exited(_) | Read::Timeout) {}
|
||||
let recorded: Vec<String> = std::fs::read_to_string(&argv).unwrap().lines().map(String::from).collect();
|
||||
let mut expected = sandbox_args(&sandbox, &scratch);
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ echo left > "$CLAUDE_CONFIG_DIR/leftover"
|
|||
|
||||
fn run_once(program: &Path, scratch: &Path, seed: Option<&Path>) -> Vec<String> {
|
||||
let config = FreshConfig::create(scratch, seed, None).unwrap();
|
||||
let mut child = spawn_program(program, &[], &token_holding("tok-canary-7f3a"), &unit_environment(), config).unwrap();
|
||||
let mut child = spawn_program(program, &[], &token_holding("tok-canary-7f3a"), (&unit_environment(), None), config).unwrap();
|
||||
child.close_input();
|
||||
let (lines, code) = drain(&mut *child);
|
||||
assert_eq!(code, Some(0), "{lines:?}");
|
||||
|
|
@ -167,7 +167,7 @@ fn the_child_scrubs_its_subprocesses_unless_the_unit_says_off() {
|
|||
let mut unit = unit_environment();
|
||||
unit.extend(value.map(|value| (SCRUB_VARIABLE.into(), value.into())));
|
||||
let config = FreshConfig::create(dir.path(), None, None).unwrap();
|
||||
let mut child = spawn_program(&program, &[], &token_holding("tok-canary-7f3a"), &unit, config).unwrap();
|
||||
let mut child = spawn_program(&program, &[], &token_holding("tok-canary-7f3a"), (&unit, None), config).unwrap();
|
||||
child.close_input();
|
||||
let (lines, _) = drain(&mut *child);
|
||||
field(&lines, "scrub")
|
||||
|
|
@ -177,3 +177,22 @@ fn the_child_scrubs_its_subprocesses_unless_the_unit_says_off() {
|
|||
assert_eq!(scrub_with(Some("OFF")), "1", "only the exact word turns it off");
|
||||
assert_eq!(scrub_with(Some("off")), "0", "a unit for a user that holds root turns it off");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_child_holds_huginn_session_only_as_the_spawn_set_it_never_as_the_unit_had_it() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let program = script(&dir, "echo \"session=${HUGINN_SESSION-unset}\"");
|
||||
let session_of = |unit_value: Option<&str>, given: Option<&str>| {
|
||||
let mut unit = unit_environment();
|
||||
unit.extend(unit_value.map(|value| ("HUGINN_SESSION".into(), value.into())));
|
||||
let config = FreshConfig::create(dir.path(), None, None).unwrap();
|
||||
let mut child = spawn_program(&program, &[], &token_holding("tok-canary-7f3a"), (&unit, given), config).unwrap();
|
||||
child.close_input();
|
||||
let (lines, _) = drain(&mut *child);
|
||||
field(&lines, "session")
|
||||
};
|
||||
assert_eq!(session_of(None, Some("mind-20261111T072944197Z")), "mind-20261111T072944197Z");
|
||||
assert_eq!(session_of(None, None), "unset", "a child with no run label is given none");
|
||||
assert_eq!(session_of(Some("stale-unit-value"), None), "unset", "the unit's own value is not on the allow-list");
|
||||
assert_eq!(session_of(Some("stale-unit-value"), Some("mind-new")), "mind-new", "the run's label is the only value");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ fn previous() -> Uuid {
|
|||
Uuid::parse_str(PREVIOUS).unwrap()
|
||||
}
|
||||
|
||||
fn kept_for(warm_for_s: i64, same_seed: bool) -> Kept {
|
||||
pub(super) fn kept_for(warm_for_s: i64, same_seed: bool) -> Kept {
|
||||
Kept { session: previous(), transcript: PathBuf::from(format!("/state/session/{PREVIOUS}.jsonl")), warm_until: at_start() + ChronoDuration::seconds(warm_for_s), written_at: at_start(), same_seed }
|
||||
}
|
||||
|
||||
|
|
@ -321,8 +321,8 @@ impl Spawner for FilesThenDies {
|
|||
self.files.keep(keep)
|
||||
}
|
||||
|
||||
fn spawn(&self, args: &[String], token: &TokenFd) -> Result<Box<dyn Child>> {
|
||||
self.dies.spawn(args, token)
|
||||
fn spawn(&self, args: &[String], token: &TokenFd, session: Option<&str>) -> Result<Box<dyn Child>> {
|
||||
self.dies.spawn(args, token, session)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -660,6 +660,8 @@ impl Child for Scripted {
|
|||
pub(crate) struct Fake {
|
||||
pub(crate) child: RefCell<Option<Scripted>>,
|
||||
pub(crate) spawns: RefCell<Vec<Vec<String>>>,
|
||||
/// The `HUGINN_SESSION` each spawn was given, in order with `spawns`.
|
||||
pub(crate) sessions: RefCell<Vec<Option<String>>>,
|
||||
pub(crate) refuse: bool,
|
||||
/// The conversation the spawner reports as kept; a test sets it.
|
||||
pub(crate) kept: RefCell<Option<Kept>>,
|
||||
|
|
@ -670,7 +672,7 @@ pub(crate) struct Fake {
|
|||
impl Fake {
|
||||
pub(crate) fn with(child: Option<Scripted>, refuse: bool) -> Self {
|
||||
let log = child.as_ref().map(|child| child.log.clone());
|
||||
Self { child: RefCell::new(child), spawns: RefCell::new(Vec::new()), refuse, kept: RefCell::new(None), log }
|
||||
Self { child: RefCell::new(child), spawns: RefCell::new(Vec::new()), sessions: RefCell::new(Vec::new()), refuse, kept: RefCell::new(None), log }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -686,8 +688,9 @@ impl Spawner for Fake {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn spawn(&self, args: &[String], _token: &TokenFd) -> Result<Box<dyn Child>> {
|
||||
fn spawn(&self, args: &[String], _token: &TokenFd, session: Option<&str>) -> Result<Box<dyn Child>> {
|
||||
self.spawns.borrow_mut().push(args.to_vec());
|
||||
self.sessions.borrow_mut().push(session.map(str::to_string));
|
||||
if self.refuse {
|
||||
return Err(anyhow!("refused"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -308,7 +308,7 @@ mind_body::self_run: impl std :: error :: Error for SeedLinkRefused
|
|||
mind_body::self_run: impl< 'a > SelfInbox < 'a >
|
||||
mind_body::self_run: mod inbox
|
||||
mind_body::self_run: pub trait Child { fn write_line (& mut self , line : & str) ; fn close_input (& mut self) ; fn next (& mut self , timeout : StdDuration) -> Read ; fn stop (& mut self , how : Stop) -> bool ; }
|
||||
mind_body::self_run: pub trait Spawner { fn spawn (& self , args : & [String] , token : & TokenFd) -> Result < Box < dyn Child > > ; fn kept (& self) -> Option < Kept > { None } fn keep (& self , _keep : Option < (Uuid , DateTime < Utc >) >) -> Result < () > { Ok (()) } }
|
||||
mind_body::self_run: pub trait Spawner { fn spawn (& self , args : & [String] , token : & TokenFd , session : Option < & str >) -> Result < Box < dyn Child > > ; fn kept (& self) -> Option < Kept > { None } fn keep (& self , _keep : Option < (Uuid , DateTime < Utc >) >) -> Result < () > { Ok (()) } }
|
||||
mind_body::self_run: struct ClaudeSpawner pub-fields[]
|
||||
mind_body::self_run: struct Kept # [derive (Clone , Debug , PartialEq , Eq)] pub-fields[session: Uuid, transcript: PathBuf, warm_until: DateTime < Utc >, written_at: DateTime < Utc >, same_seed: bool]
|
||||
mind_body::self_run::decide: impl State
|
||||
|
|
|
|||
Loading…
Reference in a new issue