From 1be4905cdd0534d891c1657cc13d8bc58f760bd2 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Mon, 10 Aug 2026 18:06:13 +0200 Subject: [PATCH 01/56] Add declared native delivery selector --- crates/agent-spec/src/kdl_format.rs | 17 ++++++ crates/agent-spec/src/lib.rs | 4 +- crates/agent-spec/src/spec.rs | 58 ++++++++++++++++++- crates/agent-spec/tests/discovery.rs | 87 +++++++++++++++++++++++++++- src/catalog_transaction.rs | 6 ++ src/eval_run.rs | 1 + src/lib.rs | 4 +- src/main.rs | 10 ++++ src/run.rs | 3 + tests/catalog_diff.rs | 25 ++++++++ tests/doctor.rs | 54 +++++++++++++++++ tests/reconcile.rs | 1 + tests/run.rs | 1 + 13 files changed, 262 insertions(+), 9 deletions(-) diff --git a/crates/agent-spec/src/kdl_format.rs b/crates/agent-spec/src/kdl_format.rs index 6540668e..d4541af5 100644 --- a/crates/agent-spec/src/kdl_format.rs +++ b/crates/agent-spec/src/kdl_format.rs @@ -124,6 +124,23 @@ fn agent_node_to_raw(node: &DeclaredNode) -> anyhow::Result { "command" => raw.command = arg_string(child), "argv" => raw.argv = Some(argv(child)?), "ding" => raw.ding = true, + "deliver" => { + anyhow::ensure!( + raw.deliver.is_none(), + "agent declares `deliver` more than once" + ); + anyhow::ensure!( + child.type_name.is_none() + && child.children.is_empty() + && child.entries.len() == 1 + && child.entries[0].name.is_none(), + "agent `deliver` must contain exactly one positional string" + ); + raw.deliver = Some(Some( + arg_string(child) + .ok_or_else(|| anyhow::anyhow!("agent `deliver` value must be a string"))?, + )); + } "env" => {} "pty" => { if let Some(name) = arg_string(child) { diff --git a/crates/agent-spec/src/lib.rs b/crates/agent-spec/src/lib.rs index e9824715..cfe26b4e 100644 --- a/crates/agent-spec/src/lib.rs +++ b/crates/agent-spec/src/lib.rs @@ -43,6 +43,6 @@ pub use discovery::{ path_defaults, }; pub use spec::{ - AgentDesiredState, AgentSpec, JobType, Resource, Restart, RestartMode, Task, TaskKind, - TaskLifecycle, parse_duration, validate_desired_state_reason, + AgentDesiredState, AgentSpec, DeliveryTransport, JobType, Resource, Restart, RestartMode, Task, + TaskKind, TaskLifecycle, parse_duration, validate_desired_state_reason, }; diff --git a/crates/agent-spec/src/spec.rs b/crates/agent-spec/src/spec.rs index a0cf9a29..d4b0afc8 100644 --- a/crates/agent-spec/src/spec.rs +++ b/crates/agent-spec/src/spec.rs @@ -5,9 +5,10 @@ //! stage's script; must NOT allocate a terminal, R09). st2 reads only the runner-normative subset: //! `identity`, presentation (`name`, `description`), `host`, `role` (metadata only), `type`, //! `workspace`, whole-agent desired state (plus legacy `retired`), `keep`, `supervisor`, -//! `restart{}`, task lifecycle, Resource bindings (declaration metadata), and the tasks. Everything render-only -//! (`harness`, `model`, `persona`, `permissions`, `transport`, `strategy`, `meta{}`) is baked into -//! the tasks/commands by the render layer and ignored here. +//! `restart{}`, `deliver`, task lifecycle, Resource bindings (declaration metadata), and the tasks. +//! Everything render-only (`harness`, `model`, `persona`, `permissions`, legacy `transport` +//! metadata, `strategy`, `meta{}`) is baked into the tasks/commands by the render layer and ignored +//! here. //! //! Three on-disk formats lower to this model: KDL (canonical, parsed by hand in `kdl_format`), and //! TOML/JSON (serde). Every spec is a `service` — `type = batch` is retired; evals run through the @@ -36,6 +37,32 @@ pub enum AgentDesiredState { Retired { reason: Option }, } +/// One provider-native message delivery transport declared by an agent. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeliveryTransport { + Mcp, + AppServer, +} + +impl DeliveryTransport { + pub fn as_str(self) -> &'static str { + match self { + Self::Mcp => "mcp", + Self::AppServer => "app-server", + } + } + + fn parse(value: &str) -> anyhow::Result { + match value { + "mcp" => Ok(Self::Mcp), + "app-server" => Ok(Self::AppServer), + _ => anyhow::bail!( + "unsupported `deliver` value '{value}' (expected `mcp` or `app-server`)" + ), + } + } +} + impl AgentDesiredState { pub fn as_str(&self) -> &'static str { match self { @@ -91,6 +118,8 @@ pub struct AgentSpec { pub keep: bool, /// Crash/restart policy (§4). `None` → the runner's default policy. pub restart: Option, + /// Provider-native delivery selected by `deliver`; `None` means legacy `ding` or no delivery. + pub delivery: Option, /// Named typed references used by the agent. st2 preserves these for readers but does not /// resolve them or assign launch, readiness, access, or lifecycle semantics. pub resources: Vec, @@ -327,6 +356,15 @@ impl AgentSpec { .any(|task| !task.derived && (task.command.is_some() || task.argv.is_some())) } + /// True when the declaration selected legacy screen delivery or one native transport. + pub fn has_delivery_transport(&self) -> bool { + self.delivery.is_some() + || self + .tasks + .iter() + .any(|task| task.derived && task.kind == TaskKind::Exec && task.name == "ding") + } + /// The restart policy in effect (declared, else the runner default). pub fn restart_policy(&self) -> Restart { self.restart.clone().unwrap_or_default() @@ -399,6 +437,9 @@ pub(crate) struct RawSpec { /// Compact catalog form: include the built-in `st2 ding` sidecar. #[serde(default)] pub ding: bool, + /// Compact catalog form: select one provider-native delivery transport. + #[serde(default, deserialize_with = "deserialize_explicit_optional")] + pub deliver: Option>, /// Compact catalog form: reconciliation policy for the generated agent PTY. pub lifecycle: Option, /// `pty "" {}` / `[pty.]` — interactive tasks. @@ -750,6 +791,7 @@ impl RawSpec { || self.command.is_some() || self.argv.is_some() || self.ding + || self.deliver.is_some() || !self.resource.0.is_empty() || !self.pty.is_empty() || !self.exec.is_empty() @@ -778,6 +820,15 @@ impl RawSpec { desired_state_value.as_deref(), desired_state_reason, )?; + let deliver = reject_explicit_null("deliver", self.deliver)?; + let delivery = deliver + .as_deref() + .map(DeliveryTransport::parse) + .transpose()?; + anyhow::ensure!( + !(self.ding && delivery.is_some()), + "agent '{identity}' declares both `ding` and `deliver`; choose one transport" + ); validate_launch( &identity, self.command.as_ref(), @@ -850,6 +901,7 @@ impl RawSpec { desired_state, keep: self.keep, restart: self.restart.map(RawRestart::lower), + delivery, resources, tasks, path, diff --git a/crates/agent-spec/tests/discovery.rs b/crates/agent-spec/tests/discovery.rs index af08b9a3..baa5e654 100644 --- a/crates/agent-spec/tests/discovery.rs +++ b/crates/agent-spec/tests/discovery.rs @@ -8,7 +8,7 @@ use std::fs; use std::path::Path; use std::time::Duration; -use agent_spec::spec::{TaskKind, TaskLifecycle}; +use agent_spec::spec::{DeliveryTransport, TaskKind, TaskLifecycle}; use agent_spec::{ AgentDesiredState, AgentSpec, JobType, Resource, Task, discover, discover_strict, }; @@ -139,10 +139,11 @@ fn lifecycle_fields_make_path_placed_files_agent_candidates() { } #[test] -fn explicit_json_null_lifecycle_fields_are_rejected_instead_of_granting_running_intent() { +fn explicit_json_null_fields_are_rejected_instead_of_granting_default_behavior() { for (name, lifecycle) in [ ("null-retired", r#""retired":null"#), ("null-state", r#""desired_state":null"#), + ("null-deliver", r#""deliver":null"#), ( "null-reason", r#""desired_state":"suspended","desired_state_reason":null"#, @@ -328,6 +329,88 @@ agent "cos" { ding.env.get("ST_AGENT").map(String::as_str), Some("Silber.cos") ); + assert!(spec.delivery.is_none()); + assert!(spec.has_delivery_transport()); +} + +#[test] +fn deliver_is_typed_without_lowering_to_the_legacy_ding_task() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "agents/h/claude/agent.kdl", + r#"agent "claude" { host "h"; command "claude"; deliver "mcp" }"#, + ); + write( + tmp.path(), + "agents/h/codex/agent.kdl", + r#"agent "codex" { host "h"; command "codex"; deliver "app-server" }"#, + ); + + let found = discover(tmp.path()); + assert!(found.errors.is_empty(), "{:?}", found.errors); + let claude = find(&found.specs, "claude"); + let codex = find(&found.specs, "codex"); + assert_eq!(claude.delivery, Some(DeliveryTransport::Mcp)); + assert_eq!(codex.delivery, Some(DeliveryTransport::AppServer)); + assert_eq!(claude.delivery.unwrap().as_str(), "mcp"); + assert_eq!(codex.delivery.unwrap().as_str(), "app-server"); + for spec in [claude, codex] { + assert!(spec.has_delivery_transport()); + assert_eq!(spec.tasks.len(), 1); + assert!(spec.tasks.iter().all(|task| !task.derived)); + } +} + +#[test] +fn deliver_rejects_unknown_duplicate_mixed_and_malformed_declarations() { + for (name, declaration, expected) in [ + ( + "unknown", + r#"agent "worker" { command "true"; deliver "socket" }"#, + "unsupported `deliver` value 'socket'", + ), + ( + "duplicate", + r#"agent "worker" { command "true"; deliver "mcp"; deliver "app-server" }"#, + "declares `deliver` more than once", + ), + ( + "mixed", + r#"agent "worker" { command "true"; ding; deliver "mcp" }"#, + "declares both `ding` and `deliver`", + ), + ( + "missing", + r#"agent "worker" { command "true"; deliver }"#, + "must contain exactly one positional string", + ), + ( + "non-string", + r#"agent "worker" { command "true"; deliver #true }"#, + "value must be a string", + ), + ( + "property", + r#"agent "worker" { command "true"; deliver "mcp" mode="extra" }"#, + "must contain exactly one positional string", + ), + ] { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + &format!("agents/h/{name}/agent.kdl"), + declaration, + ); + let found = discover(tmp.path()); + assert!(found.specs.is_empty(), "{name}: {:?}", found.specs); + assert_eq!(found.errors.len(), 1, "{name}: {:?}", found.errors); + assert!( + found.errors[0].message.contains(expected), + "{name}: expected {expected:?}, got {:?}", + found.errors[0] + ); + } } #[test] diff --git a/src/catalog_transaction.rs b/src/catalog_transaction.rs index cadc52b6..ff153459 100644 --- a/src/catalog_transaction.rs +++ b/src/catalog_transaction.rs @@ -629,6 +629,12 @@ fn normalize_agent(spec: &agent_spec::AgentSpec) -> Result Vec desired_state: AgentDesiredState::Running, keep: false, restart: None, + delivery: None, resources: Vec::new(), tasks, path: path.clone(), diff --git a/src/lib.rs b/src/lib.rs index 4eab71f6..696dcb1a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -42,8 +42,8 @@ pub use agent_spec::{discovery, spec}; pub use agent_spec::discovery::{Discovered, SpecError, discover, discover_strict}; pub use agent_spec::spec::{ - AgentDesiredState, AgentSpec, JobType, Resource, Restart, RestartMode, Task, TaskKind, - TaskLifecycle, parse_duration, + AgentDesiredState, AgentSpec, DeliveryTransport, JobType, Resource, Restart, RestartMode, Task, + TaskKind, TaskLifecycle, parse_duration, }; pub use catalog_lock::CatalogLock; pub use exec_backend::ExecBackend; diff --git a/src/main.rs b/src/main.rs index de118588..969233ad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1476,6 +1476,12 @@ fn doctor_cmd(root: &Path, host: Option, require_supervisor: bool) -> Re ); continue; } + if !spec.has_delivery_transport() { + report_advisory( + &format!("{bus_id} delivery transport missing"), + "declare `ding` or `deliver`; agent receives no DING", + ); + } for task in &spec.tasks { let id = task .id @@ -1620,6 +1626,10 @@ fn report_check(problems: &mut usize, ok: bool, label: &str, detail: &str) { } } +fn report_advisory(label: &str, detail: &str) { + println!(" ⚠ {label} — {detail}"); +} + fn presentation_cmd( field: st2::agent_author::PresentationField, args: PresentationArgs, diff --git a/src/run.rs b/src/run.rs index 7e1324b8..53c4564a 100644 --- a/src/run.rs +++ b/src/run.rs @@ -2280,6 +2280,7 @@ mod tests { desired_state: crate::AgentDesiredState::Running, keep: false, restart: None, + delivery: None, resources: vec![], tasks: vec![Task { kind: TaskKind::Pty, @@ -2329,6 +2330,7 @@ mod tests { desired_state: crate::AgentDesiredState::Running, keep: false, restart: None, + delivery: None, resources: vec![], tasks: vec![Task { kind: TaskKind::Pty, @@ -2585,6 +2587,7 @@ mod tests { desired_state: crate::AgentDesiredState::Running, keep: false, restart: None, + delivery: None, resources: vec![], tasks: vec![], path: std::path::PathBuf::from("/x"), diff --git a/tests/catalog_diff.rs b/tests/catalog_diff.rs index d9de8858..5a3b4da8 100644 --- a/tests/catalog_diff.rs +++ b/tests/catalog_diff.rs @@ -158,6 +158,31 @@ fn desired_state_and_reason_have_distinct_secret_safe_semantic_addresses() { assert!(!rendered.contains(reason)); } +#[test] +fn declared_delivery_has_one_exact_semantic_address() { + let (_temp, catalog, prepared, root) = fixture(); + fs::write( + prepared.join("agents/host/worker/agent.kdl"), + r#"agent "worker" { + host "host" + deliver "app-server" + argv "tool" "arg" +} +"#, + ) + .unwrap(); + + let receipt = parsed(&diff(&catalog, &prepared, &root)); + let fields = agent_fields(&receipt); + assert_eq!( + fields + .iter() + .filter(|field| field.as_str() == "/agents/host/worker/delivery") + .count(), + 1 + ); +} + #[test] fn effective_task_id_and_cwd_defaults_normalize_to_explicit_values() { let (_temp, catalog, prepared, _root) = fixture(); diff --git a/tests/doctor.rs b/tests/doctor.rs index 6ef50b28..edb46f83 100644 --- a/tests/doctor.rs +++ b/tests/doctor.rs @@ -361,3 +361,57 @@ fn suspended_declaration_distinguishes_live_dead_keep_and_dead_nonkeep() { assert!(!stdout.contains("h.idle presence"), "{stdout}"); } } + +#[test] +fn missing_delivery_is_advisory_while_an_invalid_delivery_is_a_catalog_problem() { + let tmp = tempfile::tempdir().unwrap(); + let catalog = tmp.path().join("catalog"); + let declaration = catalog.join("agents/h/worker/agent.kdl"); + let bin = tmp.path().join("bin"); + fs::create_dir_all(declaration.parent().unwrap()).unwrap(); + fs::create_dir_all(&bin).unwrap(); + fs::write( + &declaration, + r#"agent "worker" { host "h"; command "true" }"#, + ) + .unwrap(); + fs::write(declaration.parent().unwrap().join("status"), "available\n").unwrap(); + executable( + &bin.join("pty"), + "#!/bin/sh\nif [ \"$1\" = list ]; then printf '[{\"name\":\"h.worker\",\"status\":\"running\"}]\\n'; fi\n", + ); + + let missing = doctor(&catalog, &bin, &tmp.path().join("state")); + let stdout = String::from_utf8_lossy(&missing.stdout); + assert!( + missing.status.success(), + "stdout:\n{stdout}\nstderr:\n{}", + String::from_utf8_lossy(&missing.stderr) + ); + assert!( + stdout.contains( + "⚠ h.worker delivery transport missing — declare `ding` or `deliver`; agent receives no DING" + ), + "{stdout}" + ); + + fs::write( + &declaration, + r#"agent "worker" { host "h"; command "true"; deliver "mcp" }"#, + ) + .unwrap(); + let declared = doctor(&catalog, &bin, &tmp.path().join("state")); + let stdout = String::from_utf8_lossy(&declared.stdout); + assert!(declared.status.success(), "{stdout}"); + assert!(!stdout.contains("delivery transport missing"), "{stdout}"); + + fs::write( + &declaration, + r#"agent "worker" { host "h"; command "true"; deliver "mpc" }"#, + ) + .unwrap(); + let invalid = doctor(&catalog, &bin, &tmp.path().join("state")); + let stdout = String::from_utf8_lossy(&invalid.stdout); + assert!(!invalid.status.success(), "{stdout}"); + assert!(stdout.contains("unsupported `deliver` value 'mpc'"), "{stdout}"); +} diff --git a/tests/reconcile.rs b/tests/reconcile.rs index c0850f4c..a9f670ac 100644 --- a/tests/reconcile.rs +++ b/tests/reconcile.rs @@ -398,6 +398,7 @@ fn spec( }, keep: false, restart: None, + delivery: None, resources: Vec::new(), tasks, path: PathBuf::from(format!( diff --git a/tests/run.rs b/tests/run.rs index ce92b5fb..c5a21178 100644 --- a/tests/run.rs +++ b/tests/run.rs @@ -236,6 +236,7 @@ fn task_spec(identity: &str, host: Option<&str>, id: &str) -> AgentSpec { desired_state: AgentDesiredState::Running, keep: false, restart: None, + delivery: None, resources: vec![], tasks: vec![Task { kind: TaskKind::Exec, From 8f8ec5fcf6139866d403d615b455be6ce8229ca8 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Mon, 10 Aug 2026 19:09:58 +0200 Subject: [PATCH 02/56] Add controlled Codex app-server binding --- Cargo.lock | 166 +++++- Cargo.toml | 1 + src/codex_app_server.rs | 1067 +++++++++++++++++++++++++++++++++++++ src/eval_run.rs | 6 +- src/lib.rs | 1 + src/main.rs | 29 +- src/reconcile.rs | 97 +++- src/run.rs | 6 +- tests/codex_app_server.rs | 103 ++++ 9 files changed, 1465 insertions(+), 11 deletions(-) create mode 100644 src/codex_app_server.rs create mode 100644 tests/codex_app_server.rs diff --git a/Cargo.lock b/Cargo.lock index 3fef6d66..703fe975 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,12 +91,38 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core", +] + [[package]] name = "clap" version = "4.6.3" @@ -152,6 +178,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -161,6 +193,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -171,14 +212,40 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", ] [[package]] @@ -231,6 +298,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", + "rand_core", ] [[package]] @@ -245,6 +313,31 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -437,6 +530,23 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rustix" version = "1.1.4" @@ -511,6 +621,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + [[package]] name = "sha2" version = "0.10.9" @@ -518,8 +639,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -539,6 +660,7 @@ dependencies = [ "st2-wire", "tempfile", "toml", + "tungstenite", ] [[package]] @@ -590,6 +712,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + [[package]] name = "toml" version = "0.9.12+spec-1.1.0" @@ -629,6 +771,22 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +[[package]] +name = "tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", +] + [[package]] name = "typenum" version = "1.20.1" diff --git a/Cargo.toml b/Cargo.toml index ac67cde3..f32814f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ sha2 = "0.10" st2-wire = { path = "crates/st2-wire" } tempfile = "3" toml = "0.9" +tungstenite = "0.30" [dev-dependencies] libc = "0.2" diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs new file mode 100644 index 00000000..8c94a923 --- /dev/null +++ b/src/codex_app_server.rs @@ -0,0 +1,1067 @@ +//! Controlled Codex app-server launch and persistent thread ownership. +//! +//! Native delivery cannot infer a thread from cwd, process, PTY, or `thread/list`. This module +//! starts a dedicated provider daemon, initializes an observer connection before the interactive +//! client starts, and binds a typed start or successful-resume event to the exact wrapper process +//! incarnation that owns the PTY launch. Message watching and delivery are deliberately later +//! layers; this module establishes only the topology and identity boundary they consume. + +use std::fs::{self, File, OpenOptions}; +use std::io::{Read as _, Write}; +use std::net::Shutdown; +use std::os::unix::ffi::OsStrExt as _; +use std::os::unix::fs::{FileTypeExt as _, OpenOptionsExt as _, PermissionsExt as _}; +use std::os::unix::io::AsRawFd as _; +use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::thread; +use std::time::{Duration, Instant}; + +use anyhow::{Context as _, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; +use tungstenite::{Message, WebSocket}; + +pub const SUPPORTED_CODEX_CLI_VERSION: &str = "codex-cli 0.145.0"; +const RUNTIME_SCHEMA: &str = "st2.codex-runtime.v1"; +const BINDING_SCHEMA: &str = "st2.codex-thread-binding.v1"; +const STARTUP_TIMEOUT: Duration = Duration::from_secs(30); +const CONTROL_POLL: Duration = Duration::from_millis(100); +const SOCKET_PATH_BUDGET: usize = 96; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CodexRuntime { + schema: String, + agent: String, + runtime_id: String, + incarnation: String, +} + +impl CodexRuntime { + fn fresh(agent: String, runtime_id: String) -> Result { + Ok(Self { + schema: RUNTIME_SCHEMA.to_string(), + agent, + runtime_id, + incarnation: random_token()?, + }) + } + + pub fn agent(&self) -> &str { + &self.agent + } + + pub fn runtime_id(&self) -> &str { + &self.runtime_id + } + + pub fn incarnation(&self) -> &str { + &self.incarnation + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CodexThreadBinding { + schema: String, + agent: String, + runtime_id: String, + runtime_incarnation: String, + thread_id: String, +} + +impl CodexThreadBinding { + fn new(runtime: &CodexRuntime, thread_id: String) -> Self { + Self { + schema: BINDING_SCHEMA.to_string(), + agent: runtime.agent.clone(), + runtime_id: runtime.runtime_id.clone(), + runtime_incarnation: runtime.incarnation.clone(), + thread_id, + } + } + + pub fn thread_id(&self) -> &str { + &self.thread_id + } + + pub fn runtime_incarnation(&self) -> &str { + &self.runtime_incarnation + } +} + +/// Run one authored Codex argv behind a dedicated app server and initialized control connection. +pub fn run_controlled( + catalog_root: &Path, + identity: String, + runtime_id: String, + codex_argv: Vec, +) -> Result<()> { + anyhow::ensure!( + !codex_argv.is_empty(), + "Codex controlled launch argv is empty" + ); + ensure_supported_version(&codex_argv[0])?; + + let state_dir = state_dir(catalog_root, &identity); + secure_dir(&state_dir)?; + let _owner_lock = acquire_owner_lock(&state_dir)?; + let binding_path = state_dir.join("binding.json"); + let resume_thread = load_resume_thread(&binding_path, &identity, &runtime_id)?; + + let socket_path = socket_path(catalog_root, &identity)?; + let socket_dir = socket_path + .parent() + .context("Codex app-server socket has no parent")?; + secure_dir(socket_dir)?; + match fs::symlink_metadata(&socket_path) { + Ok(metadata) => { + anyhow::ensure!( + metadata.file_type().is_socket(), + "Codex app-server path already exists and is not a socket: {}", + socket_path.display() + ); + match UnixStream::connect(&socket_path) { + Ok(_) => anyhow::bail!( + "Codex app-server socket {} is already live; refusing a second control owner", + socket_path.display() + ), + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound + ) => + { + fs::remove_file(&socket_path).with_context(|| { + format!("removing stale Codex socket {}", socket_path.display()) + })?; + } + Err(error) => { + return Err(error).with_context(|| { + format!( + "checking existing Codex socket {} before launch", + socket_path.display() + ) + }); + } + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error) + .with_context(|| format!("checking Codex socket path {}", socket_path.display())); + } + } + + // Publish a new incarnation only after this process holds the owner lock and has proved that no + // older daemon is live. A rejected second owner must not invalidate the first owner's binding. + let runtime = CodexRuntime::fresh(identity, runtime_id)?; + atomic_json(&state_dir.join("runtime.json"), &runtime)?; + + let log = OpenOptions::new() + .create(true) + .append(true) + .mode(0o600) + .open(state_dir.join("app-server.log"))?; + let endpoint = format!("unix://{}", socket_path.display()); + let mut server = Command::new(&codex_argv[0]) + .args(["app-server", "--listen", &endpoint]) + .stdin(Stdio::null()) + .stdout(log.try_clone()?) + .stderr(log) + .spawn() + .with_context(|| format!("starting {} app-server", codex_argv[0]))?; + + let result = run_connected( + &mut server, + &socket_path, + &endpoint, + &state_dir, + &runtime, + &codex_argv, + resume_thread.as_deref(), + ); + terminate_child(&mut server); + let _ = fs::remove_file(&socket_path); + result +} + +fn run_connected( + server: &mut Child, + socket_path: &Path, + endpoint: &str, + state_dir: &Path, + runtime: &CodexRuntime, + codex_argv: &[String], + resume_thread: Option<&str>, +) -> Result<()> { + let control = connect_control(server, socket_path, STARTUP_TIMEOUT)?; + let shutdown = control.try_clone()?; + let websocket = initialize_control(control)?; + let (events_tx, events_rx) = mpsc::channel(); + let binding_path = state_dir.join("binding.json"); + let runtime_for_reader = runtime.clone(); + let expected_resume = resume_thread.map(str::to_owned); + let event_thread = thread::spawn(move || { + pump_control( + websocket, + &binding_path, + &runtime_for_reader, + expected_resume.as_deref(), + events_tx, + ) + }); + + // The initialized observer is already reading before this child can issue thread/start or + // thread/resume. Insert the remote endpoint as a global Codex option and preserve every authored + // argument after the provider executable. + let mut tui_command = Command::new(&codex_argv[0]); + tui_command.args(controlled_tui_args( + endpoint, + &codex_argv[1..], + resume_thread, + )?); + let mut tui = tui_command + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .with_context(|| format!("starting controlled {} TUI", codex_argv[0]))?; + + let result = wait_for_binding(&mut tui, &events_rx, STARTUP_TIMEOUT) + .and_then(|_| monitor_bound_tui(&mut tui, &events_rx)); + if result.is_err() { + terminate_child(&mut tui); + } + let _ = shutdown.shutdown(Shutdown::Both); + let _ = event_thread.join(); + result +} + +fn controlled_tui_args( + endpoint: &str, + authored_args: &[String], + resume_thread: Option<&str>, +) -> Result> { + let mut args = vec!["--remote".to_string(), endpoint.to_string()]; + let Some(thread_id) = resume_thread else { + args.extend_from_slice(authored_args); + return Ok(args); + }; + let Some(insertion) = resume_insertion_index(authored_args)? else { + args.extend_from_slice(authored_args); + return Ok(args); + }; + args.push("resume".to_string()); + // Codex models these flags on the `resume` command as well as the root command. Keep them + // before SESSION_ID so clap does not treat a following flag as the optional prompt. + args.extend_from_slice(&authored_args[..insertion]); + args.push(thread_id.to_string()); + args.extend_from_slice(&authored_args[insertion..]); + Ok(args) +} + +/// Find where a pinned Codex 0.145.0 interactive argv begins its prompt or subcommand. +/// +/// Automatic resume must insert `resume ` after global options and before the authored +/// prompt. Unknown options fail closed because guessing can turn an option value into a prompt or a +/// prompt into a session selector. `--image` is variadic, so automatic resume requires an explicit +/// `--` boundary when that option is present. +fn resume_insertion_index(authored_args: &[String]) -> Result> { + let delimiter = authored_args.iter().position(|arg| arg == "--"); + let mut index = 0; + while index < authored_args.len() { + let argument = authored_args[index].as_str(); + if argument == "--" { + return Ok(Some(index)); + } + if !argument.starts_with('-') || argument == "-" { + return if matches!(argument, "resume" | "fork") { + Ok(None) + } else { + Ok(Some(index)) + }; + } + + if matches!( + argument, + "--strict-config" + | "--oss" + | "--dangerously-bypass-approvals-and-sandbox" + | "--dangerously-bypass-hook-trust" + | "--search" + | "--no-alt-screen" + ) { + index += 1; + continue; + } + anyhow::ensure!( + !matches!(argument, "-h" | "--help" | "-V" | "--version"), + "cannot automatically resume a Codex help or version invocation" + ); + + let exact_value_option = matches!( + argument, + "-c" | "--config" + | "--enable" + | "--disable" + | "--remote-auth-token-env" + | "-m" + | "--model" + | "--local-provider" + | "-p" + | "--profile" + | "-s" + | "--sandbox" + | "-C" + | "--cd" + | "--add-dir" + | "-a" + | "--ask-for-approval" + ); + if exact_value_option { + anyhow::ensure!( + index + 1 < authored_args.len(), + "Codex option '{argument}' has no value" + ); + index += 2; + continue; + } + if matches!(argument, "-i" | "--image") + || argument.starts_with("-i=") + || argument.starts_with("--image=") + { + let boundary = delimiter.context( + "automatic Codex resume with variadic --image requires an explicit `--` prompt boundary", + )?; + return Ok(Some(boundary)); + } + + let long_value = [ + "--config=", + "--enable=", + "--disable=", + "--remote-auth-token-env=", + "--model=", + "--local-provider=", + "--profile=", + "--sandbox=", + "--cd=", + "--add-dir=", + "--ask-for-approval=", + ] + .iter() + .any(|prefix| argument.starts_with(prefix)); + let short_value = ["-c", "-m", "-p", "-s", "-C", "-a"] + .iter() + .any(|prefix| argument.starts_with(prefix) && argument.len() > prefix.len()); + anyhow::ensure!( + long_value || short_value, + "cannot automatically resume through unknown Codex option '{argument}'" + ); + index += 1; + } + Ok(Some(authored_args.len())) +} + +fn connect_control( + server: &mut Child, + socket_path: &Path, + timeout: Duration, +) -> Result { + let deadline = Instant::now() + timeout; + loop { + match UnixStream::connect(socket_path) { + Ok(stream) => return Ok(stream), + Err(error) if Instant::now() < deadline => { + if let Some(status) = server.try_wait()? { + anyhow::bail!("Codex app-server exited before control connected: {status}"); + } + if error.kind() != std::io::ErrorKind::NotFound + && error.kind() != std::io::ErrorKind::ConnectionRefused + { + return Err(error).with_context(|| { + format!("connecting Codex control socket {}", socket_path.display()) + }); + } + thread::sleep(Duration::from_millis(50)); + } + Err(error) => { + return Err(error).with_context(|| { + format!( + "Codex control socket {} was not ready within {}s", + socket_path.display(), + timeout.as_secs() + ) + }); + } + } + } +} + +fn initialize_control(stream: UnixStream) -> Result> { + stream.set_read_timeout(Some(STARTUP_TIMEOUT))?; + let (mut websocket, response) = tungstenite::client("ws://localhost/", stream) + .map_err(|error| anyhow::anyhow!("Codex WebSocket handshake failed: {error}"))?; + anyhow::ensure!( + response.status().as_u16() == 101, + "Codex WebSocket handshake returned {}", + response.status() + ); + write_json_message( + &mut websocket, + &json!({ + "method": "initialize", + "id": 0, + "params": { + "clientInfo": { + "name": "st2", + "title": "st2", + "version": env!("CARGO_PKG_VERSION") + }, + "capabilities": { "experimentalApi": true } + } + }), + )?; + + loop { + let message = read_json_message(&mut websocket)? + .context("Codex app-server closed the control connection during initialize")?; + if message.get("id") != Some(&Value::from(0)) { + continue; + } + if let Some(error) = message.get("error") { + anyhow::bail!("Codex app-server rejected initialize: {error}"); + } + anyhow::ensure!( + message.get("result").is_some(), + "Codex app-server initialize response has no result" + ); + break; + } + write_json_message( + &mut websocket, + &json!({ "method": "initialized", "params": {} }), + )?; + websocket.get_ref().set_read_timeout(None)?; + Ok(websocket) +} + +#[derive(Debug)] +enum ControlEvent { + Bound, + Closed, + Failed(String), +} + +fn pump_control( + mut websocket: WebSocket, + binding_path: &Path, + runtime: &CodexRuntime, + expected_resume: Option<&str>, + events: Sender, +) { + let result = (|| -> Result<()> { + let mut bound_thread: Option = None; + loop { + let Some(message) = read_json_message(&mut websocket)? else { + let _ = events.send(ControlEvent::Closed); + return Ok(()); + }; + let thread_id = match message.get("method").and_then(Value::as_str) { + Some("thread/started") => message + .pointer("/params/thread/id") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + .context("thread/started has no non-empty params.thread.id")?, + Some("thread/status/changed") if expected_resume.is_some() => { + let thread_id = message + .pointer("/params/threadId") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + .context("thread/status/changed has no non-empty params.threadId")?; + let status = message + .pointer("/params/status/type") + .and_then(Value::as_str) + .context("thread/status/changed has no params.status.type")?; + if Some(thread_id) != expected_resume || !matches!(status, "idle" | "active") { + continue; + } + thread_id + } + _ => continue, + }; + match bound_thread.as_deref() { + None => { + atomic_json( + binding_path, + &CodexThreadBinding::new(runtime, thread_id.to_string()), + )?; + bound_thread = Some(thread_id.to_string()); + let _ = events.send(ControlEvent::Bound); + } + Some(bound) if bound == thread_id => {} + // A dedicated daemon can emit secondary thread starts for review/fork flows. The + // first TUI-owned thread remains the binding; never silently rebind it. + Some(_) => {} + } + } + })(); + if let Err(error) = result { + let _ = events.send(ControlEvent::Failed(format!("{error:#}"))); + } +} + +fn wait_for_binding( + tui: &mut Child, + events: &Receiver, + timeout: Duration, +) -> Result<()> { + let deadline = Instant::now() + timeout; + loop { + if let Some(status) = tui.try_wait()? { + anyhow::bail!("controlled Codex TUI exited before thread binding: {status}"); + } + let wait = deadline + .saturating_duration_since(Instant::now()) + .min(CONTROL_POLL); + if wait.is_zero() { + anyhow::bail!( + "controlled Codex TUI did not establish typed thread ownership within {}s", + timeout.as_secs() + ); + } + match events.recv_timeout(wait) { + Ok(ControlEvent::Bound) => return Ok(()), + Ok(ControlEvent::Closed) => { + anyhow::bail!("Codex control connection closed before thread binding") + } + Ok(ControlEvent::Failed(error)) => { + anyhow::bail!("Codex control failed before thread binding: {error}") + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => { + anyhow::bail!("Codex control observer ended before thread binding") + } + } + } +} + +fn monitor_bound_tui(tui: &mut Child, events: &Receiver) -> Result<()> { + loop { + if let Some(status) = tui.try_wait()? { + return completed_tui(status); + } + match events.recv_timeout(CONTROL_POLL) { + Ok(ControlEvent::Bound) => {} + Ok(ControlEvent::Closed) => { + anyhow::bail!("Codex control connection closed while the TUI was live") + } + Ok(ControlEvent::Failed(error)) => { + anyhow::bail!("Codex control failed while the TUI was live: {error}") + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => { + anyhow::bail!("Codex control observer ended while the TUI was live") + } + } + } +} + +fn completed_tui(status: ExitStatus) -> Result<()> { + anyhow::ensure!( + status.success(), + "controlled Codex TUI exited with {status}" + ); + Ok(()) +} + +fn ensure_supported_version(codex: &str) -> Result<()> { + let output = Command::new(codex) + .arg("--version") + .output() + .with_context(|| format!("reading Codex version from {codex}"))?; + anyhow::ensure!( + output.status.success(), + "{codex} --version failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + let actual = String::from_utf8(output.stdout) + .context("Codex version output is not UTF-8")? + .trim() + .to_string(); + anyhow::ensure!( + actual == SUPPORTED_CODEX_CLI_VERSION, + "unsupported Codex app-server protocol version '{actual}' (expected '{SUPPORTED_CODEX_CLI_VERSION}')" + ); + Ok(()) +} + +pub fn state_dir(catalog_root: &Path, identity: &str) -> PathBuf { + let base = std::env::var_os("XDG_STATE_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/state"))) + .unwrap_or_else(|| PathBuf::from("/tmp")); + state_dir_in(&base, catalog_root, identity) +} + +fn state_dir_in(base: &Path, catalog_root: &Path, identity: &str) -> PathBuf { + base.join("st2") + .join("codex") + .join(runtime_key(catalog_root, identity)) +} + +fn socket_path(catalog_root: &Path, identity: &str) -> Result { + let key = runtime_key(catalog_root, identity); + let preferred = std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .map(|base| base.join("st2-codex").join(format!("{key}.sock"))); + if let Some(path) = preferred + && path.as_os_str().as_bytes().len() <= SOCKET_PATH_BUDGET + { + return Ok(path); + } + let path = PathBuf::from("/tmp") + .join(format!("st2-{}", unsafe { libc::geteuid() })) + .join("codex") + .join(format!("{key}.sock")); + anyhow::ensure!( + path.as_os_str().as_bytes().len() <= SOCKET_PATH_BUDGET, + "Codex app-server socket path is too long: {}", + path.display() + ); + Ok(path) +} + +fn runtime_key(catalog_root: &Path, identity: &str) -> String { + let mut hash = Sha256::new(); + for value in [catalog_root.as_os_str().as_bytes(), identity.as_bytes()] { + hash.update((value.len() as u64).to_be_bytes()); + hash.update(value); + } + let digest = format!("{:x}", hash.finalize()); + digest[..24].to_string() +} + +fn secure_dir(path: &Path) -> Result<()> { + fs::create_dir_all(path)?; + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + Ok(()) +} + +fn acquire_owner_lock(state_dir: &Path) -> Result { + let path = state_dir.join("owner.lock"); + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .mode(0o600) + .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW) + .open(&path) + .with_context(|| format!("opening Codex runtime owner lock {}", path.display()))?; + // SAFETY: `file` owns this descriptor until the returned guard is dropped. `flock` does not + // access Rust memory, and closing the descriptor releases the process-scoped lock after crash. + let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + if result != 0 { + return Err(std::io::Error::last_os_error()) + .with_context(|| format!("Codex runtime already has an owner at {}", path.display())); + } + Ok(file) +} + +fn atomic_json(path: &Path, value: &impl Serialize) -> Result<()> { + let parent = path.parent().context("state file has no parent")?; + secure_dir(parent)?; + let temp = parent.join(format!( + ".{}.{}.tmp", + path.file_name().unwrap().to_string_lossy(), + random_token()? + )); + let result = (|| -> Result<()> { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&temp)?; + serde_json::to_writer_pretty(&mut file, value)?; + file.write_all(b"\n")?; + file.sync_all()?; + fs::rename(&temp, path)?; + File::open(parent)?.sync_all()?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&temp); + } + result +} + +pub fn load_current_binding( + path: &Path, + runtime: &CodexRuntime, +) -> Result> { + let bytes = match fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + let binding: CodexThreadBinding = serde_json::from_slice(&bytes)?; + anyhow::ensure!( + binding.schema == BINDING_SCHEMA, + "unsupported Codex binding schema" + ); + anyhow::ensure!( + binding.agent == runtime.agent + && binding.runtime_id == runtime.runtime_id + && binding.runtime_incarnation == runtime.incarnation, + "Codex thread binding belongs to a different runtime incarnation" + ); + Ok(Some(binding)) +} + +fn load_resume_thread(path: &Path, agent: &str, runtime_id: &str) -> Result> { + let bytes = match fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + let binding: CodexThreadBinding = serde_json::from_slice(&bytes)?; + anyhow::ensure!( + binding.schema == BINDING_SCHEMA, + "unsupported Codex binding schema" + ); + anyhow::ensure!( + binding.agent == agent && binding.runtime_id == runtime_id, + "Codex resume binding belongs to a different agent runtime" + ); + anyhow::ensure!( + !binding.thread_id.is_empty(), + "Codex resume binding has an empty thread id" + ); + Ok(Some(binding.thread_id)) +} + +fn random_token() -> Result { + let mut bytes = [0_u8; 16]; + File::open("/dev/urandom")?.read_exact(&mut bytes)?; + Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect()) +} + +fn write_json_message(websocket: &mut WebSocket, value: &Value) -> Result<()> { + websocket.send(Message::Text(value.to_string().into()))?; + Ok(()) +} + +fn read_json_message(websocket: &mut WebSocket) -> Result> { + loop { + let message = match websocket.read() { + Ok(message) => message, + Err(tungstenite::Error::ConnectionClosed | tungstenite::Error::AlreadyClosed) => { + return Ok(None); + } + Err(error) => return Err(error.into()), + }; + match message { + Message::Text(text) => { + let value = serde_json::from_str(&text) + .context("decoding Codex app-server WebSocket JSON")?; + return Ok(Some(value)); + } + Message::Close(_) => return Ok(None), + Message::Ping(_) | Message::Pong(_) => continue, + Message::Binary(_) | Message::Frame(_) => { + anyhow::bail!("Codex app-server sent a non-text WebSocket message") + } + } + } +} + +fn terminate_child(child: &mut Child) { + match child.try_wait() { + Ok(Some(_)) => {} + _ => { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::net::UnixListener; + + #[test] + fn control_initializes_before_recording_the_first_thread_only() { + let tmp = tempfile::tempdir().unwrap(); + let socket = tmp.path().join("server.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let server = thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut websocket = tungstenite::accept(stream).unwrap(); + let initialize = read_json_message(&mut websocket).unwrap().unwrap(); + assert_eq!(initialize["method"], "initialize"); + assert_eq!(initialize["params"]["clientInfo"]["name"], "st2"); + write_json_message( + &mut websocket, + &json!({ "id": 0, "result": { "userAgent": "fake" } }), + ) + .unwrap(); + let initialized = read_json_message(&mut websocket).unwrap().unwrap(); + assert_eq!(initialized["method"], "initialized"); + write_json_message( + &mut websocket, + &json!({ "method": "thread/started", "params": { "thread": { "id": "thread-main" } } }), + ) + .unwrap(); + write_json_message( + &mut websocket, + &json!({ "method": "thread/started", "params": { "thread": { "id": "thread-review" } } }), + ) + .unwrap(); + }); + + let stream = UnixStream::connect(&socket).unwrap(); + let shutdown = stream.try_clone().unwrap(); + let websocket = initialize_control(stream).unwrap(); + let state = tmp.path().join("state"); + let binding_path = state.join("binding.json"); + let runtime = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(); + let (tx, rx) = mpsc::channel(); + let runtime_for_pump = runtime.clone(); + let binding_for_pump = binding_path.clone(); + let pump = thread::spawn(move || { + pump_control(websocket, &binding_for_pump, &runtime_for_pump, None, tx) + }); + assert!(matches!( + rx.recv_timeout(Duration::from_secs(2)).unwrap(), + ControlEvent::Bound + )); + server.join().unwrap(); + let _ = shutdown.shutdown(Shutdown::Both); + pump.join().unwrap(); + + let binding = load_current_binding(&binding_path, &runtime) + .unwrap() + .unwrap(); + assert_eq!(binding.thread_id(), "thread-main"); + } + + #[test] + fn a_successfully_loaded_expected_resume_is_bound_to_the_new_incarnation() { + let tmp = tempfile::tempdir().unwrap(); + let socket = tmp.path().join("server.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let server = thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut websocket = tungstenite::accept(stream).unwrap(); + let initialize = read_json_message(&mut websocket).unwrap().unwrap(); + assert_eq!(initialize["method"], "initialize"); + write_json_message( + &mut websocket, + &json!({ "id": 0, "result": { "userAgent": "fake" } }), + ) + .unwrap(); + let initialized = read_json_message(&mut websocket).unwrap().unwrap(); + assert_eq!(initialized["method"], "initialized"); + write_json_message( + &mut websocket, + &json!({ + "method": "thread/status/changed", + "params": { + "threadId": "thread-unrelated", + "status": { "type": "active", "activeFlags": [] } + } + }), + ) + .unwrap(); + write_json_message( + &mut websocket, + &json!({ + "method": "thread/status/changed", + "params": { + "threadId": "thread-prior", + "status": { "type": "idle" } + } + }), + ) + .unwrap(); + }); + + let stream = UnixStream::connect(&socket).unwrap(); + let shutdown = stream.try_clone().unwrap(); + let websocket = initialize_control(stream).unwrap(); + let binding_path = tmp.path().join("state/binding.json"); + let runtime = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(); + let (tx, rx) = mpsc::channel(); + let runtime_for_pump = runtime.clone(); + let binding_for_pump = binding_path.clone(); + let pump = thread::spawn(move || { + pump_control( + websocket, + &binding_for_pump, + &runtime_for_pump, + Some("thread-prior"), + tx, + ) + }); + assert!(matches!( + rx.recv_timeout(Duration::from_secs(2)).unwrap(), + ControlEvent::Bound + )); + server.join().unwrap(); + let _ = shutdown.shutdown(Shutdown::Both); + pump.join().unwrap(); + + let binding = load_current_binding(&binding_path, &runtime) + .unwrap() + .unwrap(); + assert_eq!(binding.thread_id(), "thread-prior"); + } + + #[test] + fn a_binding_from_another_runtime_incarnation_is_rejected() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("binding.json"); + let prior = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(); + let current = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(); + atomic_json( + &path, + &CodexThreadBinding::new(&prior, "thread-prior".into()), + ) + .unwrap(); + assert_eq!( + load_resume_thread(&path, "h.worker", "h.worker").unwrap(), + Some("thread-prior".into()), + "a validated prior binding may select resume but must not become current ownership" + ); + let error = load_current_binding(&path, ¤t).unwrap_err(); + assert!(error.to_string().contains("different runtime incarnation")); + } + + #[test] + fn controlled_tui_resumes_a_prior_binding_without_overriding_authored_selection() { + let authored = vec!["--model".into(), "gpt-test".into(), "boot".into()]; + assert_eq!( + controlled_tui_args("unix:///server.sock", &authored, None).unwrap(), + [ + "--remote", + "unix:///server.sock", + "--model", + "gpt-test", + "boot" + ] + ); + assert_eq!( + controlled_tui_args("unix:///server.sock", &authored, Some("thread-prior")).unwrap(), + [ + "--remote", + "unix:///server.sock", + "resume", + "--model", + "gpt-test", + "thread-prior", + "boot" + ] + ); + assert_eq!( + controlled_tui_args( + "unix:///server.sock", + &["resume".into(), "thread-explicit".into()], + Some("thread-prior") + ) + .unwrap(), + [ + "--remote", + "unix:///server.sock", + "resume", + "thread-explicit" + ] + ); + + let fork = vec![ + "--dangerously-bypass-hook-trust".into(), + "fork".into(), + "thread-explicit".into(), + ]; + assert_eq!( + controlled_tui_args("unix:///server.sock", &fork, Some("thread-prior")).unwrap(), + [ + "--remote", + "unix:///server.sock", + "--dangerously-bypass-hook-trust", + "fork", + "thread-explicit" + ] + ); + } + + #[test] + fn controlled_tui_resume_fails_closed_at_ambiguous_option_boundaries() { + let unknown = controlled_tui_args( + "unix:///server.sock", + &["--future-option".into(), "value".into(), "prompt".into()], + Some("thread-prior"), + ) + .unwrap_err(); + assert!(unknown.to_string().contains("unknown Codex option")); + + let image = controlled_tui_args( + "unix:///server.sock", + &["--image".into(), "one.png".into(), "prompt".into()], + Some("thread-prior"), + ) + .unwrap_err(); + assert!(image.to_string().contains("explicit `--`")); + + assert_eq!( + controlled_tui_args( + "unix:///server.sock", + &[ + "--image".into(), + "one.png".into(), + "--".into(), + "prompt".into(), + ], + Some("thread-prior"), + ) + .unwrap(), + [ + "--remote", + "unix:///server.sock", + "resume", + "--image", + "one.png", + "thread-prior", + "--", + "prompt" + ] + ); + } + + #[test] + fn state_key_is_path_and_identity_specific_without_embedding_either() { + let base = Path::new("/state"); + let first = state_dir_in(base, Path::new("/catalog/a"), "h.worker"); + let second = state_dir_in(base, Path::new("/catalog/b"), "h.worker"); + assert_ne!(first, second); + assert!(first.starts_with("/state/st2/codex")); + assert!(!first.display().to_string().contains("worker")); + assert!(!first.display().to_string().contains("catalog/a")); + } + + #[test] + fn runtime_owner_lock_is_nonblocking_and_released_on_close() { + let tmp = tempfile::tempdir().unwrap(); + let first = acquire_owner_lock(tmp.path()).unwrap(); + let error = acquire_owner_lock(tmp.path()).unwrap_err(); + assert!(error.to_string().contains("already has an owner")); + drop(first); + acquire_owner_lock(tmp.path()).unwrap(); + } +} diff --git a/src/eval_run.rs b/src/eval_run.rs index a758aabb..e3c0b9f0 100644 --- a/src/eval_run.rs +++ b/src/eval_run.rs @@ -16,7 +16,9 @@ use crate::eval_spec::{ }; use crate::expand::expand_catalog; use crate::flapping::FlappingCap; -use crate::reconcile::{TaskCompileContext, compile_generated_ding_tasks, reconcile}; +use crate::reconcile::{TaskCompileContext, compile_generated_tasks, reconcile}; +#[cfg(test)] +use crate::reconcile::compile_generated_ding_tasks; use crate::run::{Runner, SystemRunner, UpReport, detect_host, execute}; use agent_spec::spec::{AgentDesiredState, AgentSpec, JobType, Task, TaskKind, TaskLifecycle}; @@ -1094,7 +1096,7 @@ fn run_eval_inner( .collect::>(); (specs, runtime_tasks, participants, None) }; - compile_generated_ding_tasks(&mut specs, host, task_context)?; + compile_generated_tasks(&mut specs, host, task_context)?; let task_ids = runtime_tasks .iter() .map(|task| task.runtime_id.clone()) diff --git a/src/lib.rs b/src/lib.rs index 696dcb1a..8495b19d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ pub mod agents; pub mod catalog; pub mod catalog_lock; pub mod catalog_transaction; +pub mod codex_app_server; pub mod context; pub mod ding; pub mod eval_run; diff --git a/src/main.rs b/src/main.rs index 969233ad..1f34f2a2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -112,6 +112,19 @@ enum Command { #[arg(long, default_value_t = 1000)] interval: u64, }, + /// Internal controlled Codex launch. Generated only for `deliver "app-server"` tasks. + #[command(hide = true)] + CodexAppServer { + /// Exact agent bus identity that owns the controlled thread. + #[arg(long)] + identity: String, + /// Exact reconciled PTY task identity for this runtime. + #[arg(long)] + runtime_id: String, + /// Original structured Codex invocation, including its provider executable. + #[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)] + codex_argv: Vec, + }, /// Get or set an agent's presence status. No `--set` prints the status; no identity means yours /// (`$ST_AGENT`). Settable: offline | available | busy | away | dnd (`unknown` is derived). Status { @@ -818,6 +831,20 @@ fn main() -> Result<()> { host, interval, } => ding_cmd(session, identity, root, host, interval), + Command::CodexAppServer { + identity, + runtime_id, + codex_argv, + } => { + let catalog = catalog_arg(None)?; + let catalog = catalog.canonicalize().unwrap_or(catalog); + st2::codex_app_server::run_controlled( + &catalog, + identity, + runtime_id, + codex_argv, + ) + } Command::Status { identity, set, ctx } => status_cmd(identity, set, ctx), Command::Rename(args) => presentation_cmd(st2::agent_author::PresentationField::Name, args), Command::Describe(args) => { @@ -2672,7 +2699,7 @@ fn up_spec_fleet(spec_file: &Path, host: Option, once: bool, interval: u let task_context = st2::reconcile::TaskCompileContext::current(root.clone())?; st2::eval_run::prepare_spawn_env(task_context.st2_executable()); let mut specs = st2::eval_run::spec_to_agent_specs(&spec.agents, &this_host, &root); - st2::reconcile::compile_generated_ding_tasks(&mut specs, &this_host, &task_context)?; + st2::reconcile::compile_generated_tasks(&mut specs, &this_host, &task_context)?; let runner = SystemRunner::new(root.clone(), exec_state_dir(&this_host)); // One supervisor per (spec dir, host) — the same host-lock discipline as the catalog path. diff --git a/src/reconcile.rs b/src/reconcile.rs index 4688778a..d2d0b45a 100644 --- a/src/reconcile.rs +++ b/src/reconcile.rs @@ -15,7 +15,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; -use agent_spec::spec::{AgentSpec, TaskKind, TaskLifecycle}; +use agent_spec::spec::{AgentSpec, DeliveryTransport, TaskKind, TaskLifecycle}; /// Immutable inputs captured once before generated tasks are compiled. #[derive(Debug, Clone, PartialEq, Eq)] @@ -73,6 +73,20 @@ impl TaskCompileContext { pub fn st2_executable(&self) -> &Path { &self.st2_executable } + + pub fn catalog_root(&self) -> &Path { + &self.catalog_root + } +} + +/// Compile every runner-owned launch marker into an exact invocation of this st2 binary. +pub fn compile_generated_tasks( + specs: &mut [AgentSpec], + this_host: &str, + context: &TaskCompileContext, +) -> Result<()> { + compile_generated_ding_tasks(specs, this_host, context)?; + compile_app_server_agent_tasks(specs, this_host, context) } /// Replace only runner-generated DING markers with exact direct argv. Authored tasks never carry @@ -122,6 +136,87 @@ pub fn compile_generated_ding_tasks( Ok(()) } +/// Route an explicitly selected Codex native transport through st2's controlled-launch wrapper. +/// +/// The wrapper owns the provider daemon and its control connection, so it can complete the +/// initialize handshake before the interactive client is allowed to create or resume a thread. +/// App-server delivery therefore requires structured argv: rewriting opaque shell source would be +/// unsound, and an already-remote launch would have two competing control owners. +pub fn compile_app_server_agent_tasks( + specs: &mut [AgentSpec], + this_host: &str, + context: &TaskCompileContext, +) -> Result<()> { + let st2_executable = context + .st2_executable + .to_str() + .context("running st2 executable path is not UTF-8")? + .to_owned(); + let catalog_root = context + .catalog_root + .to_str() + .context("catalog root is not UTF-8")? + .to_owned(); + + for spec in specs { + if spec.delivery != Some(DeliveryTransport::AppServer) { + continue; + } + let bus_id = spec.bus_id(this_host); + let mut candidates = spec + .tasks + .iter_mut() + .filter(|task| !task.derived && task.name == "agent"); + let task = candidates.next().with_context(|| { + format!( + "agent '{bus_id}' selects `deliver \"app-server\"` but has no canonical `agent` task" + ) + })?; + anyhow::ensure!( + candidates.next().is_none(), + "agent '{bus_id}' selects `deliver \"app-server\"` with more than one canonical `agent` task" + ); + anyhow::ensure!( + task.kind == TaskKind::Pty, + "agent '{bus_id}' selects `deliver \"app-server\"` for a non-PTY canonical task" + ); + let authored = task.argv.clone().with_context(|| { + format!( + "agent '{bus_id}' selects `deliver \"app-server\"`; its canonical task must use structured `argv`, not shell `command`" + ) + })?; + anyhow::ensure!( + !authored.is_empty(), + "agent '{bus_id}' selects `deliver \"app-server\"` with an empty canonical argv" + ); + anyhow::ensure!( + !authored + .iter() + .any(|arg| arg == "--remote" || arg.starts_with("--remote=")), + "agent '{bus_id}' selects `deliver \"app-server\"` but its canonical argv already declares `--remote`" + ); + let runtime_id = task + .id + .clone() + .unwrap_or_else(|| format!("{bus_id}.{}", task.name)); + let mut argv = vec![ + st2_executable.clone(), + "--catalog".to_string(), + catalog_root.clone(), + "codex-app-server".to_string(), + "--identity".to_string(), + bus_id, + "--runtime-id".to_string(), + runtime_id, + "--".to_string(), + ]; + argv.extend(authored); + task.command = None; + task.argv = Some(argv); + } + Ok(()) +} + /// ACTUAL state: one running/known task as st2 observes it (unioned across backends). #[derive(Debug, Clone, PartialEq, Eq)] pub struct Session { diff --git a/src/run.rs b/src/run.rs index 53c4564a..b2eb46e2 100644 --- a/src/run.rs +++ b/src/run.rs @@ -32,7 +32,7 @@ use crate::flapping::FlappingCap; use crate::message; use crate::reconcile::{ PtyPresentation, ReconcilePlan, Session, TaskCompileContext, TaskLaunch, TaskTarget, - compile_generated_ding_tasks, + compile_generated_tasks, }; use crate::task_inventory::{ DesiredRuntime, ObservationBatch, ObservedState, RuntimeGeneration, RuntimeObservation, @@ -1514,7 +1514,7 @@ fn reconcile_pass( .filter(|spec| !materialized.failed_agents.contains(&spec.bus_id(this_host))) .cloned() .collect(); - if let Err(error) = compile_generated_ding_tasks(&mut eligible_specs, this_host, task_context) { + if let Err(error) = compile_generated_tasks(&mut eligible_specs, this_host, task_context) { report.skipped = true; report .errors @@ -1795,7 +1795,7 @@ where crate::reconcile::validate_task_identities(specs, this_host)?; let task_context = TaskCompileContext::current(catalog_root.to_path_buf())?; let mut compiled_specs = specs.to_vec(); - compile_generated_ding_tasks(&mut compiled_specs, this_host, &task_context)?; + compile_generated_tasks(&mut compiled_specs, this_host, &task_context)?; let sessions = runner .list_sessions() .map_err(|e| anyhow::anyhow!("list sessions: {e}"))?; diff --git a/tests/codex_app_server.rs b/tests/codex_app_server.rs new file mode 100644 index 00000000..b0f7d2f9 --- /dev/null +++ b/tests/codex_app_server.rs @@ -0,0 +1,103 @@ +use std::fs; +use std::path::Path; + +use st2::DeliveryTransport; +use st2::reconcile::{TaskCompileContext, compile_generated_tasks}; + +fn write(path: &Path, body: &str) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, body).unwrap(); +} + +fn context(root: &Path) -> TaskCompileContext { + let executable = root.join("bin/st2"); + write(&executable, "test binary"); + TaskCompileContext::new(root.to_path_buf(), executable).unwrap() +} + +#[test] +fn app_server_selector_wraps_the_canonical_argv_with_exact_owner_inputs() { + let tmp = tempfile::tempdir().unwrap(); + let declaration = tmp.path().join("agents/h/worker/agent.kdl"); + write( + &declaration, + r#"agent "worker" { + host "h" + deliver "app-server" + argv "codex" "--model" "gpt-test" "boot" +} +"#, + ); + let mut found = st2::discover(tmp.path()); + assert!(found.errors.is_empty(), "{:?}", found.errors); + + compile_generated_tasks(&mut found.specs, "h", &context(tmp.path())).unwrap(); + + let spec = &found.specs[0]; + assert_eq!(spec.delivery, Some(DeliveryTransport::AppServer)); + let task = spec.tasks.iter().find(|task| task.name == "agent").unwrap(); + assert_eq!(task.command, None); + assert_eq!( + task.argv.as_deref(), + Some( + [ + tmp.path().join("bin/st2").display().to_string(), + "--catalog".into(), + tmp.path().display().to_string(), + "codex-app-server".into(), + "--identity".into(), + "h.worker".into(), + "--runtime-id".into(), + "h.worker".into(), + "--".into(), + "codex".into(), + "--model".into(), + "gpt-test".into(), + "boot".into(), + ] + .as_slice() + ) + ); +} + +#[test] +fn app_server_selector_rejects_shell_and_pre_remote_launches_without_mutating_them() { + for (name, launch, expected) in [ + ( + "shell", + "command \"exec codex\"", + "must use structured `argv`", + ), + ( + "remote", + "argv \"codex\" \"--remote\" \"unix:///other.sock\"", + "already declares `--remote`", + ), + ] { + let tmp = tempfile::tempdir().unwrap(); + write( + &tmp.path().join(format!("agents/h/{name}/agent.kdl")), + &format!("agent \"{name}\" {{ host \"h\"; deliver \"app-server\"; {launch} }}"), + ); + let mut found = st2::discover(tmp.path()); + assert!(found.errors.is_empty(), "{name}: {:?}", found.errors); + let before = found.specs.clone(); + let error = + compile_generated_tasks(&mut found.specs, "h", &context(tmp.path())).unwrap_err(); + assert!(error.to_string().contains(expected), "{error:#}"); + assert_eq!(found.specs, before, "{name} compile failure mutated source"); + } +} + +#[test] +fn mcp_selector_does_not_rewrite_the_authored_launch() { + let tmp = tempfile::tempdir().unwrap(); + write( + &tmp.path().join("agents/h/worker/agent.kdl"), + r#"agent "worker" { host "h"; deliver "mcp"; argv "claude" "boot" }"#, + ); + let mut found = st2::discover(tmp.path()); + let before = found.specs.clone(); + compile_generated_tasks(&mut found.specs, "h", &context(tmp.path())).unwrap(); + assert_eq!(found.specs, before); +} From 4eeb16306238dfccfac30f4330f9267235167b5f Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Mon, 10 Aug 2026 19:42:14 +0200 Subject: [PATCH 03/56] Track controlled Codex turn state --- src/codex_app_server.rs | 778 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 736 insertions(+), 42 deletions(-) diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index 8c94a923..cb1ffd4f 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -3,8 +3,8 @@ //! Native delivery cannot infer a thread from cwd, process, PTY, or `thread/list`. This module //! starts a dedicated provider daemon, initializes an observer connection before the interactive //! client starts, and binds a typed start or successful-resume event to the exact wrapper process -//! incarnation that owns the PTY launch. Message watching and delivery are deliberately later -//! layers; this module establishes only the topology and identity boundary they consume. +//! incarnation that owns the PTY launch. Its control watcher persists delivery-relevant thread and +//! turn state. Message selection and delivery remain later layers. use std::fs::{self, File, OpenOptions}; use std::io::{Read as _, Write}; @@ -28,6 +28,8 @@ use tungstenite::{Message, WebSocket}; pub const SUPPORTED_CODEX_CLI_VERSION: &str = "codex-cli 0.145.0"; const RUNTIME_SCHEMA: &str = "st2.codex-runtime.v1"; const BINDING_SCHEMA: &str = "st2.codex-thread-binding.v1"; +const CONTROL_STATE_SCHEMA: &str = "st2.codex-control-state.v1"; +const CONTROL_SUBSCRIBE_REQUEST_ID: u64 = 1; const STARTUP_TIMEOUT: Duration = Duration::from_secs(30); const CONTROL_POLL: Duration = Duration::from_millis(100); const SOCKET_PATH_BUDGET: usize = 96; @@ -94,6 +96,287 @@ impl CodexThreadBinding { } } +/// The latest delivery-relevant state observed on the bound app-server control stream. +/// +/// `Active` is the only state that permits `turn/steer`: its turn ID came from the latest +/// unmatched `turn/started` event. Every other non-idle state is an explicit hold. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum CodexObservedState { + AwaitingStatus, + Idle, + Active { + #[serde(rename = "turnId")] + turn_id: String, + }, + Held { + reason: CodexHoldReason, + #[serde(rename = "turnId")] + turn_id: Option, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum CodexHoldReason { + ActiveWithoutTurn, + ConflictingTurn, + Review, + Compaction, + NotLoaded, + SystemError, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CodexControlState { + schema: String, + agent: String, + runtime_id: String, + runtime_incarnation: String, + thread_id: String, + subscribed: bool, + observed: CodexObservedState, +} + +enum SubscriptionAcceptance { + Accepted { changed: bool }, + Deferred, +} + +impl CodexControlState { + fn new(runtime: &CodexRuntime, thread_id: String) -> Self { + Self { + schema: CONTROL_STATE_SCHEMA.to_string(), + agent: runtime.agent.clone(), + runtime_id: runtime.runtime_id.clone(), + runtime_incarnation: runtime.incarnation.clone(), + thread_id, + subscribed: false, + observed: CodexObservedState::AwaitingStatus, + } + } + + pub fn thread_id(&self) -> &str { + &self.thread_id + } + + pub fn observed(&self) -> &CodexObservedState { + &self.observed + } + + pub fn subscribed(&self) -> bool { + self.subscribed + } + + fn accept_subscription(&mut self, message: &Value) -> Result { + if let Some(error) = message.get("error") { + let code = error.get("code").and_then(Value::as_i64); + let detail = error.get("message").and_then(Value::as_str); + if code == Some(-32600) + && detail + .is_some_and(|detail| detail.starts_with("no rollout found for thread id ")) + { + return Ok(SubscriptionAcceptance::Deferred); + } + anyhow::bail!("Codex app-server rejected control thread/resume: {error}"); + } + anyhow::ensure!( + message.get("result").is_some(), + "Codex control thread/resume response has no result" + ); + let thread_id = required_string(message, "/result/thread/id", "thread/resume response")?; + anyhow::ensure!( + thread_id == self.thread_id, + "Codex control thread/resume returned a different thread" + ); + let status = required_string( + message, + "/result/thread/status/type", + "thread/resume response", + )?; + let before = (self.subscribed, self.observed.clone()); + self.subscribed = true; + self.observe_thread_status(status); + Ok(SubscriptionAcceptance::Accepted { + changed: (self.subscribed, self.observed.clone()) != before, + }) + } + + fn observe(&mut self, message: &Value) -> Result { + let Some(method) = message.get("method").and_then(Value::as_str) else { + return Ok(false); + }; + let before = self.observed.clone(); + match method { + "thread/started" => { + let thread_id = required_string(message, "/params/thread/id", method)?; + if thread_id != self.thread_id { + return Ok(false); + } + let status = required_string(message, "/params/thread/status/type", method)?; + self.observe_thread_status(status); + } + "thread/status/changed" => { + let thread_id = required_string(message, "/params/threadId", method)?; + if thread_id != self.thread_id { + return Ok(false); + } + let status = required_string(message, "/params/status/type", method)?; + self.observe_thread_status(status); + } + "turn/started" => { + let thread_id = required_string(message, "/params/threadId", method)?; + if thread_id != self.thread_id { + return Ok(false); + } + let turn_id = required_string(message, "/params/turn/id", method)?.to_string(); + self.observe_turn_started(turn_id); + } + "turn/completed" => { + let thread_id = required_string(message, "/params/threadId", method)?; + if thread_id != self.thread_id { + return Ok(false); + } + let turn_id = required_string(message, "/params/turn/id", method)?; + self.observe_turn_completed(turn_id); + } + "item/started" | "item/completed" => { + let thread_id = required_string(message, "/params/threadId", method)?; + if thread_id != self.thread_id { + return Ok(false); + } + let item_type = required_string(message, "/params/item/type", method)?; + let reason = match item_type { + "enteredReviewMode" => CodexHoldReason::Review, + "contextCompaction" => CodexHoldReason::Compaction, + _ => return Ok(false), + }; + let turn_id = required_string(message, "/params/turnId", method)?; + self.observe_non_steerable(turn_id, reason); + } + _ => return Ok(false), + } + Ok(self.observed != before) + } + + fn observe_thread_status(&mut self, status: &str) { + self.observed = match status { + "idle" => CodexObservedState::Idle, + "active" => match &self.observed { + CodexObservedState::Active { .. } + | CodexObservedState::Held { + reason: + CodexHoldReason::Review + | CodexHoldReason::Compaction + | CodexHoldReason::ConflictingTurn, + .. + } => self.observed.clone(), + _ => CodexObservedState::Held { + reason: CodexHoldReason::ActiveWithoutTurn, + turn_id: None, + }, + }, + "notLoaded" => CodexObservedState::Held { + reason: CodexHoldReason::NotLoaded, + turn_id: None, + }, + "systemError" => CodexObservedState::Held { + reason: CodexHoldReason::SystemError, + turn_id: None, + }, + _ => CodexObservedState::Held { + reason: CodexHoldReason::SystemError, + turn_id: None, + }, + }; + } + + fn observe_turn_started(&mut self, turn_id: String) { + self.observed = match &self.observed { + CodexObservedState::Active { turn_id: current } if current == &turn_id => { + self.observed.clone() + } + CodexObservedState::Held { + reason: CodexHoldReason::Review | CodexHoldReason::Compaction, + turn_id: Some(current), + } if current == &turn_id => self.observed.clone(), + CodexObservedState::Active { .. } + | CodexObservedState::Held { + reason: + CodexHoldReason::Review + | CodexHoldReason::Compaction + | CodexHoldReason::ConflictingTurn, + .. + } => CodexObservedState::Held { + reason: CodexHoldReason::ConflictingTurn, + turn_id: None, + }, + _ => CodexObservedState::Active { turn_id }, + }; + } + + fn observe_turn_completed(&mut self, turn_id: &str) { + self.observed = match &self.observed { + CodexObservedState::Idle => CodexObservedState::Idle, + CodexObservedState::Active { turn_id: current } if current == turn_id => { + CodexObservedState::Idle + } + CodexObservedState::Held { + reason: CodexHoldReason::Review | CodexHoldReason::Compaction, + turn_id: Some(current), + } if current == turn_id => CodexObservedState::Idle, + CodexObservedState::AwaitingStatus + | CodexObservedState::Held { + reason: CodexHoldReason::ActiveWithoutTurn, + .. + } => CodexObservedState::Idle, + CodexObservedState::Held { + reason: CodexHoldReason::ConflictingTurn, + .. + } => self.observed.clone(), + _ => CodexObservedState::Held { + reason: CodexHoldReason::ConflictingTurn, + turn_id: None, + }, + }; + } + + fn observe_non_steerable(&mut self, turn_id: &str, reason: CodexHoldReason) { + self.observed = match &self.observed { + CodexObservedState::Active { turn_id: current } if current == turn_id => { + CodexObservedState::Held { + reason, + turn_id: Some(turn_id.to_string()), + } + } + CodexObservedState::Held { + reason: current_reason, + turn_id: Some(current), + } if current == turn_id + && matches!( + current_reason, + CodexHoldReason::Review | CodexHoldReason::Compaction + ) => + { + self.observed.clone() + } + _ => CodexObservedState::Held { + reason: CodexHoldReason::ConflictingTurn, + turn_id: None, + }, + }; + } +} + +fn required_string<'a>(message: &'a Value, pointer: &str, method: &str) -> Result<&'a str> { + message + .pointer(pointer) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .with_context(|| format!("{method} has no non-empty {pointer}")) +} + /// Run one authored Codex argv behind a dedicated app server and initialized control connection. pub fn run_controlled( catalog_root: &Path, @@ -199,17 +482,21 @@ fn run_connected( codex_argv: &[String], resume_thread: Option<&str>, ) -> Result<()> { + let tui_args = controlled_tui_args(endpoint, &codex_argv[1..], resume_thread)?; + let expected_resume = + expected_resume_thread(&codex_argv[1..], resume_thread)?.map(str::to_owned); let control = connect_control(server, socket_path, STARTUP_TIMEOUT)?; let shutdown = control.try_clone()?; let websocket = initialize_control(control)?; let (events_tx, events_rx) = mpsc::channel(); let binding_path = state_dir.join("binding.json"); + let control_state_path = state_dir.join("control-state.json"); let runtime_for_reader = runtime.clone(); - let expected_resume = resume_thread.map(str::to_owned); let event_thread = thread::spawn(move || { pump_control( websocket, &binding_path, + &control_state_path, &runtime_for_reader, expected_resume.as_deref(), events_tx, @@ -220,11 +507,7 @@ fn run_connected( // thread/resume. Insert the remote endpoint as a global Codex option and preserve every authored // argument after the provider executable. let mut tui_command = Command::new(&codex_argv[0]); - tui_command.args(controlled_tui_args( - endpoint, - &codex_argv[1..], - resume_thread, - )?); + tui_command.args(tui_args); let mut tui = tui_command .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) @@ -265,6 +548,22 @@ fn controlled_tui_args( Ok(args) } +/// A saved binding constrains the watcher only when st2 inserted that resume selection. +/// +/// An authored `resume` or `fork` command owns its own selection. The watcher binds the first typed +/// event from that command instead of rejecting it because it differs from an older saved binding. +fn expected_resume_thread<'a>( + authored_args: &[String], + resume_thread: Option<&'a str>, +) -> Result> { + let Some(thread_id) = resume_thread else { + return Ok(None); + }; + Ok(resume_insertion_index(authored_args)? + .is_some() + .then_some(thread_id)) +} + /// Find where a pinned Codex 0.145.0 interactive argv begins its prompt or subcommand. /// /// Automatic resume must insert `resume ` after global options and before the authored @@ -454,6 +753,7 @@ fn initialize_control(stream: UnixStream) -> Result> { #[derive(Debug)] enum ControlEvent { Bound, + Observed, Closed, Failed(String), } @@ -461,53 +761,80 @@ enum ControlEvent { fn pump_control( mut websocket: WebSocket, binding_path: &Path, + control_state_path: &Path, runtime: &CodexRuntime, expected_resume: Option<&str>, events: Sender, ) { let result = (|| -> Result<()> { - let mut bound_thread: Option = None; + let mut control_state: Option = None; + let mut subscription_pending = false; loop { let Some(message) = read_json_message(&mut websocket)? else { let _ = events.send(ControlEvent::Closed); return Ok(()); }; - let thread_id = match message.get("method").and_then(Value::as_str) { - Some("thread/started") => message - .pointer("/params/thread/id") - .and_then(Value::as_str) - .filter(|id| !id.is_empty()) - .context("thread/started has no non-empty params.thread.id")?, - Some("thread/status/changed") if expected_resume.is_some() => { - let thread_id = message - .pointer("/params/threadId") - .and_then(Value::as_str) - .filter(|id| !id.is_empty()) - .context("thread/status/changed has no non-empty params.threadId")?; - let status = message - .pointer("/params/status/type") - .and_then(Value::as_str) - .context("thread/status/changed has no params.status.type")?; - if Some(thread_id) != expected_resume || !matches!(status, "idle" | "active") { - continue; - } - thread_id - } - _ => continue, - }; - match bound_thread.as_deref() { - None => { + if control_state.is_none() { + let Some(thread_id) = binding_candidate(&message, expected_resume)? else { + continue; + }; + { atomic_json( binding_path, &CodexThreadBinding::new(runtime, thread_id.to_string()), )?; - bound_thread = Some(thread_id.to_string()); + control_state = Some(CodexControlState::new(runtime, thread_id.to_string())); + atomic_json( + control_state_path, + control_state + .as_ref() + .context("Codex control state is unbound")?, + )?; let _ = events.send(ControlEvent::Bound); } - Some(bound) if bound == thread_id => {} - // A dedicated daemon can emit secondary thread starts for review/fork flows. The - // first TUI-owned thread remains the binding; never silently rebind it. - Some(_) => {} + } + + let state = control_state + .as_mut() + .context("Codex control state is unbound")?; + let changed = if message.get("id") == Some(&Value::from(CONTROL_SUBSCRIBE_REQUEST_ID)) { + anyhow::ensure!( + subscription_pending, + "Codex control received an unexpected thread/resume response" + ); + subscription_pending = false; + match state.accept_subscription(&message)? { + SubscriptionAcceptance::Accepted { changed } => changed, + SubscriptionAcceptance::Deferred => false, + } + } else { + state.observe(&message)? + }; + if changed { + atomic_json(control_state_path, state)?; + let _ = events.send(ControlEvent::Observed); + } + if !state.subscribed + && !subscription_pending + && message.get("method").and_then(Value::as_str) == Some("thread/status/changed") + && message.pointer("/params/threadId").and_then(Value::as_str) + == Some(state.thread_id.as_str()) + && matches!( + message + .pointer("/params/status/type") + .and_then(Value::as_str), + Some("idle" | "active") + ) + { + write_json_message( + &mut websocket, + &json!({ + "method": "thread/resume", + "id": CONTROL_SUBSCRIBE_REQUEST_ID, + "params": { "threadId": state.thread_id } + }), + )?; + subscription_pending = true; } } })(); @@ -516,6 +843,29 @@ fn pump_control( } } +fn binding_candidate<'a>( + message: &'a Value, + expected_resume: Option<&str>, +) -> Result> { + match message.get("method").and_then(Value::as_str) { + Some("thread/started") => { + let thread_id = required_string(message, "/params/thread/id", "thread/started")?; + Ok(expected_resume + .is_none_or(|expected| expected == thread_id) + .then_some(thread_id)) + } + Some("thread/status/changed") if expected_resume.is_some() => { + let thread_id = required_string(message, "/params/threadId", "thread/status/changed")?; + let status = required_string(message, "/params/status/type", "thread/status/changed")?; + Ok( + (Some(thread_id) == expected_resume && matches!(status, "idle" | "active")) + .then_some(thread_id), + ) + } + _ => Ok(None), + } +} + fn wait_for_binding( tui: &mut Child, events: &Receiver, @@ -537,6 +887,7 @@ fn wait_for_binding( } match events.recv_timeout(wait) { Ok(ControlEvent::Bound) => return Ok(()), + Ok(ControlEvent::Observed) => {} Ok(ControlEvent::Closed) => { anyhow::bail!("Codex control connection closed before thread binding") } @@ -558,6 +909,7 @@ fn monitor_bound_tui(tui: &mut Child, events: &Receiver) -> Result } match events.recv_timeout(CONTROL_POLL) { Ok(ControlEvent::Bound) => {} + Ok(ControlEvent::Observed) => {} Ok(ControlEvent::Closed) => { anyhow::bail!("Codex control connection closed while the TUI was live") } @@ -724,6 +1076,31 @@ pub fn load_current_binding( Ok(Some(binding)) } +pub fn load_current_control_state( + path: &Path, + runtime: &CodexRuntime, + binding: &CodexThreadBinding, +) -> Result> { + let bytes = match fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + let state: CodexControlState = serde_json::from_slice(&bytes)?; + anyhow::ensure!( + state.schema == CONTROL_STATE_SCHEMA, + "unsupported Codex control-state schema" + ); + anyhow::ensure!( + state.agent == runtime.agent + && state.runtime_id == runtime.runtime_id + && state.runtime_incarnation == runtime.incarnation + && state.thread_id == binding.thread_id, + "Codex control state belongs to a different runtime binding" + ); + Ok(Some(state)) +} + fn load_resume_thread(path: &Path, agent: &str, runtime_id: &str) -> Result> { let bytes = match fs::read(path) { Ok(bytes) => bytes, @@ -816,12 +1193,47 @@ mod tests { assert_eq!(initialized["method"], "initialized"); write_json_message( &mut websocket, - &json!({ "method": "thread/started", "params": { "thread": { "id": "thread-main" } } }), + &json!({ + "method": "thread/started", + "params": { "thread": { "id": "thread-main", "status": { "type": "idle" } } } + }), ) .unwrap(); write_json_message( &mut websocket, - &json!({ "method": "thread/started", "params": { "thread": { "id": "thread-review" } } }), + &json!({ + "method": "thread/status/changed", + "params": { "threadId": "thread-main", "status": { "type": "idle" } } + }), + ) + .unwrap(); + let subscribe = read_json_message(&mut websocket).unwrap().unwrap(); + assert_eq!(subscribe["method"], "thread/resume"); + assert_eq!(subscribe["params"]["threadId"], "thread-main"); + write_json_message( + &mut websocket, + &json!({ + "id": CONTROL_SUBSCRIBE_REQUEST_ID, + "result": { + "thread": { "id": "thread-main", "status": { "type": "idle" } } + } + }), + ) + .unwrap(); + write_json_message( + &mut websocket, + &json!({ + "method": "thread/started", + "params": { "thread": { "id": "thread-review", "status": { "type": "idle" } } } + }), + ) + .unwrap(); + write_json_message( + &mut websocket, + &json!({ + "method": "turn/started", + "params": { "threadId": "thread-main", "turn": { "id": "turn-main" } } + }), ) .unwrap(); }); @@ -831,12 +1243,21 @@ mod tests { let websocket = initialize_control(stream).unwrap(); let state = tmp.path().join("state"); let binding_path = state.join("binding.json"); + let control_state_path = state.join("control-state.json"); let runtime = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(); let (tx, rx) = mpsc::channel(); let runtime_for_pump = runtime.clone(); let binding_for_pump = binding_path.clone(); + let control_state_for_pump = control_state_path.clone(); let pump = thread::spawn(move || { - pump_control(websocket, &binding_for_pump, &runtime_for_pump, None, tx) + pump_control( + websocket, + &binding_for_pump, + &control_state_for_pump, + &runtime_for_pump, + None, + tx, + ) }); assert!(matches!( rx.recv_timeout(Duration::from_secs(2)).unwrap(), @@ -850,6 +1271,17 @@ mod tests { .unwrap() .unwrap(); assert_eq!(binding.thread_id(), "thread-main"); + let state = + load_current_control_state(&state.join("control-state.json"), &runtime, &binding) + .unwrap() + .unwrap(); + assert_eq!( + state.observed(), + &CodexObservedState::Active { + turn_id: "turn-main".into() + } + ); + assert!(state.subscribed()); } #[test] @@ -869,6 +1301,16 @@ mod tests { .unwrap(); let initialized = read_json_message(&mut websocket).unwrap().unwrap(); assert_eq!(initialized["method"], "initialized"); + write_json_message( + &mut websocket, + &json!({ + "method": "thread/started", + "params": { + "thread": { "id": "thread-unrelated", "status": { "type": "idle" } } + } + }), + ) + .unwrap(); write_json_message( &mut websocket, &json!({ @@ -891,20 +1333,36 @@ mod tests { }), ) .unwrap(); + let subscribe = read_json_message(&mut websocket).unwrap().unwrap(); + assert_eq!(subscribe["method"], "thread/resume"); + assert_eq!(subscribe["params"]["threadId"], "thread-prior"); + write_json_message( + &mut websocket, + &json!({ + "id": CONTROL_SUBSCRIBE_REQUEST_ID, + "result": { + "thread": { "id": "thread-prior", "status": { "type": "idle" } } + } + }), + ) + .unwrap(); }); let stream = UnixStream::connect(&socket).unwrap(); let shutdown = stream.try_clone().unwrap(); let websocket = initialize_control(stream).unwrap(); let binding_path = tmp.path().join("state/binding.json"); + let control_state_path = tmp.path().join("state/control-state.json"); let runtime = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(); let (tx, rx) = mpsc::channel(); let runtime_for_pump = runtime.clone(); let binding_for_pump = binding_path.clone(); + let control_state_for_pump = control_state_path.clone(); let pump = thread::spawn(move || { pump_control( websocket, &binding_for_pump, + &control_state_for_pump, &runtime_for_pump, Some("thread-prior"), tx, @@ -922,6 +1380,11 @@ mod tests { .unwrap() .unwrap(); assert_eq!(binding.thread_id(), "thread-prior"); + let state = load_current_control_state(&control_state_path, &runtime, &binding) + .unwrap() + .unwrap(); + assert!(state.subscribed()); + assert_eq!(state.observed(), &CodexObservedState::Idle); } #[test] @@ -944,6 +1407,221 @@ mod tests { assert!(error.to_string().contains("different runtime incarnation")); } + #[test] + fn watcher_holds_without_an_exact_turn_and_tracks_one_unmatched_lifecycle() { + let runtime = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(); + let mut state = CodexControlState::new(&runtime, "thread-main".into()); + + assert!( + state + .observe(&json!({ + "method": "thread/status/changed", + "params": { + "threadId": "thread-main", + "status": { "type": "active", "activeFlags": [] } + } + })) + .unwrap() + ); + assert_eq!( + state.observed(), + &CodexObservedState::Held { + reason: CodexHoldReason::ActiveWithoutTurn, + turn_id: None, + } + ); + + assert!( + state + .observe(&json!({ + "method": "turn/started", + "params": { + "threadId": "thread-main", + "turn": { "id": "turn-1" } + } + })) + .unwrap() + ); + assert_eq!( + state.observed(), + &CodexObservedState::Active { + turn_id: "turn-1".into() + } + ); + + assert!( + !state + .observe(&json!({ + "method": "turn/started", + "params": { + "threadId": "thread-other", + "turn": { "id": "turn-other" } + } + })) + .unwrap() + ); + assert_eq!( + state.observed(), + &CodexObservedState::Active { + turn_id: "turn-1".into() + } + ); + + assert!( + state + .observe(&json!({ + "method": "turn/completed", + "params": { + "threadId": "thread-main", + "turn": { "id": "turn-1" } + } + })) + .unwrap() + ); + assert_eq!(state.observed(), &CodexObservedState::Idle); + } + + #[test] + fn watcher_holds_review_compaction_and_conflicting_turns_until_safe() { + let runtime = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(); + let mut state = CodexControlState::new(&runtime, "thread-main".into()); + + state + .observe(&json!({ + "method": "turn/started", + "params": { "threadId": "thread-main", "turn": { "id": "turn-1" } } + })) + .unwrap(); + state + .observe(&json!({ + "method": "item/started", + "params": { + "threadId": "thread-main", + "turnId": "turn-1", + "item": { "type": "enteredReviewMode" } + } + })) + .unwrap(); + assert_eq!( + state.observed(), + &CodexObservedState::Held { + reason: CodexHoldReason::Review, + turn_id: Some("turn-1".into()), + } + ); + + state + .observe(&json!({ + "method": "thread/status/changed", + "params": { + "threadId": "thread-main", + "status": { "type": "active", "activeFlags": [] } + } + })) + .unwrap(); + assert!(matches!( + state.observed(), + CodexObservedState::Held { + reason: CodexHoldReason::Review, + .. + } + )); + + state + .observe(&json!({ + "method": "turn/started", + "params": { "threadId": "thread-main", "turn": { "id": "turn-2" } } + })) + .unwrap(); + assert_eq!( + state.observed(), + &CodexObservedState::Held { + reason: CodexHoldReason::ConflictingTurn, + turn_id: None, + } + ); + + state + .observe(&json!({ + "method": "thread/status/changed", + "params": { "threadId": "thread-main", "status": { "type": "idle" } } + })) + .unwrap(); + assert_eq!(state.observed(), &CodexObservedState::Idle); + + state + .observe(&json!({ + "method": "turn/started", + "params": { "threadId": "thread-main", "turn": { "id": "turn-3" } } + })) + .unwrap(); + state + .observe(&json!({ + "method": "item/completed", + "params": { + "threadId": "thread-main", + "turnId": "turn-3", + "item": { "type": "contextCompaction" } + } + })) + .unwrap(); + assert_eq!( + state.observed(), + &CodexObservedState::Held { + reason: CodexHoldReason::Compaction, + turn_id: Some("turn-3".into()), + } + ); + } + + #[test] + fn persisted_control_state_is_bound_to_the_exact_runtime_incarnation() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("control-state.json"); + let runtime = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(); + let binding = CodexThreadBinding::new(&runtime, "thread-main".into()); + let mut state = CodexControlState::new(&runtime, "thread-main".into()); + state.observed = CodexObservedState::Active { + turn_id: "turn-1".into(), + }; + atomic_json(&path, &state).unwrap(); + let persisted: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + assert_eq!(persisted["observed"]["turnId"], "turn-1"); + assert!(persisted["observed"].get("turn_id").is_none()); + + assert_eq!( + load_current_control_state(&path, &runtime, &binding) + .unwrap() + .unwrap(), + state + ); + + let replacement = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(); + let replacement_binding = CodexThreadBinding::new(&replacement, "thread-main".into()); + let error = + load_current_control_state(&path, &replacement, &replacement_binding).unwrap_err(); + assert!(error.to_string().contains("different runtime binding")); + } + + #[test] + fn subscription_waits_for_a_rollout_without_claiming_success() { + let runtime = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(); + let mut state = CodexControlState::new(&runtime, "thread-main".into()); + let acceptance = state + .accept_subscription(&json!({ + "id": CONTROL_SUBSCRIBE_REQUEST_ID, + "error": { + "code": -32600, + "message": "no rollout found for thread id thread-main" + } + })) + .unwrap(); + + assert!(matches!(acceptance, SubscriptionAcceptance::Deferred)); + assert!(!state.subscribed()); + assert_eq!(state.observed(), &CodexObservedState::AwaitingStatus); + } + #[test] fn controlled_tui_resumes_a_prior_binding_without_overriding_authored_selection() { let authored = vec!["--model".into(), "gpt-test".into(), "boot".into()]; @@ -983,6 +1661,14 @@ mod tests { "thread-explicit" ] ); + assert_eq!( + expected_resume_thread( + &["resume".into(), "thread-explicit".into()], + Some("thread-prior") + ) + .unwrap(), + None + ); let fork = vec![ "--dangerously-bypass-hook-trust".into(), @@ -999,6 +1685,14 @@ mod tests { "thread-explicit" ] ); + assert_eq!( + expected_resume_thread(&fork, Some("thread-prior")).unwrap(), + None + ); + assert_eq!( + expected_resume_thread(&authored, Some("thread-prior")).unwrap(), + Some("thread-prior") + ); } #[test] From 2614c84d175d001c0a63961f6d2cf51e248e9bfe Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Mon, 10 Aug 2026 19:48:23 +0200 Subject: [PATCH 04/56] Disambiguate Codex response IDs --- src/codex_app_server.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index cb1ffd4f..d5f170e7 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -797,7 +797,9 @@ fn pump_control( let state = control_state .as_mut() .context("Codex control state is unbound")?; - let changed = if message.get("id") == Some(&Value::from(CONTROL_SUBSCRIBE_REQUEST_ID)) { + let changed = if message.get("method").is_none() + && message.get("id") == Some(&Value::from(CONTROL_SUBSCRIBE_REQUEST_ID)) + { anyhow::ensure!( subscription_pending, "Codex control received an unexpected thread/resume response" @@ -1210,6 +1212,17 @@ mod tests { let subscribe = read_json_message(&mut websocket).unwrap().unwrap(); assert_eq!(subscribe["method"], "thread/resume"); assert_eq!(subscribe["params"]["threadId"], "thread-main"); + // JSON-RPC request IDs are per direction. A server request may reuse the client's + // subscription ID and must not be consumed as that client's response. + write_json_message( + &mut websocket, + &json!({ + "id": CONTROL_SUBSCRIBE_REQUEST_ID, + "method": "item/commandExecution/requestApproval", + "params": {} + }), + ) + .unwrap(); write_json_message( &mut websocket, &json!({ From 69c5b7223322d9f2c38876762f31c6429251ed3f Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Mon, 10 Aug 2026 20:14:45 +0200 Subject: [PATCH 05/56] Deliver Codex DINGs through app server --- src/codex_app_server.rs | 646 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 624 insertions(+), 22 deletions(-) diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index d5f170e7..0853c333 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -4,8 +4,10 @@ //! starts a dedicated provider daemon, initializes an observer connection before the interactive //! client starts, and binds a typed start or successful-resume event to the exact wrapper process //! incarnation that owns the PTY launch. Its control watcher persists delivery-relevant thread and -//! turn state. Message selection and delivery remain later layers. +//! turn state. The native delivery layer selects one durable FIFO inbox head and submits typed +//! input only when that state proves an idle or one exact regular active turn. +use std::collections::HashSet; use std::fs::{self, File, OpenOptions}; use std::io::{Read as _, Write}; use std::net::Shutdown; @@ -23,15 +25,19 @@ use anyhow::{Context as _, Result}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sha2::{Digest as _, Sha256}; -use tungstenite::{Message, WebSocket}; +use tungstenite::{Message as WebSocketMessage, WebSocket}; + +use crate::{ding, message, run, status}; pub const SUPPORTED_CODEX_CLI_VERSION: &str = "codex-cli 0.145.0"; const RUNTIME_SCHEMA: &str = "st2.codex-runtime.v1"; const BINDING_SCHEMA: &str = "st2.codex-thread-binding.v1"; const CONTROL_STATE_SCHEMA: &str = "st2.codex-control-state.v1"; const CONTROL_SUBSCRIBE_REQUEST_ID: u64 = 1; +const FIRST_DELIVERY_REQUEST_ID: u64 = 2; const STARTUP_TIMEOUT: Duration = Duration::from_secs(30); const CONTROL_POLL: Duration = Duration::from_millis(100); +const INBOX_REFRESH_FALLBACK: Duration = Duration::from_secs(15); const SOCKET_PATH_BUDGET: usize = 96; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -139,6 +145,239 @@ pub struct CodexControlState { observed: CodexObservedState, } +#[derive(Debug, Clone)] +struct CodexDeliveryConfig { + catalog_root: PathBuf, + agent_dir: PathBuf, + inbox: PathBuf, + identity: String, + this_host: String, +} + +impl CodexDeliveryConfig { + fn resolve(catalog_root: &Path, identity: &str) -> Result { + let this_host = run::detect_host(); + let agent_dir = message::resolve_agent_dir(catalog_root, identity, &this_host)? + .with_context(|| { + format!( + "Codex native delivery agent '{identity}' is not declared in {}", + catalog_root.display() + ) + })?; + Ok(Self { + catalog_root: catalog_root.to_path_buf(), + inbox: message::inbox_dir(&agent_dir), + agent_dir, + identity: identity.to_string(), + this_host, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum CodexDeliveryMethod { + Start, + Steer { turn_id: String }, +} + +#[derive(Debug, Clone)] +struct PendingCodexDelivery { + request_id: u64, + filename: String, + method: CodexDeliveryMethod, +} + +#[derive(Debug, Clone)] +struct RejectedCodexDelivery { + filename: String, + observed: CodexObservedState, +} + +struct CodexInboxDelivery { + config: CodexDeliveryConfig, + wake: Receiver<()>, + _watcher: Option, + next_refresh: Instant, + head: Option, + suppressed: bool, + /// Transport submissions in this process. Typed receipt reconciliation is a later adapter + /// layer; this set only prevents a successful JSON response from becoming a hot resend loop. + submitted_this_run: HashSet, + pending: Option, + rejected: Option, + next_request_id: u64, +} + +impl CodexInboxDelivery { + fn new(config: CodexDeliveryConfig) -> Result { + fs::create_dir_all(&config.inbox).with_context(|| { + format!( + "creating Codex native delivery inbox {}", + config.inbox.display() + ) + })?; + let (wake_tx, wake) = mpsc::channel(); + let watcher = crate::watch::watch_recursive_mutations(&config.agent_dir, wake_tx); + Ok(Self { + config, + wake, + _watcher: watcher, + next_refresh: Instant::now(), + head: None, + suppressed: false, + submitted_this_run: HashSet::new(), + pending: None, + rejected: None, + next_request_id: FIRST_DELIVERY_REQUEST_ID, + }) + } + + fn refresh_if_due(&mut self) -> Result<()> { + let mut due = Instant::now() >= self.next_refresh; + while self.wake.try_recv().is_ok() { + due = true; + } + if !due { + return Ok(()); + } + let unread = message::list_inbox(&self.config.inbox)?; + self.submitted_this_run + .retain(|filename| unread.iter().any(|message| message.filename == *filename)); + if self.rejected.as_ref().is_some_and(|rejected| { + unread + .iter() + .all(|message| message.filename != rejected.filename) + }) { + self.rejected = None; + } + self.head = unread.into_iter().next(); + self.suppressed = + status::read_state(&status::status_path(&self.config.agent_dir)) == status::State::Dnd; + self.next_refresh = Instant::now() + INBOX_REFRESH_FALLBACK; + Ok(()) + } + + fn maybe_request(&mut self, state: &CodexControlState) -> Result> { + self.refresh_if_due()?; + if self.pending.is_some() || !state.subscribed || self.suppressed { + return Ok(None); + } + let Some(head) = self.head.as_ref() else { + return Ok(None); + }; + if self.submitted_this_run.contains(&head.filename) + || self.rejected.as_ref().is_some_and(|rejected| { + rejected.filename == head.filename && rejected.observed == state.observed + }) + { + return Ok(None); + } + let method = match &state.observed { + CodexObservedState::Idle => CodexDeliveryMethod::Start, + CodexObservedState::Active { turn_id } => CodexDeliveryMethod::Steer { + turn_id: turn_id.clone(), + }, + CodexObservedState::AwaitingStatus | CodexObservedState::Held { .. } => { + return Ok(None); + } + }; + let request_id = self.next_request_id; + self.next_request_id = self + .next_request_id + .checked_add(1) + .context("Codex delivery request ID overflow")?; + let client_id = + stable_client_user_message_id(&self.config.identity, state.thread_id(), &head.filename); + let text = ding::poke_text( + &self.config.catalog_root, + &self.config.this_host, + &self.config.identity, + head, + ); + let request = + codex_delivery_request(request_id, state.thread_id(), &client_id, &text, &method); + self.pending = Some(PendingCodexDelivery { + request_id, + filename: head.filename.clone(), + method, + }); + Ok(Some(request)) + } + + fn accept_response(&mut self, message: &Value, observed: &CodexObservedState) -> Result { + let Some(pending) = self.pending.as_ref() else { + return Ok(false); + }; + if message.get("method").is_some() + || message.get("id") != Some(&Value::from(pending.request_id)) + { + return Ok(false); + } + let pending = self + .pending + .take() + .context("Codex delivery is not pending")?; + if message.get("error").is_some() { + self.rejected = Some(RejectedCodexDelivery { + filename: pending.filename, + observed: observed.clone(), + }); + return Ok(true); + } + match &pending.method { + CodexDeliveryMethod::Start => { + required_string(message, "/result/turn/id", "turn/start response")?; + } + CodexDeliveryMethod::Steer { turn_id } => { + let returned = required_string(message, "/result/turnId", "turn/steer response")?; + anyhow::ensure!( + returned == turn_id, + "Codex turn/steer response returned a different turn" + ); + } + } + self.rejected = None; + self.submitted_this_run.insert(pending.filename); + Ok(true) + } +} + +fn stable_client_user_message_id(recipient: &str, thread_id: &str, filename: &str) -> String { + let mut hash = Sha256::new(); + hash.update(b"st2.codex-client-user-message.v1"); + for value in [ + recipient.as_bytes(), + thread_id.as_bytes(), + filename.as_bytes(), + ] { + hash.update((value.len() as u64).to_be_bytes()); + hash.update(value); + } + format!("st2:{:x}", hash.finalize()) +} + +fn codex_delivery_request( + request_id: u64, + thread_id: &str, + client_id: &str, + text: &str, + method: &CodexDeliveryMethod, +) -> Value { + let mut params = json!({ + "threadId": thread_id, + "clientUserMessageId": client_id, + "input": [{ "type": "text", "text": text, "text_elements": [] }] + }); + let method_name = match method { + CodexDeliveryMethod::Start => "turn/start", + CodexDeliveryMethod::Steer { turn_id } => { + params["expectedTurnId"] = Value::String(turn_id.clone()); + "turn/steer" + } + }; + json!({ "method": method_name, "id": request_id, "params": params }) +} + enum SubscriptionAcceptance { Accepted { changed: bool }, Deferred, @@ -298,15 +537,15 @@ impl CodexControlState { self.observed.clone() } CodexObservedState::Held { - reason: CodexHoldReason::Review | CodexHoldReason::Compaction, - turn_id: Some(current), - } if current == &turn_id => self.observed.clone(), + reason: reason @ (CodexHoldReason::Review | CodexHoldReason::Compaction), + .. + } => CodexObservedState::Held { + reason: *reason, + turn_id: Some(turn_id), + }, CodexObservedState::Active { .. } | CodexObservedState::Held { - reason: - CodexHoldReason::Review - | CodexHoldReason::Compaction - | CodexHoldReason::ConflictingTurn, + reason: CodexHoldReason::ConflictingTurn, .. } => CodexObservedState::Held { reason: CodexHoldReason::ConflictingTurn, @@ -389,6 +628,7 @@ pub fn run_controlled( "Codex controlled launch argv is empty" ); ensure_supported_version(&codex_argv[0])?; + let delivery = CodexDeliveryConfig::resolve(catalog_root, &identity)?; let state_dir = state_dir(catalog_root, &identity); secure_dir(&state_dir)?; @@ -463,10 +703,10 @@ pub fn run_controlled( &mut server, &socket_path, &endpoint, - &state_dir, &runtime, &codex_argv, resume_thread.as_deref(), + delivery, ); terminate_child(&mut server); let _ = fs::remove_file(&socket_path); @@ -477,11 +717,12 @@ fn run_connected( server: &mut Child, socket_path: &Path, endpoint: &str, - state_dir: &Path, runtime: &CodexRuntime, codex_argv: &[String], resume_thread: Option<&str>, + delivery: CodexDeliveryConfig, ) -> Result<()> { + let state_dir = state_dir(&delivery.catalog_root, &delivery.identity); let tui_args = controlled_tui_args(endpoint, &codex_argv[1..], resume_thread)?; let expected_resume = expected_resume_thread(&codex_argv[1..], resume_thread)?.map(str::to_owned); @@ -499,6 +740,7 @@ fn run_connected( &control_state_path, &runtime_for_reader, expected_resume.as_deref(), + Some(delivery), events_tx, ) }); @@ -764,15 +1006,30 @@ fn pump_control( control_state_path: &Path, runtime: &CodexRuntime, expected_resume: Option<&str>, + delivery: Option, events: Sender, ) { let result = (|| -> Result<()> { let mut control_state: Option = None; let mut subscription_pending = false; + let mut delivery = delivery.map(CodexInboxDelivery::new).transpose()?; + websocket.get_ref().set_read_timeout(Some(CONTROL_POLL))?; loop { - let Some(message) = read_json_message(&mut websocket)? else { - let _ = events.send(ControlEvent::Closed); - return Ok(()); + let message = match poll_json_message(&mut websocket)? { + ControlRead::Message(message) => Some(message), + ControlRead::Timeout => None, + ControlRead::Closed => { + let _ = events.send(ControlEvent::Closed); + return Ok(()); + } + }; + let Some(message) = message else { + if let (Some(state), Some(delivery)) = (control_state.as_ref(), delivery.as_mut()) + && let Some(request) = delivery.maybe_request(state)? + { + write_json_message(&mut websocket, &request)?; + } + continue; }; if control_state.is_none() { let Some(thread_id) = binding_candidate(&message, expected_resume)? else { @@ -797,7 +1054,13 @@ fn pump_control( let state = control_state .as_mut() .context("Codex control state is unbound")?; - let changed = if message.get("method").is_none() + let delivery_response = match delivery.as_mut() { + Some(delivery) => delivery.accept_response(&message, &state.observed)?, + None => false, + }; + let changed = if delivery_response { + false + } else if message.get("method").is_none() && message.get("id") == Some(&Value::from(CONTROL_SUBSCRIBE_REQUEST_ID)) { anyhow::ensure!( @@ -838,6 +1101,11 @@ fn pump_control( )?; subscription_pending = true; } + if let Some(delivery) = delivery.as_mut() + && let Some(request) = delivery.maybe_request(state)? + { + write_json_message(&mut websocket, &request)?; + } } })(); if let Err(error) = result { @@ -1132,7 +1400,7 @@ fn random_token() -> Result { } fn write_json_message(websocket: &mut WebSocket, value: &Value) -> Result<()> { - websocket.send(Message::Text(value.to_string().into()))?; + websocket.send(WebSocketMessage::Text(value.to_string().into()))?; Ok(()) } @@ -1146,14 +1414,52 @@ fn read_json_message(websocket: &mut WebSocket) -> Result return Err(error.into()), }; match message { - Message::Text(text) => { + WebSocketMessage::Text(text) => { let value = serde_json::from_str(&text) .context("decoding Codex app-server WebSocket JSON")?; return Ok(Some(value)); } - Message::Close(_) => return Ok(None), - Message::Ping(_) | Message::Pong(_) => continue, - Message::Binary(_) | Message::Frame(_) => { + WebSocketMessage::Close(_) => return Ok(None), + WebSocketMessage::Ping(_) | WebSocketMessage::Pong(_) => continue, + WebSocketMessage::Binary(_) | WebSocketMessage::Frame(_) => { + anyhow::bail!("Codex app-server sent a non-text WebSocket message") + } + } + } +} + +enum ControlRead { + Message(Value), + Timeout, + Closed, +} + +fn poll_json_message(websocket: &mut WebSocket) -> Result { + loop { + let message = match websocket.read() { + Ok(message) => message, + Err(tungstenite::Error::ConnectionClosed | tungstenite::Error::AlreadyClosed) => { + return Ok(ControlRead::Closed); + } + Err(tungstenite::Error::Io(error)) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => + { + return Ok(ControlRead::Timeout); + } + Err(error) => return Err(error.into()), + }; + match message { + WebSocketMessage::Text(text) => { + let value = serde_json::from_str(&text) + .context("decoding Codex app-server WebSocket JSON")?; + return Ok(ControlRead::Message(value)); + } + WebSocketMessage::Close(_) => return Ok(ControlRead::Closed), + WebSocketMessage::Ping(_) | WebSocketMessage::Pong(_) => continue, + WebSocketMessage::Binary(_) | WebSocketMessage::Frame(_) => { anyhow::bail!("Codex app-server sent a non-text WebSocket message") } } @@ -1175,6 +1481,300 @@ mod tests { use super::*; use std::os::unix::net::UnixListener; + fn delivery_config(root: &Path) -> CodexDeliveryConfig { + let agent_dir = root.join("agents/h/worker"); + CodexDeliveryConfig { + catalog_root: root.to_path_buf(), + inbox: message::inbox_dir(&agent_dir), + agent_dir, + identity: "h.worker".into(), + this_host: "h".into(), + } + } + + fn subscribed_state(observed: CodexObservedState) -> CodexControlState { + let runtime = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(); + let mut state = CodexControlState::new(&runtime, "thread-main".into()); + state.subscribed = true; + state.observed = observed; + state + } + + #[test] + fn delivery_request_uses_typed_start_and_exact_turn_steer() { + let start = codex_delivery_request( + 2, + "thread-main", + "st2:client", + "notice", + &CodexDeliveryMethod::Start, + ); + assert_eq!(start["method"], "turn/start"); + assert_eq!(start["params"]["threadId"], "thread-main"); + assert_eq!(start["params"]["clientUserMessageId"], "st2:client"); + assert_eq!(start["params"]["input"][0]["type"], "text"); + assert_eq!(start["params"]["input"][0]["text"], "notice"); + assert!(start["params"].get("expectedTurnId").is_none()); + + let steer = codex_delivery_request( + 3, + "thread-main", + "st2:client", + "notice", + &CodexDeliveryMethod::Steer { + turn_id: "turn-current".into(), + }, + ); + assert_eq!(steer["method"], "turn/steer"); + assert_eq!(steer["params"]["expectedTurnId"], "turn-current"); + assert!(steer["params"].get("model").is_none()); + assert!(steer["params"].get("approvalPolicy").is_none()); + } + + #[test] + fn delivery_client_id_is_stable_and_binds_every_identity_component() { + let id = + stable_client_user_message_id("h.worker", "thread-main", "1786380000000-abc123.md"); + assert_eq!( + id, + stable_client_user_message_id("h.worker", "thread-main", "1786380000000-abc123.md") + ); + assert!(id.starts_with("st2:")); + assert_ne!( + id, + stable_client_user_message_id("h.other", "thread-main", "1786380000000-abc123.md") + ); + assert_ne!( + id, + stable_client_user_message_id("h.worker", "thread-other", "1786380000000-abc123.md") + ); + assert_ne!( + id, + stable_client_user_message_id("h.worker", "thread-main", "1786380000000-def456.md") + ); + } + + #[test] + fn review_compaction_and_dnd_hold_the_unread_fifo_head() { + let tmp = tempfile::tempdir().unwrap(); + let config = delivery_config(tmp.path()); + let filename = + message::send_to_inbox(&config.inbox, "h.sender", Some("held"), None, &[], "body") + .unwrap(); + let mut delivery = CodexInboxDelivery::new(config.clone()).unwrap(); + for reason in [CodexHoldReason::Review, CodexHoldReason::Compaction] { + let state = subscribed_state(CodexObservedState::Held { + reason, + turn_id: Some("turn-current".into()), + }); + assert_eq!(delivery.maybe_request(&state).unwrap(), None); + assert!(config.inbox.join(&filename).is_file()); + } + + status::set_state(&status::status_path(&config.agent_dir), status::State::Dnd).unwrap(); + delivery.next_refresh = Instant::now(); + assert_eq!( + delivery + .maybe_request(&subscribed_state(CodexObservedState::Idle)) + .unwrap(), + None + ); + assert_eq!(message::list_inbox(&config.inbox).unwrap().len(), 1); + } + + #[test] + fn a_rejected_exact_steer_has_no_fallback_and_remains_retryable_after_state_changes() { + let tmp = tempfile::tempdir().unwrap(); + let config = delivery_config(tmp.path()); + let filename = + message::send_to_inbox(&config.inbox, "h.sender", Some("retry"), None, &[], "body") + .unwrap(); + let mut delivery = CodexInboxDelivery::new(config.clone()).unwrap(); + let active = subscribed_state(CodexObservedState::Active { + turn_id: "turn-current".into(), + }); + let steer = delivery.maybe_request(&active).unwrap().unwrap(); + assert_eq!(steer["method"], "turn/steer"); + assert_eq!(steer["params"]["expectedTurnId"], "turn-current"); + let request_id = steer["id"].clone(); + let client_id = steer["params"]["clientUserMessageId"].clone(); + + assert!( + !delivery + .accept_response( + &json!({ + "id": request_id, + "method": "item/commandExecution/requestApproval", + "params": {} + }), + active.observed(), + ) + .unwrap() + ); + assert!(delivery + .accept_response( + &json!({ "id": request_id, "error": { "code": -32600, "message": "stale turn" } }), + active.observed(), + ) + .unwrap()); + assert_eq!(delivery.maybe_request(&active).unwrap(), None); + assert!(config.inbox.join(&filename).is_file()); + + let retry = delivery + .maybe_request(&subscribed_state(CodexObservedState::Idle)) + .unwrap() + .unwrap(); + assert_eq!(retry["method"], "turn/start"); + assert_eq!(retry["params"]["clientUserMessageId"], client_id); + assert!(config.inbox.join(&filename).is_file()); + } + + #[test] + fn a_success_response_suppresses_resubmission_without_archiving_the_message() { + let tmp = tempfile::tempdir().unwrap(); + let config = delivery_config(tmp.path()); + let filename = message::send_to_inbox( + &config.inbox, + "h.sender", + Some("submitted"), + None, + &[], + "body", + ) + .unwrap(); + let mut delivery = CodexInboxDelivery::new(config.clone()).unwrap(); + let idle = subscribed_state(CodexObservedState::Idle); + let request = delivery.maybe_request(&idle).unwrap().unwrap(); + assert!( + delivery + .accept_response( + &json!({ "id": request["id"], "result": { "turn": { "id": "turn-new" } } }), + idle.observed(), + ) + .unwrap() + ); + assert_eq!(delivery.maybe_request(&idle).unwrap(), None); + assert!(config.inbox.join(&filename).is_file()); + } + + #[test] + fn subscribed_control_pump_delivers_the_real_fifo_head() { + let tmp = tempfile::tempdir().unwrap(); + let config = delivery_config(tmp.path()); + let filename = + message::send_to_inbox(&config.inbox, "h.sender", Some("wired"), None, &[], "body") + .unwrap(); + let socket = tmp.path().join("server.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let server_filename = filename.clone(); + let server = thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut websocket = tungstenite::accept(stream).unwrap(); + assert_eq!( + read_json_message(&mut websocket).unwrap().unwrap()["method"], + "initialize" + ); + write_json_message( + &mut websocket, + &json!({ "id": 0, "result": { "userAgent": "fake" } }), + ) + .unwrap(); + assert_eq!( + read_json_message(&mut websocket).unwrap().unwrap()["method"], + "initialized" + ); + write_json_message( + &mut websocket, + &json!({ + "method": "thread/started", + "params": { "thread": { "id": "thread-main", "status": { "type": "idle" } } } + }), + ) + .unwrap(); + write_json_message( + &mut websocket, + &json!({ + "method": "thread/status/changed", + "params": { "threadId": "thread-main", "status": { "type": "idle" } } + }), + ) + .unwrap(); + let subscribe = read_json_message(&mut websocket).unwrap().unwrap(); + assert_eq!(subscribe["method"], "thread/resume"); + write_json_message( + &mut websocket, + &json!({ + "id": CONTROL_SUBSCRIBE_REQUEST_ID, + "result": { + "thread": { "id": "thread-main", "status": { "type": "idle" } } + } + }), + ) + .unwrap(); + let delivery = read_json_message(&mut websocket).unwrap().unwrap(); + assert_eq!(delivery["id"], FIRST_DELIVERY_REQUEST_ID); + assert_eq!(delivery["method"], "turn/start"); + assert_eq!(delivery["params"]["threadId"], "thread-main"); + assert_eq!( + delivery["params"]["input"][0]["text"], + "[DING] ? h.sender: wired [id:".to_owned() + + server_filename + .trim_end_matches(".md") + .rsplit_once('-') + .unwrap() + .1 + + "]" + ); + assert!( + delivery["params"]["clientUserMessageId"] + .as_str() + .unwrap() + .starts_with("st2:") + ); + write_json_message( + &mut websocket, + &json!({ + "id": FIRST_DELIVERY_REQUEST_ID, + "result": { "turn": { "id": "turn-delivery" } } + }), + ) + .unwrap(); + }); + + let stream = UnixStream::connect(&socket).unwrap(); + let shutdown = stream.try_clone().unwrap(); + let websocket = initialize_control(stream).unwrap(); + let binding_path = tmp.path().join("state/binding.json"); + let control_state_path = tmp.path().join("state/control-state.json"); + let runtime = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(); + let (tx, rx) = mpsc::channel(); + let runtime_for_pump = runtime.clone(); + let binding_for_pump = binding_path.clone(); + let control_state_for_pump = control_state_path.clone(); + let pump = thread::spawn(move || { + pump_control( + websocket, + &binding_for_pump, + &control_state_for_pump, + &runtime_for_pump, + None, + Some(config), + tx, + ) + }); + assert!(matches!( + rx.recv_timeout(Duration::from_secs(2)).unwrap(), + ControlEvent::Bound + )); + server.join().unwrap(); + let _ = shutdown.shutdown(Shutdown::Both); + pump.join().unwrap(); + assert!(delivery_config(tmp.path()).inbox.join(filename).is_file()); + } + #[test] fn control_initializes_before_recording_the_first_thread_only() { let tmp = tempfile::tempdir().unwrap(); @@ -1269,6 +1869,7 @@ mod tests { &control_state_for_pump, &runtime_for_pump, None, + None, tx, ) }); @@ -1378,6 +1979,7 @@ mod tests { &control_state_for_pump, &runtime_for_pump, Some("thread-prior"), + None, tx, ) }); @@ -1549,8 +2151,8 @@ mod tests { assert_eq!( state.observed(), &CodexObservedState::Held { - reason: CodexHoldReason::ConflictingTurn, - turn_id: None, + reason: CodexHoldReason::Review, + turn_id: Some("turn-2".into()), } ); From ede292619160cc0e337c7f917d8c4f539cf62156 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Mon, 10 Aug 2026 20:23:56 +0200 Subject: [PATCH 06/56] Keep Codex delivery held through review completion --- src/codex_app_server.rs | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index 0853c333..e805e502 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -563,8 +563,8 @@ impl CodexControlState { } CodexObservedState::Held { reason: CodexHoldReason::Review | CodexHoldReason::Compaction, - turn_id: Some(current), - } if current == turn_id => CodexObservedState::Idle, + .. + } => self.observed.clone(), CodexObservedState::AwaitingStatus | CodexObservedState::Held { reason: CodexHoldReason::ActiveWithoutTurn, @@ -2156,6 +2156,24 @@ mod tests { } ); + // Codex can complete the preparatory review turn after the reviewer turn starts. The + // review hold survives that stale completion and ends only when the thread is idle. + assert!( + !state + .observe(&json!({ + "method": "turn/completed", + "params": { "threadId": "thread-main", "turn": { "id": "turn-1" } } + })) + .unwrap() + ); + assert_eq!( + state.observed(), + &CodexObservedState::Held { + reason: CodexHoldReason::Review, + turn_id: Some("turn-2".into()), + } + ); + state .observe(&json!({ "method": "thread/status/changed", @@ -2187,6 +2205,21 @@ mod tests { turn_id: Some("turn-3".into()), } ); + assert!( + !state + .observe(&json!({ + "method": "turn/completed", + "params": { "threadId": "thread-main", "turn": { "id": "turn-3" } } + })) + .unwrap() + ); + assert!(matches!( + state.observed(), + CodexObservedState::Held { + reason: CodexHoldReason::Compaction, + .. + } + )); } #[test] From c05a80b3145508c4c4374a28a5703009de23effb Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Mon, 10 Aug 2026 20:31:35 +0200 Subject: [PATCH 07/56] Keep duplicate Codex hold events typed --- src/codex_app_server.rs | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index e805e502..10c7aa0f 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -591,10 +591,10 @@ impl CodexControlState { } CodexObservedState::Held { reason: current_reason, - turn_id: Some(current), - } if current == turn_id + .. + } if current_reason == &reason && matches!( - current_reason, + reason, CodexHoldReason::Review | CodexHoldReason::Compaction ) => { @@ -2156,8 +2156,30 @@ mod tests { } ); - // Codex can complete the preparatory review turn after the reviewer turn starts. The - // review hold survives that stale completion and ends only when the thread is idle. + // Codex can complete the preparatory review item after the reviewer turn starts. That + // duplicate review event keeps the typed hold bound to the newer turn. + assert!( + !state + .observe(&json!({ + "method": "item/completed", + "params": { + "threadId": "thread-main", + "turnId": "turn-1", + "item": { "type": "enteredReviewMode" } + } + })) + .unwrap() + ); + assert_eq!( + state.observed(), + &CodexObservedState::Held { + reason: CodexHoldReason::Review, + turn_id: Some("turn-2".into()), + } + ); + + // The review hold also survives the stale turn completion. Only an idle thread releases + // it. assert!( !state .observe(&json!({ From 89799780444896098e124deb75720c0e434d2d8c Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Mon, 10 Aug 2026 20:37:59 +0200 Subject: [PATCH 08/56] Trust typed Codex review holds --- src/codex_app_server.rs | 55 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index 10c7aa0f..5d5d69a4 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -600,6 +600,16 @@ impl CodexControlState { { self.observed.clone() } + _ if matches!( + reason, + CodexHoldReason::Review | CodexHoldReason::Compaction + ) => + { + CodexObservedState::Held { + reason, + turn_id: Some(turn_id.to_string()), + } + } _ => CodexObservedState::Held { reason: CodexHoldReason::ConflictingTurn, turn_id: None, @@ -2204,6 +2214,51 @@ mod tests { .unwrap(); assert_eq!(state.observed(), &CodexObservedState::Idle); + // A real review can start its reviewer turn before Codex reports the preparatory turn's + // typed review item. The typed non-steerable event refines that generic conflict. + state + .observe(&json!({ + "method": "turn/started", + "params": { "threadId": "thread-main", "turn": { "id": "turn-late-1" } } + })) + .unwrap(); + state + .observe(&json!({ + "method": "turn/started", + "params": { "threadId": "thread-main", "turn": { "id": "turn-late-2" } } + })) + .unwrap(); + assert!(matches!( + state.observed(), + CodexObservedState::Held { + reason: CodexHoldReason::ConflictingTurn, + .. + } + )); + state + .observe(&json!({ + "method": "item/started", + "params": { + "threadId": "thread-main", + "turnId": "turn-late-1", + "item": { "type": "enteredReviewMode" } + } + })) + .unwrap(); + assert_eq!( + state.observed(), + &CodexObservedState::Held { + reason: CodexHoldReason::Review, + turn_id: Some("turn-late-1".into()), + } + ); + state + .observe(&json!({ + "method": "thread/status/changed", + "params": { "threadId": "thread-main", "status": { "type": "idle" } } + })) + .unwrap(); + state .observe(&json!({ "method": "turn/started", From 538de2f7753dc7d40e8cf6be196d7ff65c0a7566 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Tue, 11 Aug 2026 16:33:49 +0200 Subject: [PATCH 09/56] Keep Codex transport test payload-neutral --- src/codex_app_server.rs | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index 5d5d69a4..c281f627 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -1668,7 +1668,7 @@ mod tests { } #[test] - fn subscribed_control_pump_delivers_the_real_fifo_head() { + fn subscribed_control_pump_delivers_a_typed_reference_to_the_real_fifo_head() { let tmp = tempfile::tempdir().unwrap(); let config = delivery_config(tmp.path()); let filename = @@ -1728,21 +1728,21 @@ mod tests { assert_eq!(delivery["id"], FIRST_DELIVERY_REQUEST_ID); assert_eq!(delivery["method"], "turn/start"); assert_eq!(delivery["params"]["threadId"], "thread-main"); - assert_eq!( - delivery["params"]["input"][0]["text"], - "[DING] ? h.sender: wired [id:".to_owned() - + server_filename - .trim_end_matches(".md") - .rsplit_once('-') - .unwrap() - .1 - + "]" - ); + let head_id = server_filename + .trim_end_matches(".md") + .rsplit_once('-') + .unwrap() + .1; assert!( - delivery["params"]["clientUserMessageId"] + delivery["params"]["input"][0]["text"] .as_str() .unwrap() - .starts_with("st2:") + .contains(head_id), + "the transport payload must identify the actionable FIFO head" + ); + assert_eq!( + delivery["params"]["clientUserMessageId"], + stable_client_user_message_id("h.worker", "thread-main", &server_filename) ); write_json_message( &mut websocket, From 3e996689e9877bbbb8188218847e8388d94ed92b Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Mon, 10 Aug 2026 21:18:14 +0200 Subject: [PATCH 10/56] Persist typed Codex delivery receipts --- src/codex_app_server.rs | 651 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 629 insertions(+), 22 deletions(-) diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index c281f627..84d20cfe 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -7,7 +7,6 @@ //! turn state. The native delivery layer selects one durable FIFO inbox head and submits typed //! input only when that state proves an idle or one exact regular active turn. -use std::collections::HashSet; use std::fs::{self, File, OpenOptions}; use std::io::{Read as _, Write}; use std::net::Shutdown; @@ -33,6 +32,7 @@ pub const SUPPORTED_CODEX_CLI_VERSION: &str = "codex-cli 0.145.0"; const RUNTIME_SCHEMA: &str = "st2.codex-runtime.v1"; const BINDING_SCHEMA: &str = "st2.codex-thread-binding.v1"; const CONTROL_STATE_SCHEMA: &str = "st2.codex-control-state.v1"; +const DELIVERY_STATE_SCHEMA: &str = "st2.codex-delivery-state.v1"; const CONTROL_SUBSCRIBE_REQUEST_ID: u64 = 1; const FIRST_DELIVERY_REQUEST_ID: u64 = 2; const STARTUP_TIMEOUT: Duration = Duration::from_secs(30); @@ -187,6 +187,52 @@ struct PendingCodexDelivery { method: CodexDeliveryMethod, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +enum CodexDeliveryPhase { + Attempted, + Accepted, +} + +/// One durable FIFO delivery attempt. +/// +/// `Attempted` is written before transport. A replacement control connection reconciles that +/// ambiguous attempt against the resumed thread before it may send the client ID again. `Accepted` +/// is written only after the exact completed typed user-message event and remains until normal +/// message archive precedence removes the inbox entry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CodexDeliveryState { + schema: String, + agent: String, + runtime_id: String, + runtime_incarnation: String, + thread_id: String, + filename: String, + client_id: String, + phase: CodexDeliveryPhase, +} + +impl CodexDeliveryState { + fn attempted( + runtime: &CodexRuntime, + thread_id: String, + filename: String, + client_id: String, + ) -> Self { + Self { + schema: DELIVERY_STATE_SCHEMA.to_string(), + agent: runtime.agent.clone(), + runtime_id: runtime.runtime_id.clone(), + runtime_incarnation: runtime.incarnation.clone(), + thread_id, + filename, + client_id, + phase: CodexDeliveryPhase::Attempted, + } + } +} + #[derive(Debug, Clone)] struct RejectedCodexDelivery { filename: String, @@ -195,21 +241,25 @@ struct RejectedCodexDelivery { struct CodexInboxDelivery { config: CodexDeliveryConfig, + state_path: PathBuf, + runtime: CodexRuntime, wake: Receiver<()>, _watcher: Option, next_refresh: Instant, head: Option, suppressed: bool, - /// Transport submissions in this process. Typed receipt reconciliation is a later adapter - /// layer; this set only prevents a successful JSON response from becoming a hot resend loop. - submitted_this_run: HashSet, + state: Option, pending: Option, rejected: Option, next_request_id: u64, } impl CodexInboxDelivery { - fn new(config: CodexDeliveryConfig) -> Result { + fn new( + config: CodexDeliveryConfig, + state_path: PathBuf, + runtime: CodexRuntime, + ) -> Result { fs::create_dir_all(&config.inbox).with_context(|| { format!( "creating Codex native delivery inbox {}", @@ -218,20 +268,35 @@ impl CodexInboxDelivery { })?; let (wake_tx, wake) = mpsc::channel(); let watcher = crate::watch::watch_recursive_mutations(&config.agent_dir, wake_tx); + let state = load_delivery_state(&state_path, &config.identity, runtime.runtime_id())?; Ok(Self { config, + state_path, + runtime, wake, _watcher: watcher, next_refresh: Instant::now(), head: None, suppressed: false, - submitted_this_run: HashSet::new(), + state, pending: None, rejected: None, next_request_id: FIRST_DELIVERY_REQUEST_ID, }) } + fn write_state(&mut self, state: CodexDeliveryState) -> Result<()> { + atomic_json(&self.state_path, &state)?; + self.state = Some(state); + Ok(()) + } + + fn clear_state(&mut self) -> Result<()> { + remove_state_file(&self.state_path)?; + self.state = None; + Ok(()) + } + fn refresh_if_due(&mut self) -> Result<()> { let mut due = Instant::now() >= self.next_refresh; while self.wake.try_recv().is_ok() { @@ -241,8 +306,13 @@ impl CodexInboxDelivery { return Ok(()); } let unread = message::list_inbox(&self.config.inbox)?; - self.submitted_this_run - .retain(|filename| unread.iter().any(|message| message.filename == *filename)); + if self.state.as_ref().is_some_and(|state| { + unread + .iter() + .all(|message| message.filename != state.filename) + }) { + self.clear_state()?; + } if self.rejected.as_ref().is_some_and(|rejected| { unread .iter() @@ -262,14 +332,20 @@ impl CodexInboxDelivery { if self.pending.is_some() || !state.subscribed || self.suppressed { return Ok(None); } + if let Some(delivery_state) = self.state.as_ref() { + if delivery_state.thread_id == state.thread_id { + return Ok(None); + } + // A newly selected thread is a different delivery binding. An old binding's receipt + // must neither suppress nor acknowledge delivery to this thread. + self.clear_state()?; + } let Some(head) = self.head.as_ref() else { return Ok(None); }; - if self.submitted_this_run.contains(&head.filename) - || self.rejected.as_ref().is_some_and(|rejected| { - rejected.filename == head.filename && rejected.observed == state.observed - }) - { + if self.rejected.as_ref().is_some_and(|rejected| { + rejected.filename == head.filename && rejected.observed == state.observed + }) { return Ok(None); } let method = match &state.observed { @@ -288,6 +364,7 @@ impl CodexInboxDelivery { .context("Codex delivery request ID overflow")?; let client_id = stable_client_user_message_id(&self.config.identity, state.thread_id(), &head.filename); + let filename = head.filename.clone(); let text = ding::poke_text( &self.config.catalog_root, &self.config.this_host, @@ -296,9 +373,15 @@ impl CodexInboxDelivery { ); let request = codex_delivery_request(request_id, state.thread_id(), &client_id, &text, &method); + self.write_state(CodexDeliveryState::attempted( + &self.runtime, + state.thread_id().to_string(), + filename.clone(), + client_id, + ))?; self.pending = Some(PendingCodexDelivery { request_id, - filename: head.filename.clone(), + filename, method, }); Ok(Some(request)) @@ -318,6 +401,13 @@ impl CodexInboxDelivery { .take() .context("Codex delivery is not pending")?; if message.get("error").is_some() { + if !self + .state + .as_ref() + .is_some_and(|state| state.phase == CodexDeliveryPhase::Accepted) + { + self.clear_state()?; + } self.rejected = Some(RejectedCodexDelivery { filename: pending.filename, observed: observed.clone(), @@ -337,9 +427,123 @@ impl CodexInboxDelivery { } } self.rejected = None; - self.submitted_this_run.insert(pending.filename); Ok(true) } + + fn accept_typed_receipt(&mut self, message: &Value, state: &CodexControlState) -> Result { + if message.get("method").and_then(Value::as_str) != Some("item/completed") + || message.pointer("/params/item/type").and_then(Value::as_str) != Some("userMessage") + { + return Ok(false); + } + let Some(delivery_state) = self.state.as_ref() else { + return Ok(false); + }; + if message.pointer("/params/threadId").and_then(Value::as_str) != Some(state.thread_id()) + || delivery_state.thread_id != state.thread_id() + || delivery_state.runtime_incarnation != self.runtime.incarnation() + || state.runtime_incarnation != self.runtime.incarnation() + || message + .pointer("/params/item/clientId") + .and_then(Value::as_str) + != Some(delivery_state.client_id.as_str()) + { + return Ok(false); + } + if delivery_state.phase == CodexDeliveryPhase::Accepted { + return Ok(true); + } + let mut accepted = delivery_state.clone(); + accepted.phase = CodexDeliveryPhase::Accepted; + self.write_state(accepted)?; + Ok(true) + } + + /// Reconcile a pre-crash attempt against the typed history returned by `thread/resume` before + /// the same client ID can be sent again. + fn reconcile_resume(&mut self, message: &Value, state: &CodexControlState) -> Result<()> { + if message.get("error").is_some() { + return Ok(()); + } + let Some(delivery_state) = self.state.as_ref() else { + return Ok(()); + }; + if delivery_state.thread_id != state.thread_id() + || delivery_state.phase == CodexDeliveryPhase::Accepted + { + return Ok(()); + } + let turns = message + .pointer("/result/thread/turns") + .and_then(Value::as_array) + .context( + "Codex thread/resume response has no typed turn history for delivery recovery", + )?; + let accepted = turns.iter().any(|turn| { + turn.get("items") + .and_then(Value::as_array) + .is_some_and(|items| { + items.iter().any(|item| { + item.get("type").and_then(Value::as_str) == Some("userMessage") + && item.get("clientId").and_then(Value::as_str) + == Some(delivery_state.client_id.as_str()) + }) + }) + }); + if accepted { + let mut state = delivery_state.clone(); + state.phase = CodexDeliveryPhase::Accepted; + self.write_state(state) + } else { + self.clear_state() + } + } +} + +fn load_delivery_state( + path: &Path, + identity: &str, + runtime_id: &str, +) -> Result> { + let bytes = match fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + let state: CodexDeliveryState = serde_json::from_slice(&bytes) + .with_context(|| format!("reading Codex delivery state {}", path.display()))?; + anyhow::ensure!( + state.schema == DELIVERY_STATE_SCHEMA, + "Codex delivery state has unsupported schema '{}'", + state.schema + ); + anyhow::ensure!( + state.agent == identity && state.runtime_id == runtime_id, + "Codex delivery state belongs to a different runtime" + ); + anyhow::ensure!( + !state.runtime_incarnation.is_empty() + && !state.thread_id.is_empty() + && message::is_message_filename(&state.filename), + "Codex delivery state has an invalid runtime binding or filename" + ); + anyhow::ensure!( + state.client_id + == stable_client_user_message_id(identity, &state.thread_id, &state.filename), + "Codex delivery state client ID does not match its binding" + ); + Ok(Some(state)) +} + +fn remove_state_file(path: &Path) -> Result<()> { + match fs::remove_file(path) { + Ok(()) => { + File::open(path.parent().context("state file has no parent")?)?.sync_all()?; + Ok(()) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } } fn stable_client_user_message_id(recipient: &str, thread_id: &str, filename: &str) -> String { @@ -1022,7 +1226,12 @@ fn pump_control( let result = (|| -> Result<()> { let mut control_state: Option = None; let mut subscription_pending = false; - let mut delivery = delivery.map(CodexInboxDelivery::new).transpose()?; + let delivery_state_path = control_state_path.with_file_name("delivery-state.json"); + let mut delivery = delivery + .map(|config| { + CodexInboxDelivery::new(config, delivery_state_path.clone(), runtime.clone()) + }) + .transpose()?; websocket.get_ref().set_read_timeout(Some(CONTROL_POLL))?; loop { let message = match poll_json_message(&mut websocket)? { @@ -1065,7 +1274,10 @@ fn pump_control( .as_mut() .context("Codex control state is unbound")?; let delivery_response = match delivery.as_mut() { - Some(delivery) => delivery.accept_response(&message, &state.observed)?, + Some(delivery) => { + delivery.accept_response(&message, &state.observed)? + || delivery.accept_typed_receipt(&message, state)? + } None => false, }; let changed = if delivery_response { @@ -1079,7 +1291,12 @@ fn pump_control( ); subscription_pending = false; match state.accept_subscription(&message)? { - SubscriptionAcceptance::Accepted { changed } => changed, + SubscriptionAcceptance::Accepted { changed } => { + if let Some(delivery) = delivery.as_mut() { + delivery.reconcile_resume(&message, state)?; + } + changed + } SubscriptionAcceptance::Deferred => false, } } else { @@ -1510,6 +1727,15 @@ mod tests { state } + fn inbox_delivery(root: &Path, config: CodexDeliveryConfig) -> CodexInboxDelivery { + CodexInboxDelivery::new( + config, + root.join("state/delivery-state.json"), + CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(), + ) + .unwrap() + } + #[test] fn delivery_request_uses_typed_start_and_exact_turn_steer() { let start = codex_delivery_request( @@ -1571,7 +1797,7 @@ mod tests { let filename = message::send_to_inbox(&config.inbox, "h.sender", Some("held"), None, &[], "body") .unwrap(); - let mut delivery = CodexInboxDelivery::new(config.clone()).unwrap(); + let mut delivery = inbox_delivery(tmp.path(), config.clone()); for reason in [CodexHoldReason::Review, CodexHoldReason::Compaction] { let state = subscribed_state(CodexObservedState::Held { reason, @@ -1599,7 +1825,7 @@ mod tests { let filename = message::send_to_inbox(&config.inbox, "h.sender", Some("retry"), None, &[], "body") .unwrap(); - let mut delivery = CodexInboxDelivery::new(config.clone()).unwrap(); + let mut delivery = inbox_delivery(tmp.path(), config.clone()); let active = subscribed_state(CodexObservedState::Active { turn_id: "turn-current".into(), }); @@ -1640,7 +1866,7 @@ mod tests { } #[test] - fn a_success_response_suppresses_resubmission_without_archiving_the_message() { + fn a_success_response_is_only_an_attempt_and_does_not_archive_the_message() { let tmp = tempfile::tempdir().unwrap(); let config = delivery_config(tmp.path()); let filename = message::send_to_inbox( @@ -1652,9 +1878,14 @@ mod tests { "body", ) .unwrap(); - let mut delivery = CodexInboxDelivery::new(config.clone()).unwrap(); + let mut delivery = inbox_delivery(tmp.path(), config.clone()); let idle = subscribed_state(CodexObservedState::Idle); let request = delivery.maybe_request(&idle).unwrap().unwrap(); + assert_eq!( + delivery.state.as_ref().unwrap().phase, + CodexDeliveryPhase::Attempted, + "submission ownership is durable before transport" + ); assert!( delivery .accept_response( @@ -1663,10 +1894,222 @@ mod tests { ) .unwrap() ); + assert_eq!( + delivery.state.as_ref().unwrap().phase, + CodexDeliveryPhase::Attempted, + "JSON success is not typed acceptance" + ); assert_eq!(delivery.maybe_request(&idle).unwrap(), None); assert!(config.inbox.join(&filename).is_file()); } + #[test] + fn only_a_completed_matching_user_message_persists_acceptance() { + let tmp = tempfile::tempdir().unwrap(); + let config = delivery_config(tmp.path()); + let filename = message::send_to_inbox( + &config.inbox, + "h.sender", + Some("receipt"), + None, + &[], + "body", + ) + .unwrap(); + let state_path = tmp.path().join("state/delivery-state.json"); + let mut delivery = inbox_delivery(tmp.path(), config.clone()); + let mut idle = CodexControlState::new(&delivery.runtime, "thread-main".into()); + idle.subscribed = true; + idle.observed = CodexObservedState::Idle; + let request = delivery.maybe_request(&idle).unwrap().unwrap(); + let client_id = request["params"]["clientUserMessageId"] + .as_str() + .unwrap() + .to_string(); + + assert!( + !delivery + .accept_typed_receipt( + &json!({ + "method": "item/started", + "params": { + "threadId": "thread-main", + "turnId": "turn-delivery", + "item": { "type": "userMessage", "clientId": client_id } + } + }), + &idle, + ) + .unwrap(), + "item/started is progress, not acceptance" + ); + assert!( + !delivery + .accept_typed_receipt( + &json!({ + "method": "item/completed", + "params": { + "threadId": "thread-other", + "turnId": "turn-delivery", + "item": { "type": "userMessage", "clientId": client_id } + } + }), + &idle, + ) + .unwrap(), + "another thread cannot acknowledge this delivery" + ); + assert!( + delivery + .accept_typed_receipt( + &json!({ + "method": "item/completed", + "params": { + "threadId": "thread-main", + "turnId": "turn-delivery", + "item": { "type": "userMessage", "clientId": client_id } + } + }), + &idle, + ) + .unwrap() + ); + assert_eq!( + load_delivery_state(&state_path, "h.worker", "h.worker") + .unwrap() + .unwrap() + .phase, + CodexDeliveryPhase::Accepted + ); + assert!(config.inbox.join(&filename).is_file()); + + drop(delivery); + let mut replacement = inbox_delivery(tmp.path(), config.clone()); + assert_eq!( + replacement.maybe_request(&idle).unwrap(), + None, + "a fresh runtime incarnation restores accepted duplicate control" + ); + + message::archive_msg( + &config.inbox, + &message::archive_dir(&config.agent_dir), + &filename, + ) + .unwrap(); + replacement.next_refresh = Instant::now(); + assert_eq!(replacement.maybe_request(&idle).unwrap(), None); + assert!( + !state_path.exists(), + "archive precedence clears the receipt" + ); + } + + #[test] + fn an_ambiguous_attempt_reconciles_resume_history_before_retry() { + let tmp = tempfile::tempdir().unwrap(); + let config = delivery_config(tmp.path()); + let filename = message::send_to_inbox( + &config.inbox, + "h.sender", + Some("reconcile"), + None, + &[], + "body", + ) + .unwrap(); + let idle = subscribed_state(CodexObservedState::Idle); + let mut first = inbox_delivery(tmp.path(), config.clone()); + let request = first.maybe_request(&idle).unwrap().unwrap(); + let client_id = request["params"]["clientUserMessageId"] + .as_str() + .unwrap() + .to_string(); + drop(first); + + let mut recovered = inbox_delivery(tmp.path(), config.clone()); + assert_eq!(recovered.maybe_request(&idle).unwrap(), None); + recovered + .reconcile_resume( + &json!({ + "id": CONTROL_SUBSCRIBE_REQUEST_ID, + "result": { + "thread": { + "id": "thread-main", + "turns": [{ + "id": "turn-delivery", + "items": [{ + "type": "userMessage", + "id": "item-delivery", + "clientId": client_id, + "content": [] + }] + }] + } + } + }), + &idle, + ) + .unwrap(); + assert_eq!( + recovered.state.as_ref().unwrap().phase, + CodexDeliveryPhase::Accepted + ); + assert_eq!(recovered.maybe_request(&idle).unwrap(), None); + assert!(config.inbox.join(&filename).is_file()); + + // An authoritative resumed history without the client ID proves that the pre-send record + // did not reach typed acceptance. Only then may the same stable ID be retried. + recovered.state.as_mut().unwrap().phase = CodexDeliveryPhase::Attempted; + atomic_json( + &tmp.path().join("state/delivery-state.json"), + recovered.state.as_ref().unwrap(), + ) + .unwrap(); + recovered + .reconcile_resume( + &json!({ + "id": CONTROL_SUBSCRIBE_REQUEST_ID, + "result": { "thread": { "id": "thread-main", "turns": [] } } + }), + &idle, + ) + .unwrap(); + assert!(recovered.state.is_none()); + let retry = recovered.maybe_request(&idle).unwrap().unwrap(); + assert_eq!(retry["params"]["clientUserMessageId"], client_id); + } + + #[test] + fn malformed_delivery_state_fails_closed() { + let tmp = tempfile::tempdir().unwrap(); + let config = delivery_config(tmp.path()); + let state_path = tmp.path().join("state/delivery-state.json"); + atomic_json( + &state_path, + &json!({ + "schema": DELIVERY_STATE_SCHEMA, + "agent": "h.worker", + "runtimeId": "h.worker", + "runtimeIncarnation": "incarnation-test", + "threadId": "thread-main", + "filename": "1786380000000-abc123.md", + "clientId": "st2:tampered", + "phase": "attempted" + }), + ) + .unwrap(); + let error = match CodexInboxDelivery::new( + config, + state_path, + CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(), + ) { + Ok(_) => panic!("accepted malformed delivery state"), + Err(error) => error, + }; + assert!(error.to_string().contains("client ID does not match")); + } + #[test] fn subscribed_control_pump_delivers_a_typed_reference_to_the_real_fifo_head() { let tmp = tempfile::tempdir().unwrap(); @@ -1744,6 +2187,10 @@ mod tests { delivery["params"]["clientUserMessageId"], stable_client_user_message_id("h.worker", "thread-main", &server_filename) ); + let client_id = delivery["params"]["clientUserMessageId"] + .as_str() + .unwrap() + .to_string(); write_json_message( &mut websocket, &json!({ @@ -1752,6 +2199,23 @@ mod tests { }), ) .unwrap(); + write_json_message( + &mut websocket, + &json!({ + "method": "item/completed", + "params": { + "threadId": "thread-main", + "turnId": "turn-delivery", + "item": { + "type": "userMessage", + "id": "item-delivery", + "clientId": client_id, + "content": [] + } + } + }), + ) + .unwrap(); }); let stream = UnixStream::connect(&socket).unwrap(); @@ -1783,6 +2247,149 @@ mod tests { let _ = shutdown.shutdown(Shutdown::Both); pump.join().unwrap(); assert!(delivery_config(tmp.path()).inbox.join(filename).is_file()); + assert_eq!( + load_delivery_state( + &tmp.path().join("state/delivery-state.json"), + "h.worker", + "h.worker", + ) + .unwrap() + .unwrap() + .phase, + CodexDeliveryPhase::Accepted + ); + } + + #[test] + fn subscribed_control_pump_reconciles_an_ambiguous_attempt_without_replay() { + let tmp = tempfile::tempdir().unwrap(); + let config = delivery_config(tmp.path()); + let filename = message::send_to_inbox( + &config.inbox, + "h.sender", + Some("recover"), + None, + &[], + "body", + ) + .unwrap(); + let client_id = stable_client_user_message_id("h.worker", "thread-main", &filename); + let prior_runtime = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(); + let delivery_state_path = tmp.path().join("state/delivery-state.json"); + atomic_json( + &delivery_state_path, + &CodexDeliveryState::attempted( + &prior_runtime, + "thread-main".into(), + filename.clone(), + client_id.clone(), + ), + ) + .unwrap(); + + let socket = tmp.path().join("server.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let server_client_id = client_id.clone(); + let server = thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_millis(500))) + .unwrap(); + let mut websocket = tungstenite::accept(stream).unwrap(); + assert_eq!( + read_json_message(&mut websocket).unwrap().unwrap()["method"], + "initialize" + ); + write_json_message( + &mut websocket, + &json!({ "id": 0, "result": { "userAgent": "fake" } }), + ) + .unwrap(); + assert_eq!( + read_json_message(&mut websocket).unwrap().unwrap()["method"], + "initialized" + ); + write_json_message( + &mut websocket, + &json!({ + "method": "thread/started", + "params": { "thread": { "id": "thread-main", "status": { "type": "idle" } } } + }), + ) + .unwrap(); + write_json_message( + &mut websocket, + &json!({ + "method": "thread/status/changed", + "params": { "threadId": "thread-main", "status": { "type": "idle" } } + }), + ) + .unwrap(); + let subscribe = read_json_message(&mut websocket).unwrap().unwrap(); + assert_eq!(subscribe["method"], "thread/resume"); + write_json_message( + &mut websocket, + &json!({ + "id": CONTROL_SUBSCRIBE_REQUEST_ID, + "result": { + "thread": { + "id": "thread-main", + "status": { "type": "idle" }, + "turns": [{ + "id": "turn-delivery", + "items": [{ + "type": "userMessage", + "id": "item-delivery", + "clientId": server_client_id, + "content": [] + }] + }] + } + } + }), + ) + .unwrap(); + assert!(matches!( + poll_json_message(&mut websocket).unwrap(), + ControlRead::Timeout + )); + }); + + let stream = UnixStream::connect(&socket).unwrap(); + let shutdown = stream.try_clone().unwrap(); + let websocket = initialize_control(stream).unwrap(); + let binding_path = tmp.path().join("state/binding.json"); + let control_state_path = tmp.path().join("state/control-state.json"); + let runtime = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(); + let (tx, rx) = mpsc::channel(); + let runtime_for_pump = runtime.clone(); + let binding_for_pump = binding_path.clone(); + let control_state_for_pump = control_state_path.clone(); + let pump = thread::spawn(move || { + pump_control( + websocket, + &binding_for_pump, + &control_state_for_pump, + &runtime_for_pump, + None, + Some(config), + tx, + ) + }); + assert!(matches!( + rx.recv_timeout(Duration::from_secs(2)).unwrap(), + ControlEvent::Bound + )); + server.join().unwrap(); + let _ = shutdown.shutdown(Shutdown::Both); + pump.join().unwrap(); + + let recovered = load_delivery_state(&delivery_state_path, "h.worker", "h.worker") + .unwrap() + .unwrap(); + assert_eq!(recovered.phase, CodexDeliveryPhase::Accepted); + assert_eq!(recovered.client_id, client_id); + assert!(delivery_config(tmp.path()).inbox.join(filename).is_file()); } #[test] From e5adaee258b3901d4903eeae601dad4446958df7 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Mon, 10 Aug 2026 21:22:29 +0200 Subject: [PATCH 11/56] Subscribe new idle Codex threads --- src/codex_app_server.rs | 42 ++++++++++++++++++++++++----------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index 84d20cfe..f3efc215 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -1308,15 +1308,7 @@ fn pump_control( } if !state.subscribed && !subscription_pending - && message.get("method").and_then(Value::as_str) == Some("thread/status/changed") - && message.pointer("/params/threadId").and_then(Value::as_str) - == Some(state.thread_id.as_str()) - && matches!( - message - .pointer("/params/status/type") - .and_then(Value::as_str), - Some("idle" | "active") - ) + && subscription_candidate(&message, state.thread_id()) { write_json_message( &mut websocket, @@ -1340,6 +1332,30 @@ fn pump_control( } } +fn subscription_candidate(message: &Value, thread_id: &str) -> bool { + match message.get("method").and_then(Value::as_str) { + Some("thread/started") => { + message.pointer("/params/thread/id").and_then(Value::as_str) == Some(thread_id) + && matches!( + message + .pointer("/params/thread/status/type") + .and_then(Value::as_str), + Some("idle" | "active") + ) + } + Some("thread/status/changed") => { + message.pointer("/params/threadId").and_then(Value::as_str) == Some(thread_id) + && matches!( + message + .pointer("/params/status/type") + .and_then(Value::as_str), + Some("idle" | "active") + ) + } + _ => false, + } +} + fn binding_candidate<'a>( message: &'a Value, expected_resume: Option<&str>, @@ -2147,14 +2163,6 @@ mod tests { }), ) .unwrap(); - write_json_message( - &mut websocket, - &json!({ - "method": "thread/status/changed", - "params": { "threadId": "thread-main", "status": { "type": "idle" } } - }), - ) - .unwrap(); let subscribe = read_json_message(&mut websocket).unwrap().unwrap(); assert_eq!(subscribe["method"], "thread/resume"); write_json_message( From d83fecf3a9474b68a05f91d5427f27dc99b4b333 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Mon, 10 Aug 2026 21:25:08 +0200 Subject: [PATCH 12/56] Activate fresh Codex thread bindings --- src/codex_app_server.rs | 38 ++++++++++---------------------------- 1 file changed, 10 insertions(+), 28 deletions(-) diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index f3efc215..81f1d659 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -1259,7 +1259,14 @@ fn pump_control( binding_path, &CodexThreadBinding::new(runtime, thread_id.to_string()), )?; - control_state = Some(CodexControlState::new(runtime, thread_id.to_string())); + let mut bound = CodexControlState::new(runtime, thread_id.to_string()); + // A fresh control client that observes the owning TUI's `thread/started` + // notification already receives that thread's broadcasts. Before its first + // turn there is no rollout for `thread/resume` to load. Saved bindings still + // require resume so their typed history can be reconciled before delivery. + bound.subscribed = expected_resume.is_none() + && message.get("method").and_then(Value::as_str) == Some("thread/started"); + control_state = Some(bound); atomic_json( control_state_path, control_state @@ -2163,18 +2170,6 @@ mod tests { }), ) .unwrap(); - let subscribe = read_json_message(&mut websocket).unwrap().unwrap(); - assert_eq!(subscribe["method"], "thread/resume"); - write_json_message( - &mut websocket, - &json!({ - "id": CONTROL_SUBSCRIBE_REQUEST_ID, - "result": { - "thread": { "id": "thread-main", "status": { "type": "idle" } } - } - }), - ) - .unwrap(); let delivery = read_json_message(&mut websocket).unwrap().unwrap(); assert_eq!(delivery["id"], FIRST_DELIVERY_REQUEST_ID); assert_eq!(delivery["method"], "turn/start"); @@ -2379,7 +2374,7 @@ mod tests { &binding_for_pump, &control_state_for_pump, &runtime_for_pump, - None, + Some("thread-main"), Some(config), tx, ) @@ -2434,11 +2429,8 @@ mod tests { }), ) .unwrap(); - let subscribe = read_json_message(&mut websocket).unwrap().unwrap(); - assert_eq!(subscribe["method"], "thread/resume"); - assert_eq!(subscribe["params"]["threadId"], "thread-main"); // JSON-RPC request IDs are per direction. A server request may reuse the client's - // subscription ID and must not be consumed as that client's response. + // subscription ID and must not be consumed as a client response. write_json_message( &mut websocket, &json!({ @@ -2448,16 +2440,6 @@ mod tests { }), ) .unwrap(); - write_json_message( - &mut websocket, - &json!({ - "id": CONTROL_SUBSCRIBE_REQUEST_ID, - "result": { - "thread": { "id": "thread-main", "status": { "type": "idle" } } - } - }), - ) - .unwrap(); write_json_message( &mut websocket, &json!({ From 7476823ba0c2751ce5baa6c122315feb267a311a Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Tue, 11 Aug 2026 22:40:48 +0200 Subject: [PATCH 13/56] Accept verified Codex 0.146 protocol --- src/codex_app_server.rs | 39 +++++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index 81f1d659..129eda69 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -28,7 +28,7 @@ use tungstenite::{Message as WebSocketMessage, WebSocket}; use crate::{ding, message, run, status}; -pub const SUPPORTED_CODEX_CLI_VERSION: &str = "codex-cli 0.145.0"; +pub const SUPPORTED_CODEX_CLI_VERSIONS: &[&str] = &["codex-cli 0.145.0", "codex-cli 0.146.0"]; const RUNTIME_SCHEMA: &str = "st2.codex-runtime.v1"; const BINDING_SCHEMA: &str = "st2.codex-thread-binding.v1"; const CONTROL_STATE_SCHEMA: &str = "st2.codex-control-state.v1"; @@ -1020,7 +1020,7 @@ fn expected_resume_thread<'a>( .then_some(thread_id)) } -/// Find where a pinned Codex 0.145.0 interactive argv begins its prompt or subcommand. +/// Find where a supported Codex interactive argv begins its prompt or subcommand. /// /// Automatic resume must insert `resume ` after global options and before the authored /// prompt. Unknown options fail closed because guessing can turn an option value into a prompt or a @@ -1467,8 +1467,9 @@ fn ensure_supported_version(codex: &str) -> Result<()> { .trim() .to_string(); anyhow::ensure!( - actual == SUPPORTED_CODEX_CLI_VERSION, - "unsupported Codex app-server protocol version '{actual}' (expected '{SUPPORTED_CODEX_CLI_VERSION}')" + SUPPORTED_CODEX_CLI_VERSIONS.contains(&actual.as_str()), + "unsupported Codex app-server protocol version '{actual}' (expected one of: {})", + SUPPORTED_CODEX_CLI_VERSIONS.join(", ") ); Ok(()) } @@ -1729,8 +1730,38 @@ fn terminate_child(child: &mut Child) { #[cfg(test)] mod tests { use super::*; + use std::os::unix::fs::PermissionsExt; use std::os::unix::net::UnixListener; + #[test] + fn protocol_version_gate_accepts_only_the_exact_allowlist() { + let tmp = tempfile::tempdir().unwrap(); + let write_version = |name: &str, version: &str| { + let path = tmp.path().join(name); + fs::write(&path, format!("#!/bin/sh\nprintf '%s\\n' '{version}'\n")).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); + path + }; + for (name, version) in [ + ("codex-0145", "codex-cli 0.145.0"), + ("codex-0146", "codex-cli 0.146.0"), + ] { + ensure_supported_version(write_version(name, version).to_str().unwrap()).unwrap(); + } + let error = ensure_supported_version( + write_version("codex-0147", "codex-cli 0.147.0") + .to_str() + .unwrap(), + ) + .unwrap_err(); + assert!(error.to_string().contains("codex-cli 0.147.0")); + assert!( + error + .to_string() + .contains("codex-cli 0.145.0, codex-cli 0.146.0") + ); + } + fn delivery_config(root: &Path) -> CodexDeliveryConfig { let agent_dir = root.join("agents/h/worker"); CodexDeliveryConfig { From c461f2102c0c33bcbd71c830f8dad0d4ddeda78f Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Tue, 11 Aug 2026 22:43:37 +0200 Subject: [PATCH 14/56] Explain Codex version admission gate --- src/codex_app_server.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index 129eda69..608e80be 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -28,6 +28,9 @@ use tungstenite::{Message as WebSocketMessage, WebSocket}; use crate::{ding, message, run, status}; +/// Every admitted version has a delivery-critical schema comparison and live remote-TUI evidence. +/// A later version stays rejected until both checks are repeated; semantic-version proximity is +/// not compatibility evidence for this experimental provider surface. pub const SUPPORTED_CODEX_CLI_VERSIONS: &[&str] = &["codex-cli 0.145.0", "codex-cli 0.146.0"]; const RUNTIME_SCHEMA: &str = "st2.codex-runtime.v1"; const BINDING_SCHEMA: &str = "st2.codex-thread-binding.v1"; From a18f115f34a9d566bd34eb04578c54c4fb6e6f47 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Tue, 11 Aug 2026 23:32:50 +0200 Subject: [PATCH 15/56] Forward Codex config to app server --- src/codex_app_server.rs | 158 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 149 insertions(+), 9 deletions(-) diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index 608e80be..c4a4206e 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -908,8 +908,9 @@ pub fn run_controlled( .mode(0o600) .open(state_dir.join("app-server.log"))?; let endpoint = format!("unix://{}", socket_path.display()); + let server_args = controlled_app_server_args(&endpoint, &codex_argv[1..])?; let mut server = Command::new(&codex_argv[0]) - .args(["app-server", "--listen", &endpoint]) + .args(server_args) .stdin(Stdio::null()) .stdout(log.try_clone()?) .stderr(log) @@ -984,6 +985,73 @@ fn run_connected( result } +/// Start app-server with the authored global configuration inputs that its CLI supports. +/// +/// Project trust, strict parsing, and feature selection affect config and hook loading in the +/// server process. Passing them only to the remote TUI silently creates two different effective +/// configurations. TUI-only policy, model, workspace, authentication, and prompt arguments stay +/// on the TUI command. +fn controlled_app_server_args(endpoint: &str, authored_args: &[String]) -> Result> { + let boundary = interactive_root_prefix_end(authored_args)?; + let mut args = vec!["app-server".to_string()]; + let mut index = 0; + while index < boundary { + let argument = authored_args[index].as_str(); + if matches!(argument, "-c" | "--config" | "--enable" | "--disable") { + args.push(argument.to_string()); + args.push(authored_args[index + 1].clone()); + index += 2; + continue; + } + if argument == "--strict-config" + || argument.starts_with("--config=") + || argument.starts_with("--enable=") + || argument.starts_with("--disable=") + || (argument.starts_with("-c") && argument.len() > 2) + { + args.push(argument.to_string()); + index += 1; + continue; + } + if matches!( + argument, + "--oss" + | "--dangerously-bypass-approvals-and-sandbox" + | "--dangerously-bypass-hook-trust" + | "--search" + | "--no-alt-screen" + ) { + index += 1; + continue; + } + if matches!(argument, "-i" | "--image") + || argument.starts_with("-i=") + || argument.starts_with("--image=") + { + break; + } + let exact_value_option = matches!( + argument, + "--remote-auth-token-env" + | "-m" + | "--model" + | "--local-provider" + | "-p" + | "--profile" + | "-s" + | "--sandbox" + | "-C" + | "--cd" + | "--add-dir" + | "-a" + | "--ask-for-approval" + ); + index += if exact_value_option { 2 } else { 1 }; + } + args.extend(["--listen".to_string(), endpoint.to_string()]); + Ok(args) +} + fn controlled_tui_args( endpoint: &str, authored_args: &[String], @@ -1030,19 +1098,27 @@ fn expected_resume_thread<'a>( /// prompt into a session selector. `--image` is variadic, so automatic resume requires an explicit /// `--` boundary when that option is present. fn resume_insertion_index(authored_args: &[String]) -> Result> { + let insertion = interactive_root_prefix_end(authored_args)?; + if authored_args + .get(insertion) + .is_some_and(|argument| matches!(argument.as_str(), "resume" | "fork")) + { + Ok(None) + } else { + Ok(Some(insertion)) + } +} + +fn interactive_root_prefix_end(authored_args: &[String]) -> Result { let delimiter = authored_args.iter().position(|arg| arg == "--"); let mut index = 0; while index < authored_args.len() { let argument = authored_args[index].as_str(); if argument == "--" { - return Ok(Some(index)); + return Ok(index); } if !argument.starts_with('-') || argument == "-" { - return if matches!(argument, "resume" | "fork") { - Ok(None) - } else { - Ok(Some(index)) - }; + return Ok(index); } if matches!( @@ -1096,7 +1172,7 @@ fn resume_insertion_index(authored_args: &[String]) -> Result> { let boundary = delimiter.context( "automatic Codex resume with variadic --image requires an explicit `--` prompt boundary", )?; - return Ok(Some(boundary)); + return Ok(boundary); } let long_value = [ @@ -1123,7 +1199,7 @@ fn resume_insertion_index(authored_args: &[String]) -> Result> { ); index += 1; } - Ok(Some(authored_args.len())) + Ok(authored_args.len()) } fn connect_control( @@ -2978,6 +3054,70 @@ mod tests { assert_eq!(state.observed(), &CodexObservedState::AwaitingStatus); } + #[test] + fn app_server_receives_only_its_supported_global_configuration() { + let authored = vec![ + "-c".into(), + "projects={\"/workspace\"={trust_level=\"trusted\"}}".into(), + "--model".into(), + "gpt-test".into(), + "--enable".into(), + "one".into(), + "--disable=two".into(), + "--strict-config".into(), + "--dangerously-bypass-approvals-and-sandbox".into(), + "--dangerously-bypass-hook-trust".into(), + "boot".into(), + ]; + + assert_eq!( + controlled_app_server_args("unix:///server.sock", &authored).unwrap(), + [ + "app-server", + "-c", + "projects={\"/workspace\"={trust_level=\"trusted\"}}", + "--enable", + "one", + "--disable=two", + "--strict-config", + "--listen", + "unix:///server.sock", + ] + ); + } + + #[test] + fn app_server_configuration_extraction_fails_closed_at_ambiguous_boundaries() { + let missing = + controlled_app_server_args("unix:///server.sock", &["-c".into()]).unwrap_err(); + assert!(missing.to_string().contains("has no value")); + + let unknown = controlled_app_server_args( + "unix:///server.sock", + &["--future-option".into(), "value".into(), "boot".into()], + ) + .unwrap_err(); + assert!(unknown.to_string().contains("unknown Codex option")); + + assert_eq!( + controlled_app_server_args( + "unix:///server.sock", + &[ + "--config=projects.x.trust_level=\"trusted\"".into(), + "resume".into(), + "thread-explicit".into(), + ], + ) + .unwrap(), + [ + "app-server", + "--config=projects.x.trust_level=\"trusted\"", + "--listen", + "unix:///server.sock", + ] + ); + } + #[test] fn controlled_tui_resumes_a_prior_binding_without_overriding_authored_selection() { let authored = vec!["--model".into(), "gpt-test".into(), "boot".into()]; From de6308042615c62780d8186f659ff810087b9529 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Tue, 11 Aug 2026 23:59:07 +0200 Subject: [PATCH 16/56] Persist Codex wrapper startup diagnostics --- src/codex_app_server.rs | 173 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 166 insertions(+), 7 deletions(-) diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index c4a4206e..b723e8f2 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -18,7 +18,7 @@ use std::path::{Path, PathBuf}; use std::process::{Child, Command, ExitStatus, Stdio}; use std::sync::mpsc::{self, Receiver, Sender}; use std::thread; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use anyhow::{Context as _, Result}; use serde::{Deserialize, Serialize}; @@ -36,6 +36,7 @@ const RUNTIME_SCHEMA: &str = "st2.codex-runtime.v1"; const BINDING_SCHEMA: &str = "st2.codex-thread-binding.v1"; const CONTROL_STATE_SCHEMA: &str = "st2.codex-control-state.v1"; const DELIVERY_STATE_SCHEMA: &str = "st2.codex-delivery-state.v1"; +const WRAPPER_DIAGNOSTIC_SCHEMA: &str = "st2.codex-wrapper-diagnostic.v1"; const CONTROL_SUBSCRIBE_REQUEST_ID: u64 = 1; const FIRST_DELIVERY_REQUEST_ID: u64 = 2; const STARTUP_TIMEOUT: Duration = Duration::from_secs(30); @@ -43,6 +44,49 @@ const CONTROL_POLL: Duration = Duration::from_millis(100); const INBOX_REFRESH_FALLBACK: Duration = Duration::from_secs(15); const SOCKET_PATH_BUDGET: usize = 96; +struct WrapperDiagnostics { + file: File, + agent: String, + runtime_id: String, +} + +impl WrapperDiagnostics { + fn open(state_dir: &Path, agent: &str, runtime_id: &str) -> Result { + let file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .mode(0o600) + .open(state_dir.join("wrapper.log"))?; + Ok(Self { + file, + agent: agent.to_string(), + runtime_id: runtime_id.to_string(), + }) + } + + fn record(&mut self, stage: &str, detail: Value) -> Result<()> { + let unix_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock is before the Unix epoch")? + .as_millis(); + serde_json::to_writer( + &mut self.file, + &json!({ + "schema": WRAPPER_DIAGNOSTIC_SCHEMA, + "unixMs": unix_ms, + "agent": self.agent, + "runtimeId": self.runtime_id, + "stage": stage, + "detail": detail, + }), + )?; + self.file.write_all(b"\n")?; + self.file.flush()?; + Ok(()) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct CodexRuntime { @@ -850,6 +894,46 @@ pub fn run_controlled( let state_dir = state_dir(catalog_root, &identity); secure_dir(&state_dir)?; let _owner_lock = acquire_owner_lock(&state_dir)?; + let mut diagnostics = WrapperDiagnostics::open(&state_dir, &identity, &runtime_id)?; + diagnostics.record("ownerAcquired", json!({}))?; + + let result = run_controlled_owned( + catalog_root, + &state_dir, + identity, + runtime_id, + codex_argv, + delivery, + &mut diagnostics, + ); + match result { + Ok(()) => { + diagnostics.record("completed", json!({}))?; + Ok(()) + } + Err(error) => { + let error_text = format!("{error:#}"); + if let Err(diagnostic_error) = + diagnostics.record("failed", json!({ "error": error_text })) + { + return Err(error).context(format!( + "persisting Codex wrapper failure diagnostic: {diagnostic_error:#}" + )); + } + Err(error) + } + } +} + +fn run_controlled_owned( + catalog_root: &Path, + state_dir: &Path, + identity: String, + runtime_id: String, + codex_argv: Vec, + delivery: CodexDeliveryConfig, + diagnostics: &mut WrapperDiagnostics, +) -> Result<()> { let binding_path = state_dir.join("binding.json"); let resume_thread = load_resume_thread(&binding_path, &identity, &runtime_id)?; @@ -901,6 +985,13 @@ pub fn run_controlled( // older daemon is live. A rejected second owner must not invalidate the first owner's binding. let runtime = CodexRuntime::fresh(identity, runtime_id)?; atomic_json(&state_dir.join("runtime.json"), &runtime)?; + diagnostics.record( + "runtimePublished", + json!({ + "runtimeIncarnation": runtime.incarnation(), + "resumeSelected": resume_thread.is_some(), + }), + )?; let log = OpenOptions::new() .create(true) @@ -909,6 +1000,7 @@ pub fn run_controlled( .open(state_dir.join("app-server.log"))?; let endpoint = format!("unix://{}", socket_path.display()); let server_args = controlled_app_server_args(&endpoint, &codex_argv[1..])?; + diagnostics.record("appServerStarting", json!({}))?; let mut server = Command::new(&codex_argv[0]) .args(server_args) .stdin(Stdio::null()) @@ -916,15 +1008,16 @@ pub fn run_controlled( .stderr(log) .spawn() .with_context(|| format!("starting {} app-server", codex_argv[0]))?; + diagnostics.record("appServerStarted", json!({ "pid": server.id() }))?; let result = run_connected( &mut server, &socket_path, - &endpoint, &runtime, &codex_argv, resume_thread.as_deref(), delivery, + diagnostics, ); terminate_child(&mut server); let _ = fs::remove_file(&socket_path); @@ -934,19 +1027,23 @@ pub fn run_controlled( fn run_connected( server: &mut Child, socket_path: &Path, - endpoint: &str, runtime: &CodexRuntime, codex_argv: &[String], resume_thread: Option<&str>, delivery: CodexDeliveryConfig, + diagnostics: &mut WrapperDiagnostics, ) -> Result<()> { let state_dir = state_dir(&delivery.catalog_root, &delivery.identity); - let tui_args = controlled_tui_args(endpoint, &codex_argv[1..], resume_thread)?; + let endpoint = format!("unix://{}", socket_path.display()); + let tui_args = controlled_tui_args(&endpoint, &codex_argv[1..], resume_thread)?; let expected_resume = expected_resume_thread(&codex_argv[1..], resume_thread)?.map(str::to_owned); + diagnostics.record("waitingForControlSocket", json!({ "pid": server.id() }))?; let control = connect_control(server, socket_path, STARTUP_TIMEOUT)?; + diagnostics.record("controlSocketConnected", json!({}))?; let shutdown = control.try_clone()?; let websocket = initialize_control(control)?; + diagnostics.record("controlInitialized", json!({}))?; let (events_tx, events_rx) = mpsc::channel(); let binding_path = state_dir.join("binding.json"); let control_state_path = state_dir.join("control-state.json"); @@ -974,9 +1071,13 @@ fn run_connected( .stderr(Stdio::inherit()) .spawn() .with_context(|| format!("starting controlled {} TUI", codex_argv[0]))?; + diagnostics.record("tuiStarted", json!({ "pid": tui.id() }))?; - let result = wait_for_binding(&mut tui, &events_rx, STARTUP_TIMEOUT) - .and_then(|_| monitor_bound_tui(&mut tui, &events_rx)); + diagnostics.record("waitingForThreadBinding", json!({ "pid": tui.id() }))?; + let result = wait_for_binding(&mut tui, &events_rx, STARTUP_TIMEOUT).and_then(|_| { + diagnostics.record("threadBound", json!({ "pid": tui.id() }))?; + monitor_bound_tui(&mut tui, &events_rx) + }); if result.is_err() { terminate_child(&mut tui); } @@ -1195,13 +1296,24 @@ fn interactive_root_prefix_end(authored_args: &[String]) -> Result { .any(|prefix| argument.starts_with(prefix) && argument.len() > prefix.len()); anyhow::ensure!( long_value || short_value, - "cannot automatically resume through unknown Codex option '{argument}'" + "cannot automatically resume through unknown Codex option '{}'", + diagnostic_option_name(argument) ); index += 1; } Ok(authored_args.len()) } +fn diagnostic_option_name(argument: &str) -> String { + if let Some((name, _)) = argument.split_once('=') { + return name.to_string(); + } + if argument.starts_with("--") { + return argument.to_string(); + } + argument.chars().take(2).collect() +} + fn connect_control( server: &mut Child, socket_path: &Path, @@ -3099,6 +3211,14 @@ mod tests { .unwrap_err(); assert!(unknown.to_string().contains("unknown Codex option")); + let sensitive = controlled_app_server_args( + "unix:///server.sock", + &["--future-token=do-not-log-this".into(), "boot".into()], + ) + .unwrap_err(); + assert!(sensitive.to_string().contains("--future-token")); + assert!(!sensitive.to_string().contains("do-not-log-this")); + assert_eq!( controlled_app_server_args( "unix:///server.sock", @@ -3245,6 +3365,45 @@ mod tests { assert!(!first.display().to_string().contains("catalog/a")); } + #[test] + fn wrapper_diagnostics_keep_one_bounded_run_without_authored_input() { + let tmp = tempfile::tempdir().unwrap(); + let state = tmp.path().join("state"); + secure_dir(&state).unwrap(); + + { + let mut diagnostics = WrapperDiagnostics::open(&state, "h.worker", "h.worker").unwrap(); + diagnostics.record("ownerAcquired", json!({})).unwrap(); + diagnostics + .record("failed", json!({ "error": "control socket was not ready" })) + .unwrap(); + } + let path = state.join("wrapper.log"); + let first = fs::read_to_string(&path).unwrap(); + let entries = first + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0]["schema"], WRAPPER_DIAGNOSTIC_SCHEMA); + assert_eq!(entries[0]["agent"], "h.worker"); + assert_eq!(entries[1]["stage"], "failed"); + assert!(first.contains("control socket was not ready")); + assert!(!first.contains("prompt")); + + { + let mut replacement = WrapperDiagnostics::open(&state, "h.worker", "h.worker").unwrap(); + replacement.record("ownerAcquired", json!({})).unwrap(); + } + let replacement = fs::read_to_string(&path).unwrap(); + assert_eq!(replacement.lines().count(), 1); + assert!(!replacement.contains("control socket was not ready")); + assert_eq!( + fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + #[test] fn runtime_owner_lock_is_nonblocking_and_released_on_close() { let tmp = tempfile::tempdir().unwrap(); From 55bdb8d2f91275720ae39adb2fd10460e068f6b9 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Wed, 12 Aug 2026 00:02:23 +0200 Subject: [PATCH 17/56] Reap children when diagnostics fail --- src/codex_app_server.rs | 39 +++++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index b723e8f2..4d105384 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -1008,17 +1008,19 @@ fn run_controlled_owned( .stderr(log) .spawn() .with_context(|| format!("starting {} app-server", codex_argv[0]))?; - diagnostics.record("appServerStarted", json!({ "pid": server.id() }))?; - - let result = run_connected( - &mut server, - &socket_path, - &runtime, - &codex_argv, - resume_thread.as_deref(), - delivery, - diagnostics, - ); + let result = diagnostics + .record("appServerStarted", json!({ "pid": server.id() })) + .and_then(|_| { + run_connected( + &mut server, + &socket_path, + &runtime, + &codex_argv, + resume_thread.as_deref(), + delivery, + diagnostics, + ) + }); terminate_child(&mut server); let _ = fs::remove_file(&socket_path); result @@ -1071,13 +1073,14 @@ fn run_connected( .stderr(Stdio::inherit()) .spawn() .with_context(|| format!("starting controlled {} TUI", codex_argv[0]))?; - diagnostics.record("tuiStarted", json!({ "pid": tui.id() }))?; - - diagnostics.record("waitingForThreadBinding", json!({ "pid": tui.id() }))?; - let result = wait_for_binding(&mut tui, &events_rx, STARTUP_TIMEOUT).and_then(|_| { - diagnostics.record("threadBound", json!({ "pid": tui.id() }))?; - monitor_bound_tui(&mut tui, &events_rx) - }); + let result = (|| -> Result<()> { + diagnostics.record("tuiStarted", json!({ "pid": tui.id() }))?; + diagnostics.record("waitingForThreadBinding", json!({ "pid": tui.id() }))?; + wait_for_binding(&mut tui, &events_rx, STARTUP_TIMEOUT).and_then(|_| { + diagnostics.record("threadBound", json!({ "pid": tui.id() }))?; + monitor_bound_tui(&mut tui, &events_rx) + }) + })(); if result.is_err() { terminate_child(&mut tui); } From 85d867635e1a9dc9085f2cbf497dcfe54059b8a9 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Wed, 12 Aug 2026 00:28:29 +0200 Subject: [PATCH 18/56] Bind Codex resume from control response --- src/codex_app_server.rs | 268 +++++++++++++++++++++++++++++----------- 1 file changed, 195 insertions(+), 73 deletions(-) diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index 4d105384..287fc1d6 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -2,10 +2,11 @@ //! //! Native delivery cannot infer a thread from cwd, process, PTY, or `thread/list`. This module //! starts a dedicated provider daemon, initializes an observer connection before the interactive -//! client starts, and binds a typed start or successful-resume event to the exact wrapper process -//! incarnation that owns the PTY launch. Its control watcher persists delivery-relevant thread and -//! turn state. The native delivery layer selects one durable FIFO inbox head and submits typed -//! input only when that state proves an idle or one exact regular active turn. +//! client starts, and binds a typed start notification or successful resume response to the exact +//! wrapper process incarnation that owns the PTY launch. Its control watcher persists +//! delivery-relevant thread and turn state. The native delivery layer selects one durable FIFO +//! inbox head and submits typed input only when that state proves an idle or one exact regular +//! active turn. use std::fs::{self, File, OpenOptions}; use std::io::{Read as _, Write}; @@ -1050,31 +1051,53 @@ fn run_connected( let binding_path = state_dir.join("binding.json"); let control_state_path = state_dir.join("control-state.json"); let runtime_for_reader = runtime.clone(); + let (mut resume_ready_tx, resume_ready_rx) = if expected_resume.is_some() { + let (tx, rx) = mpsc::channel(); + (Some(tx), Some(rx)) + } else { + (None, None) + }; let event_thread = thread::spawn(move || { pump_control( websocket, &binding_path, &control_state_path, &runtime_for_reader, - expected_resume.as_deref(), + expected_resume.as_deref().zip(resume_ready_rx), Some(delivery), events_tx, ) }); - // The initialized observer is already reading before this child can issue thread/start or - // thread/resume. Insert the remote endpoint as a global Codex option and preserve every authored - // argument after the provider executable. + // A fresh initialized observer reads before this child can issue thread/start. A resumed + // observer waits on the gate below: app-server does not promise a new start or unchanged-status + // notification on resume, so the control client sends its own resume only after the TUI exists. + // Insert the remote endpoint as a global Codex option and preserve every authored argument + // after the provider executable. let mut tui_command = Command::new(&codex_argv[0]); tui_command.args(tui_args); - let mut tui = tui_command + let mut tui = match tui_command .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .spawn() - .with_context(|| format!("starting controlled {} TUI", codex_argv[0]))?; + { + Ok(tui) => tui, + Err(error) => { + drop(resume_ready_tx); + let _ = shutdown.shutdown(Shutdown::Both); + let _ = event_thread.join(); + return Err(error) + .with_context(|| format!("starting controlled {} TUI", codex_argv[0])); + } + }; let result = (|| -> Result<()> { diagnostics.record("tuiStarted", json!({ "pid": tui.id() }))?; + if let Some(ready) = resume_ready_tx.take() { + ready + .send(()) + .context("starting Codex control resume after the TUI launched")?; + } diagnostics.record("waitingForThreadBinding", json!({ "pid": tui.id() }))?; wait_for_binding(&mut tui, &events_rx, STARTUP_TIMEOUT).and_then(|_| { diagnostics.record("threadBound", json!({ "pid": tui.id() }))?; @@ -1084,6 +1107,7 @@ fn run_connected( if result.is_err() { terminate_child(&mut tui); } + drop(resume_ready_tx); let _ = shutdown.shutdown(Shutdown::Both); let _ = event_thread.join(); result @@ -1413,11 +1437,15 @@ fn pump_control( binding_path: &Path, control_state_path: &Path, runtime: &CodexRuntime, - expected_resume: Option<&str>, + resume: Option<(&str, Receiver<()>)>, delivery: Option, events: Sender, ) { let result = (|| -> Result<()> { + let (expected_resume, resume_ready) = match resume { + Some((thread_id, ready)) => (Some(thread_id), Some(ready)), + None => (None, None), + }; let mut control_state: Option = None; let mut subscription_pending = false; let delivery_state_path = control_state_path.with_file_name("delivery-state.json"); @@ -1426,6 +1454,21 @@ fn pump_control( CodexInboxDelivery::new(config, delivery_state_path.clone(), runtime.clone()) }) .transpose()?; + if let Some(thread_id) = expected_resume { + resume_ready + .context("saved Codex binding has no TUI-start gate")? + .recv() + .context("controlled Codex TUI ended before control resume")?; + write_json_message( + &mut websocket, + &json!({ + "method": "thread/resume", + "id": CONTROL_SUBSCRIBE_REQUEST_ID, + "params": { "threadId": thread_id } + }), + )?; + subscription_pending = true; + } websocket.get_ref().set_read_timeout(Some(CONTROL_POLL))?; loop { let message = match poll_json_message(&mut websocket)? { @@ -1445,30 +1488,53 @@ fn pump_control( continue; }; if control_state.is_none() { - let Some(thread_id) = binding_candidate(&message, expected_resume)? else { - continue; - }; - { + if let Some(thread_id) = expected_resume { + if message.get("method").is_some() + || message.get("id") != Some(&Value::from(CONTROL_SUBSCRIBE_REQUEST_ID)) + { + continue; + } + anyhow::ensure!( + subscription_pending, + "Codex control received an unexpected initial thread/resume response" + ); + subscription_pending = false; + let mut bound = CodexControlState::new(runtime, thread_id.to_string()); + match bound.accept_subscription(&message)? { + SubscriptionAcceptance::Accepted { .. } => { + if let Some(delivery) = delivery.as_mut() { + delivery.reconcile_resume(&message, &bound)?; + } + } + SubscriptionAcceptance::Deferred => anyhow::bail!( + "saved Codex resume binding has no persisted rollout for thread {thread_id}" + ), + } atomic_json( binding_path, &CodexThreadBinding::new(runtime, thread_id.to_string()), )?; - let mut bound = CodexControlState::new(runtime, thread_id.to_string()); - // A fresh control client that observes the owning TUI's `thread/started` - // notification already receives that thread's broadcasts. Before its first - // turn there is no rollout for `thread/resume` to load. Saved bindings still - // require resume so their typed history can be reconciled before delivery. - bound.subscribed = expected_resume.is_none() - && message.get("method").and_then(Value::as_str) == Some("thread/started"); + atomic_json(control_state_path, &bound)?; control_state = Some(bound); - atomic_json( - control_state_path, - control_state - .as_ref() - .context("Codex control state is unbound")?, - )?; let _ = events.send(ControlEvent::Bound); + continue; } + + let Some(thread_id) = binding_candidate(&message)? else { + continue; + }; + atomic_json( + binding_path, + &CodexThreadBinding::new(runtime, thread_id.to_string()), + )?; + let mut bound = CodexControlState::new(runtime, thread_id.to_string()); + // A fresh control client that observes the owning TUI's `thread/started` + // notification is already subscribed to that thread's broadcasts. Before its + // first turn there is no persisted rollout for a redundant `thread/resume`. + bound.subscribed = true; + atomic_json(control_state_path, &bound)?; + control_state = Some(bound); + let _ = events.send(ControlEvent::Bound); } let state = control_state @@ -1557,24 +1623,11 @@ fn subscription_candidate(message: &Value, thread_id: &str) -> bool { } } -fn binding_candidate<'a>( - message: &'a Value, - expected_resume: Option<&str>, -) -> Result> { +fn binding_candidate(message: &Value) -> Result> { match message.get("method").and_then(Value::as_str) { Some("thread/started") => { let thread_id = required_string(message, "/params/thread/id", "thread/started")?; - Ok(expected_resume - .is_none_or(|expected| expected == thread_id) - .then_some(thread_id)) - } - Some("thread/status/changed") if expected_resume.is_some() => { - let thread_id = required_string(message, "/params/threadId", "thread/status/changed")?; - let status = required_string(message, "/params/status/type", "thread/status/changed")?; - Ok( - (Some(thread_id) == expected_resume && matches!(status, "idle" | "active")) - .then_some(thread_id), - ) + Ok(Some(thread_id)) } _ => Ok(None), } @@ -2537,22 +2590,6 @@ mod tests { read_json_message(&mut websocket).unwrap().unwrap()["method"], "initialized" ); - write_json_message( - &mut websocket, - &json!({ - "method": "thread/started", - "params": { "thread": { "id": "thread-main", "status": { "type": "idle" } } } - }), - ) - .unwrap(); - write_json_message( - &mut websocket, - &json!({ - "method": "thread/status/changed", - "params": { "threadId": "thread-main", "status": { "type": "idle" } } - }), - ) - .unwrap(); let subscribe = read_json_message(&mut websocket).unwrap().unwrap(); assert_eq!(subscribe["method"], "thread/resume"); write_json_message( @@ -2590,6 +2627,8 @@ mod tests { let control_state_path = tmp.path().join("state/control-state.json"); let runtime = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(); let (tx, rx) = mpsc::channel(); + let (resume_ready_tx, resume_ready_rx) = mpsc::channel(); + resume_ready_tx.send(()).unwrap(); let runtime_for_pump = runtime.clone(); let binding_for_pump = binding_path.clone(); let control_state_for_pump = control_state_path.clone(); @@ -2599,7 +2638,7 @@ mod tests { &binding_for_pump, &control_state_for_pump, &runtime_for_pump, - Some("thread-main"), + Some(("thread-main", resume_ready_rx)), Some(config), tx, ) @@ -2731,12 +2770,16 @@ mod tests { } #[test] - fn a_successfully_loaded_expected_resume_is_bound_to_the_new_incarnation() { + fn expected_resume_waits_for_tui_gate_and_binds_from_control_response() { let tmp = tempfile::tempdir().unwrap(); let socket = tmp.path().join("server.sock"); let listener = UnixListener::bind(&socket).unwrap(); + let (pre_gate_checked_tx, pre_gate_checked_rx) = mpsc::channel(); let server = thread::spawn(move || { let (stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_millis(100))) + .unwrap(); let mut websocket = tungstenite::accept(stream).unwrap(); let initialize = read_json_message(&mut websocket).unwrap().unwrap(); assert_eq!(initialize["method"], "initialize"); @@ -2747,6 +2790,11 @@ mod tests { .unwrap(); let initialized = read_json_message(&mut websocket).unwrap().unwrap(); assert_eq!(initialized["method"], "initialized"); + assert!(matches!( + poll_json_message(&mut websocket).unwrap(), + ControlRead::Timeout + )); + pre_gate_checked_tx.send(()).unwrap(); write_json_message( &mut websocket, &json!({ @@ -2768,17 +2816,6 @@ mod tests { }), ) .unwrap(); - write_json_message( - &mut websocket, - &json!({ - "method": "thread/status/changed", - "params": { - "threadId": "thread-prior", - "status": { "type": "idle" } - } - }), - ) - .unwrap(); let subscribe = read_json_message(&mut websocket).unwrap().unwrap(); assert_eq!(subscribe["method"], "thread/resume"); assert_eq!(subscribe["params"]["threadId"], "thread-prior"); @@ -2801,6 +2838,7 @@ mod tests { let control_state_path = tmp.path().join("state/control-state.json"); let runtime = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(); let (tx, rx) = mpsc::channel(); + let (resume_ready_tx, resume_ready_rx) = mpsc::channel(); let runtime_for_pump = runtime.clone(); let binding_for_pump = binding_path.clone(); let control_state_for_pump = control_state_path.clone(); @@ -2810,11 +2848,15 @@ mod tests { &binding_for_pump, &control_state_for_pump, &runtime_for_pump, - Some("thread-prior"), + Some(("thread-prior", resume_ready_rx)), None, tx, ) }); + pre_gate_checked_rx + .recv_timeout(Duration::from_secs(2)) + .unwrap(); + resume_ready_tx.send(()).unwrap(); assert!(matches!( rx.recv_timeout(Duration::from_secs(2)).unwrap(), ControlEvent::Bound @@ -2834,6 +2876,86 @@ mod tests { assert_eq!(state.observed(), &CodexObservedState::Idle); } + #[test] + fn missing_saved_rollout_fails_without_rebinding_the_incarnation() { + let tmp = tempfile::tempdir().unwrap(); + let binding_path = tmp.path().join("state/binding.json"); + let control_state_path = tmp.path().join("state/control-state.json"); + let prior_runtime = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(); + let prior_binding = CodexThreadBinding::new(&prior_runtime, "thread-prior".into()); + atomic_json(&binding_path, &prior_binding).unwrap(); + + let socket = tmp.path().join("server.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let server = thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut websocket = tungstenite::accept(stream).unwrap(); + assert_eq!( + read_json_message(&mut websocket).unwrap().unwrap()["method"], + "initialize" + ); + write_json_message( + &mut websocket, + &json!({ "id": 0, "result": { "userAgent": "fake" } }), + ) + .unwrap(); + assert_eq!( + read_json_message(&mut websocket).unwrap().unwrap()["method"], + "initialized" + ); + let resume = read_json_message(&mut websocket).unwrap().unwrap(); + assert_eq!(resume["method"], "thread/resume"); + assert_eq!(resume["params"]["threadId"], "thread-prior"); + write_json_message( + &mut websocket, + &json!({ + "id": CONTROL_SUBSCRIBE_REQUEST_ID, + "error": { + "code": -32600, + "message": "no rollout found for thread id thread-prior" + } + }), + ) + .unwrap(); + }); + + let stream = UnixStream::connect(&socket).unwrap(); + let shutdown = stream.try_clone().unwrap(); + let websocket = initialize_control(stream).unwrap(); + let runtime = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(); + let (tx, rx) = mpsc::channel(); + let (resume_ready_tx, resume_ready_rx) = mpsc::channel(); + let runtime_for_pump = runtime.clone(); + let binding_for_pump = binding_path.clone(); + let control_state_for_pump = control_state_path.clone(); + let pump = thread::spawn(move || { + pump_control( + websocket, + &binding_for_pump, + &control_state_for_pump, + &runtime_for_pump, + Some(("thread-prior", resume_ready_rx)), + None, + tx, + ) + }); + resume_ready_tx.send(()).unwrap(); + let ControlEvent::Failed(error) = rx.recv_timeout(Duration::from_secs(2)).unwrap() else { + panic!("missing saved rollout did not fail closed"); + }; + assert!(error.contains("saved Codex resume binding has no persisted rollout")); + + server.join().unwrap(); + let _ = shutdown.shutdown(Shutdown::Both); + pump.join().unwrap(); + assert_eq!( + serde_json::from_slice::(&fs::read(&binding_path).unwrap()) + .unwrap(), + prior_binding + ); + assert!(!control_state_path.exists()); + } + #[test] fn a_binding_from_another_runtime_incarnation_is_rejected() { let tmp = tempfile::tempdir().unwrap(); From 06198af1513eaa1352787be020f64ad5ed72bf19 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Wed, 12 Aug 2026 00:58:28 +0200 Subject: [PATCH 19/56] Wait for Codex TUI before control resume --- src/codex_app_server.rs | 129 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 124 insertions(+), 5 deletions(-) diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index 287fc1d6..20176456 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -3,7 +3,8 @@ //! Native delivery cannot infer a thread from cwd, process, PTY, or `thread/list`. This module //! starts a dedicated provider daemon, initializes an observer connection before the interactive //! client starts, and binds a typed start notification or successful resume response to the exact -//! wrapper process incarnation that owns the PTY launch. Its control watcher persists +//! wrapper process incarnation that owns the PTY launch. On resume, the owning TUI must first make +//! the preserved thread visible in the provider's loaded-thread inventory. Its control watcher persists //! delivery-relevant thread and turn state. The native delivery layer selects one durable FIFO //! inbox head and submits typed input only when that state proves an idle or one exact regular //! active turn. @@ -38,6 +39,7 @@ const BINDING_SCHEMA: &str = "st2.codex-thread-binding.v1"; const CONTROL_STATE_SCHEMA: &str = "st2.codex-control-state.v1"; const DELIVERY_STATE_SCHEMA: &str = "st2.codex-delivery-state.v1"; const WRAPPER_DIAGNOSTIC_SCHEMA: &str = "st2.codex-wrapper-diagnostic.v1"; +const CONTROL_TUI_LOADED_REQUEST_ID: u64 = 0; const CONTROL_SUBSCRIBE_REQUEST_ID: u64 = 1; const FIRST_DELIVERY_REQUEST_ID: u64 = 2; const STARTUP_TIMEOUT: Duration = Duration::from_secs(30); @@ -1070,8 +1072,8 @@ fn run_connected( }); // A fresh initialized observer reads before this child can issue thread/start. A resumed - // observer waits on the gate below: app-server does not promise a new start or unchanged-status - // notification on resume, so the control client sends its own resume only after the TUI exists. + // observer waits on the gate below, then proves through thread/loaded/list that the TUI issued + // its own resume. Only after that typed observation may control send its redundant resume. // Insert the remote endpoint as a global Codex option and preserve every authored argument // after the provider executable. let mut tui_command = Command::new(&codex_argv[0]); @@ -1424,6 +1426,76 @@ fn initialize_control(stream: UnixStream) -> Result> { Ok(websocket) } +/// Wait until the owning TUI has loaded the preserved thread before this control connection +/// subscribes with its own `thread/resume` request. +/// +/// Process creation is not ownership evidence. If control resumes immediately after spawn, it can +/// win the cold resume and create the session before the TUI has attached, so a successful control +/// response would not prove that the TUI consumed its authored prompt. `thread/loaded/list` is a +/// typed observation of the TUI's progress and is available in every admitted Codex version. +fn wait_for_tui_loaded_thread( + websocket: &mut WebSocket, + expected_thread_id: &str, + timeout: Duration, +) -> Result<()> { + let deadline = Instant::now() + timeout; + loop { + write_json_message( + websocket, + &json!({ + "method": "thread/loaded/list", + "id": CONTROL_TUI_LOADED_REQUEST_ID, + "params": {}, + }), + )?; + + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + anyhow::ensure!( + !remaining.is_zero(), + "controlled Codex TUI did not load preserved thread {expected_thread_id} before control resume" + ); + websocket + .get_ref() + .set_read_timeout(Some(remaining.min(CONTROL_POLL)))?; + let message = match poll_json_message(websocket)? { + ControlRead::Message(message) => message, + ControlRead::Timeout => continue, + ControlRead::Closed => anyhow::bail!( + "Codex app-server closed the control connection while waiting for the TUI to load preserved thread {expected_thread_id}" + ), + }; + if message.get("id") != Some(&Value::from(CONTROL_TUI_LOADED_REQUEST_ID)) { + continue; + } + if let Some(error) = message.get("error") { + anyhow::bail!("Codex app-server rejected thread/loaded/list: {error}"); + } + let loaded = message + .pointer("/result/data") + .and_then(Value::as_array) + .context("Codex thread/loaded/list response has no typed data")?; + let contains_expected = loaded.iter().try_fold(false, |found, thread_id| { + let thread_id = thread_id + .as_str() + .context("Codex thread/loaded/list returned a non-string thread id")?; + Ok::<_, anyhow::Error>(found || thread_id == expected_thread_id) + })?; + if contains_expected { + return Ok(()); + } + break; + } + + let remaining = deadline.saturating_duration_since(Instant::now()); + anyhow::ensure!( + !remaining.is_zero(), + "controlled Codex TUI did not load preserved thread {expected_thread_id} before control resume" + ); + thread::sleep(remaining.min(CONTROL_POLL)); + } +} + #[derive(Debug)] enum ControlEvent { Bound, @@ -1454,11 +1526,13 @@ fn pump_control( CodexInboxDelivery::new(config, delivery_state_path.clone(), runtime.clone()) }) .transpose()?; + websocket.get_ref().set_read_timeout(Some(CONTROL_POLL))?; if let Some(thread_id) = expected_resume { resume_ready .context("saved Codex binding has no TUI-start gate")? .recv() .context("controlled Codex TUI ended before control resume")?; + wait_for_tui_loaded_thread(&mut websocket, thread_id, STARTUP_TIMEOUT)?; write_json_message( &mut websocket, &json!({ @@ -1469,7 +1543,6 @@ fn pump_control( )?; subscription_pending = true; } - websocket.get_ref().set_read_timeout(Some(CONTROL_POLL))?; loop { let message = match poll_json_message(&mut websocket)? { ControlRead::Message(message) => Some(message), @@ -2590,6 +2663,17 @@ mod tests { read_json_message(&mut websocket).unwrap().unwrap()["method"], "initialized" ); + let loaded = read_json_message(&mut websocket).unwrap().unwrap(); + assert_eq!(loaded["method"], "thread/loaded/list"); + assert_eq!(loaded["id"], CONTROL_TUI_LOADED_REQUEST_ID); + write_json_message( + &mut websocket, + &json!({ + "id": CONTROL_TUI_LOADED_REQUEST_ID, + "result": { "data": ["thread-main"] } + }), + ) + .unwrap(); let subscribe = read_json_message(&mut websocket).unwrap().unwrap(); assert_eq!(subscribe["method"], "thread/resume"); write_json_message( @@ -2770,7 +2854,7 @@ mod tests { } #[test] - fn expected_resume_waits_for_tui_gate_and_binds_from_control_response() { + fn expected_resume_waits_for_tui_loaded_thread_and_binds_from_control_response() { let tmp = tempfile::tempdir().unwrap(); let socket = tmp.path().join("server.sock"); let listener = UnixListener::bind(&socket).unwrap(); @@ -2795,6 +2879,10 @@ mod tests { ControlRead::Timeout )); pre_gate_checked_tx.send(()).unwrap(); + websocket + .get_mut() + .set_read_timeout(Some(Duration::from_millis(500))) + .unwrap(); write_json_message( &mut websocket, &json!({ @@ -2816,6 +2904,27 @@ mod tests { }), ) .unwrap(); + let first_loaded = read_json_message(&mut websocket).unwrap().unwrap(); + assert_eq!(first_loaded["method"], "thread/loaded/list"); + assert_eq!(first_loaded["id"], CONTROL_TUI_LOADED_REQUEST_ID); + write_json_message( + &mut websocket, + &json!({ + "id": CONTROL_TUI_LOADED_REQUEST_ID, + "result": { "data": ["thread-unrelated"] } + }), + ) + .unwrap(); + let second_loaded = read_json_message(&mut websocket).unwrap().unwrap(); + assert_eq!(second_loaded["method"], "thread/loaded/list"); + write_json_message( + &mut websocket, + &json!({ + "id": CONTROL_TUI_LOADED_REQUEST_ID, + "result": { "data": ["thread-unrelated", "thread-prior"] } + }), + ) + .unwrap(); let subscribe = read_json_message(&mut websocket).unwrap().unwrap(); assert_eq!(subscribe["method"], "thread/resume"); assert_eq!(subscribe["params"]["threadId"], "thread-prior"); @@ -2903,6 +3012,16 @@ mod tests { read_json_message(&mut websocket).unwrap().unwrap()["method"], "initialized" ); + let loaded = read_json_message(&mut websocket).unwrap().unwrap(); + assert_eq!(loaded["method"], "thread/loaded/list"); + write_json_message( + &mut websocket, + &json!({ + "id": CONTROL_TUI_LOADED_REQUEST_ID, + "result": { "data": ["thread-prior"] } + }), + ) + .unwrap(); let resume = read_json_message(&mut websocket).unwrap().unwrap(); assert_eq!(resume["method"], "thread/resume"); assert_eq!(resume["params"]["threadId"], "thread-prior"); From 8ec67bcd473eff1f075be1c409145f7e5bded9e1 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Wed, 12 Aug 2026 01:05:03 +0200 Subject: [PATCH 20/56] Trace TUI-loaded resume gate --- src/codex_app_server.rs | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index 20176456..b913ee88 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -1101,7 +1101,7 @@ fn run_connected( .context("starting Codex control resume after the TUI launched")?; } diagnostics.record("waitingForThreadBinding", json!({ "pid": tui.id() }))?; - wait_for_binding(&mut tui, &events_rx, STARTUP_TIMEOUT).and_then(|_| { + wait_for_binding(&mut tui, &events_rx, STARTUP_TIMEOUT, diagnostics).and_then(|_| { diagnostics.record("threadBound", json!({ "pid": tui.id() }))?; monitor_bound_tui(&mut tui, &events_rx) }) @@ -1498,6 +1498,7 @@ fn wait_for_tui_loaded_thread( #[derive(Debug)] enum ControlEvent { + TuiThreadLoaded(Sender<()>), Bound, Observed, Closed, @@ -1533,6 +1534,13 @@ fn pump_control( .recv() .context("controlled Codex TUI ended before control resume")?; wait_for_tui_loaded_thread(&mut websocket, thread_id, STARTUP_TIMEOUT)?; + let (diagnostic_tx, diagnostic_rx) = mpsc::channel(); + events + .send(ControlEvent::TuiThreadLoaded(diagnostic_tx)) + .context("recording that the Codex TUI loaded the preserved thread")?; + diagnostic_rx + .recv() + .context("waiting for the Codex TUI-loaded diagnostic before control resume")?; write_json_message( &mut websocket, &json!({ @@ -1710,6 +1718,7 @@ fn wait_for_binding( tui: &mut Child, events: &Receiver, timeout: Duration, + diagnostics: &mut WrapperDiagnostics, ) -> Result<()> { let deadline = Instant::now() + timeout; loop { @@ -1726,6 +1735,10 @@ fn wait_for_binding( ); } match events.recv_timeout(wait) { + Ok(ControlEvent::TuiThreadLoaded(acknowledge)) => { + diagnostics.record("tuiThreadLoaded", json!({ "pid": tui.id() }))?; + let _ = acknowledge.send(()); + } Ok(ControlEvent::Bound) => return Ok(()), Ok(ControlEvent::Observed) => {} Ok(ControlEvent::Closed) => { @@ -1748,6 +1761,9 @@ fn monitor_bound_tui(tui: &mut Child, events: &Receiver) -> Result return completed_tui(status); } match events.recv_timeout(CONTROL_POLL) { + Ok(ControlEvent::TuiThreadLoaded(acknowledge)) => { + let _ = acknowledge.send(()); + } Ok(ControlEvent::Bound) => {} Ok(ControlEvent::Observed) => {} Ok(ControlEvent::Closed) => { @@ -2110,6 +2126,15 @@ mod tests { .unwrap() } + fn acknowledge_tui_thread_loaded(events: &Receiver) { + let ControlEvent::TuiThreadLoaded(acknowledge) = + events.recv_timeout(Duration::from_secs(2)).unwrap() + else { + panic!("control did not report the TUI-loaded gate"); + }; + acknowledge.send(()).unwrap(); + } + #[test] fn delivery_request_uses_typed_start_and_exact_turn_steer() { let start = codex_delivery_request( @@ -2727,6 +2752,7 @@ mod tests { tx, ) }); + acknowledge_tui_thread_loaded(&rx); assert!(matches!( rx.recv_timeout(Duration::from_secs(2)).unwrap(), ControlEvent::Bound @@ -2966,6 +2992,7 @@ mod tests { .recv_timeout(Duration::from_secs(2)) .unwrap(); resume_ready_tx.send(()).unwrap(); + acknowledge_tui_thread_loaded(&rx); assert!(matches!( rx.recv_timeout(Duration::from_secs(2)).unwrap(), ControlEvent::Bound @@ -3059,6 +3086,7 @@ mod tests { ) }); resume_ready_tx.send(()).unwrap(); + acknowledge_tui_thread_loaded(&rx); let ControlEvent::Failed(error) = rx.recv_timeout(Duration::from_secs(2)).unwrap() else { panic!("missing saved rollout did not fail closed"); }; From 381b07e1c72aca0701ebfecf7139f30298133644 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Wed, 12 Aug 2026 01:26:29 +0200 Subject: [PATCH 21/56] Separate Codex resume diagnostics deadline --- src/codex_app_server.rs | 128 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 119 insertions(+), 9 deletions(-) diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index b913ee88..bf83264c 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -42,6 +42,8 @@ const WRAPPER_DIAGNOSTIC_SCHEMA: &str = "st2.codex-wrapper-diagnostic.v1"; const CONTROL_TUI_LOADED_REQUEST_ID: u64 = 0; const CONTROL_SUBSCRIBE_REQUEST_ID: u64 = 1; const FIRST_DELIVERY_REQUEST_ID: u64 = 2; +// The inner provider result must reach the wrapper before the outer ownership wait expires. +const TUI_LOADED_TIMEOUT: Duration = Duration::from_secs(15); const STARTUP_TIMEOUT: Duration = Duration::from_secs(30); const CONTROL_POLL: Duration = Duration::from_millis(100); const INBOX_REFRESH_FALLBACK: Duration = Duration::from_secs(15); @@ -1060,12 +1062,20 @@ fn run_connected( (None, None) }; let event_thread = thread::spawn(move || { + let resume = expected_resume + .as_deref() + .zip(resume_ready_rx) + .map(|(thread_id, ready)| ControlResume { + thread_id, + ready, + tui_loaded_timeout: TUI_LOADED_TIMEOUT, + }); pump_control( websocket, &binding_path, &control_state_path, &runtime_for_reader, - expected_resume.as_deref().zip(resume_ready_rx), + resume, Some(delivery), events_tx, ) @@ -1505,19 +1515,29 @@ enum ControlEvent { Failed(String), } +struct ControlResume<'a> { + thread_id: &'a str, + ready: Receiver<()>, + tui_loaded_timeout: Duration, +} + fn pump_control( mut websocket: WebSocket, binding_path: &Path, control_state_path: &Path, runtime: &CodexRuntime, - resume: Option<(&str, Receiver<()>)>, + resume: Option>, delivery: Option, events: Sender, ) { let result = (|| -> Result<()> { - let (expected_resume, resume_ready) = match resume { - Some((thread_id, ready)) => (Some(thread_id), Some(ready)), - None => (None, None), + let (expected_resume, resume_ready, tui_loaded_timeout) = match resume { + Some(resume) => ( + Some(resume.thread_id), + Some(resume.ready), + resume.tui_loaded_timeout, + ), + None => (None, None, TUI_LOADED_TIMEOUT), }; let mut control_state: Option = None; let mut subscription_pending = false; @@ -1533,7 +1553,7 @@ fn pump_control( .context("saved Codex binding has no TUI-start gate")? .recv() .context("controlled Codex TUI ended before control resume")?; - wait_for_tui_loaded_thread(&mut websocket, thread_id, STARTUP_TIMEOUT)?; + wait_for_tui_loaded_thread(&mut websocket, thread_id, tui_loaded_timeout)?; let (diagnostic_tx, diagnostic_rx) = mpsc::channel(); events .send(ControlEvent::TuiThreadLoaded(diagnostic_tx)) @@ -2098,6 +2118,11 @@ mod tests { ); } + #[test] + fn tui_loaded_deadline_precedes_the_outer_binding_deadline() { + assert!(TUI_LOADED_TIMEOUT < STARTUP_TIMEOUT); + } + fn delivery_config(root: &Path) -> CodexDeliveryConfig { let agent_dir = root.join("agents/h/worker"); CodexDeliveryConfig { @@ -2747,7 +2772,11 @@ mod tests { &binding_for_pump, &control_state_for_pump, &runtime_for_pump, - Some(("thread-main", resume_ready_rx)), + Some(ControlResume { + thread_id: "thread-main", + ready: resume_ready_rx, + tui_loaded_timeout: TUI_LOADED_TIMEOUT, + }), Some(config), tx, ) @@ -2983,7 +3012,11 @@ mod tests { &binding_for_pump, &control_state_for_pump, &runtime_for_pump, - Some(("thread-prior", resume_ready_rx)), + Some(ControlResume { + thread_id: "thread-prior", + ready: resume_ready_rx, + tui_loaded_timeout: TUI_LOADED_TIMEOUT, + }), None, tx, ) @@ -3012,6 +3045,79 @@ mod tests { assert_eq!(state.observed(), &CodexObservedState::Idle); } + #[test] + fn tui_loaded_timeout_reports_the_specific_failure_before_outer_binding_timeout() { + let tmp = tempfile::tempdir().unwrap(); + let socket = tmp.path().join("server.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let server = thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut websocket = tungstenite::accept(stream).unwrap(); + assert_eq!( + read_json_message(&mut websocket).unwrap().unwrap()["method"], + "initialize" + ); + write_json_message( + &mut websocket, + &json!({ "id": 0, "result": { "userAgent": "fake" } }), + ) + .unwrap(); + assert_eq!( + read_json_message(&mut websocket).unwrap().unwrap()["method"], + "initialized" + ); + let loaded = read_json_message(&mut websocket).unwrap().unwrap(); + assert_eq!(loaded["method"], "thread/loaded/list"); + write_json_message( + &mut websocket, + &json!({ + "id": CONTROL_TUI_LOADED_REQUEST_ID, + "result": { "data": [] } + }), + ) + .unwrap(); + thread::sleep(Duration::from_millis(250)); + }); + + let stream = UnixStream::connect(&socket).unwrap(); + let shutdown = stream.try_clone().unwrap(); + let websocket = initialize_control(stream).unwrap(); + let binding_path = tmp.path().join("state/binding.json"); + let control_state_path = tmp.path().join("state/control-state.json"); + let runtime = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(); + let (tx, rx) = mpsc::channel(); + let (resume_ready_tx, resume_ready_rx) = mpsc::channel(); + let pump = thread::spawn(move || { + pump_control( + websocket, + &binding_path, + &control_state_path, + &runtime, + Some(ControlResume { + thread_id: "thread-prior", + ready: resume_ready_rx, + tui_loaded_timeout: Duration::from_millis(50), + }), + None, + tx, + ) + }); + resume_ready_tx.send(()).unwrap(); + let ControlEvent::Failed(error) = rx.recv_timeout(Duration::from_secs(2)).unwrap() else { + panic!("inner TUI-loaded deadline did not report its specific failure"); + }; + assert!( + error.contains( + "controlled Codex TUI did not load preserved thread thread-prior before control resume" + ), + "unexpected control failure: {error}" + ); + + let _ = shutdown.shutdown(Shutdown::Both); + pump.join().unwrap(); + server.join().unwrap(); + } + #[test] fn missing_saved_rollout_fails_without_rebinding_the_incarnation() { let tmp = tempfile::tempdir().unwrap(); @@ -3080,7 +3186,11 @@ mod tests { &binding_for_pump, &control_state_for_pump, &runtime_for_pump, - Some(("thread-prior", resume_ready_rx)), + Some(ControlResume { + thread_id: "thread-prior", + ready: resume_ready_rx, + tui_loaded_timeout: TUI_LOADED_TIMEOUT, + }), None, tx, ) From 0246032fe6420723bd9c58aa134413927cfaca93 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Wed, 12 Aug 2026 02:01:57 +0200 Subject: [PATCH 22/56] Project hook trust for remote resume --- src/codex_app_server.rs | 375 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 374 insertions(+), 1 deletion(-) diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index bf83264c..25ebda12 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -9,6 +9,7 @@ //! inbox head and submits typed input only when that state proves an idle or one exact regular //! active turn. +use std::collections::BTreeMap; use std::fs::{self, File, OpenOptions}; use std::io::{Read as _, Write}; use std::net::Shutdown; @@ -42,6 +43,7 @@ const WRAPPER_DIAGNOSTIC_SCHEMA: &str = "st2.codex-wrapper-diagnostic.v1"; const CONTROL_TUI_LOADED_REQUEST_ID: u64 = 0; const CONTROL_SUBSCRIBE_REQUEST_ID: u64 = 1; const FIRST_DELIVERY_REQUEST_ID: u64 = 2; +const HOOK_TRUST_PREFLIGHT_REQUEST_ID: u64 = 1; // The inner provider result must reach the wrapper before the outer ownership wait expires. const TUI_LOADED_TIMEOUT: Duration = Duration::from_secs(15); const STARTUP_TIMEOUT: Duration = Duration::from_secs(30); @@ -1004,7 +1006,20 @@ fn run_controlled_owned( .mode(0o600) .open(state_dir.join("app-server.log"))?; let endpoint = format!("unix://{}", socket_path.display()); - let server_args = controlled_app_server_args(&endpoint, &codex_argv[1..])?; + let mut server_args = controlled_app_server_args(&endpoint, &codex_argv[1..])?; + if resume_thread.is_some() && authored_bypasses_hook_trust(&codex_argv[1..]) { + let hook_cwd = controlled_hook_cwd(&codex_argv[1..])?; + if let Some(projection) = preflight_hook_trust( + &codex_argv[0], + &server_args, + &socket_path, + &hook_cwd, + &log, + diagnostics, + )? { + insert_app_server_config_override(&mut server_args, projection.override_value)?; + } + } diagnostics.record("appServerStarting", json!({}))?; let mut server = Command::new(&codex_argv[0]) .args(server_args) @@ -1192,6 +1207,228 @@ fn controlled_app_server_args(endpoint: &str, authored_args: &[String]) -> Resul Ok(args) } +fn authored_bypasses_hook_trust(authored_args: &[String]) -> bool { + authored_args + .iter() + .any(|argument| argument == "--dangerously-bypass-hook-trust") +} + +/// Resolve the workspace whose non-managed hooks the remote TUI reviews before a resume. +/// +/// st2 starts the wrapper in the declared workspace. An explicit Codex `--cd`/`-C` overrides it, +/// and the last occurrence wins just as the provider CLI does. The path must already exist because +/// both project-layer discovery and remote resume require a real directory. +fn controlled_hook_cwd(authored_args: &[String]) -> Result { + let boundary = interactive_root_prefix_end(authored_args)?; + let mut selected = std::env::current_dir().context("reading controlled Codex workspace")?; + let mut index = 0; + while index < boundary { + let argument = authored_args[index].as_str(); + if matches!(argument, "-C" | "--cd") { + selected = PathBuf::from(&authored_args[index + 1]); + index += 2; + continue; + } + if let Some(value) = argument.strip_prefix("--cd=") { + selected = PathBuf::from(value); + } else if let Some(value) = argument.strip_prefix("-C") + && !value.is_empty() + { + selected = PathBuf::from(value); + } + index += if matches!( + argument, + "-c" | "--config" + | "--enable" + | "--disable" + | "--remote-auth-token-env" + | "-m" + | "--model" + | "--local-provider" + | "-p" + | "--profile" + | "-s" + | "--sandbox" + | "--add-dir" + | "-a" + | "--ask-for-approval" + ) { + 2 + } else { + 1 + }; + } + if selected.is_relative() { + selected = std::env::current_dir() + .context("reading controlled Codex workspace")? + .join(selected); + } + fs::canonicalize(&selected).with_context(|| { + format!( + "resolving controlled Codex workspace {}", + selected.display() + ) + }) +} + +#[derive(Debug)] +struct HookTrustProjection { + override_value: String, + count: usize, +} + +/// Codex 0.145/0.146 deliberately ignores the hook-trust bypass for startup review on every +/// persistent remote resume. Before the owning TUI starts, ask the same exact provider binary for +/// its typed hook keys and hashes, then project those hashes into the final app-server's session +/// flags. This implements the authored one-invocation bypass without writing persisted trust. +fn preflight_hook_trust( + codex: &str, + server_args: &[String], + socket_path: &Path, + cwd: &Path, + log: &File, + diagnostics: &mut WrapperDiagnostics, +) -> Result> { + diagnostics.record("hookTrustPreflightStarting", json!({}))?; + let mut server = Command::new(codex) + .args(server_args) + .stdin(Stdio::null()) + .stdout(log.try_clone()?) + .stderr(log.try_clone()?) + .spawn() + .with_context(|| format!("starting {codex} hook-trust preflight app-server"))?; + let result = diagnostics + .record("hookTrustPreflightStarted", json!({ "pid": server.id() })) + .and_then(|_| { + let control = connect_control(&mut server, socket_path, STARTUP_TIMEOUT)?; + let mut websocket = initialize_control(control)?; + query_hook_trust_projection(&mut websocket, cwd) + }); + terminate_child(&mut server); + let _ = fs::remove_file(socket_path); + let projection = result?; + diagnostics.record( + "hookTrustPreflightComplete", + json!({ "projectedHookCount": projection.as_ref().map_or(0, |value| value.count) }), + )?; + Ok(projection) +} + +fn query_hook_trust_projection( + websocket: &mut WebSocket, + cwd: &Path, +) -> Result> { + write_json_message( + websocket, + &json!({ + "method": "hooks/list", + "id": HOOK_TRUST_PREFLIGHT_REQUEST_ID, + "params": { "cwds": [cwd.to_string_lossy()] }, + }), + )?; + websocket + .get_ref() + .set_read_timeout(Some(STARTUP_TIMEOUT))?; + let response = loop { + let message = read_json_message(websocket)? + .context("Codex app-server closed during hook-trust preflight")?; + if message.get("id") == Some(&Value::from(HOOK_TRUST_PREFLIGHT_REQUEST_ID)) { + break message; + } + }; + if let Some(error) = response.get("error") { + anyhow::bail!("Codex app-server rejected hooks/list preflight: {error}"); + } + hook_trust_projection_from_response(&response, cwd) +} + +fn hook_trust_projection_from_response( + response: &Value, + cwd: &Path, +) -> Result> { + let data = response + .pointer("/result/data") + .and_then(Value::as_array) + .context("Codex hooks/list preflight response has no typed data")?; + anyhow::ensure!( + data.len() == 1, + "Codex hooks/list preflight returned {} cwd entries instead of one", + data.len() + ); + let entry = &data[0]; + anyhow::ensure!( + entry.get("cwd").and_then(Value::as_str) == Some(cwd.to_string_lossy().as_ref()), + "Codex hooks/list preflight returned a different cwd" + ); + let hooks = entry + .get("hooks") + .and_then(Value::as_array) + .context("Codex hooks/list preflight cwd entry has no typed hooks")?; + let mut projected = BTreeMap::new(); + for hook in hooks { + let status = hook + .get("trustStatus") + .and_then(Value::as_str) + .context("Codex hooks/list preflight hook has no trustStatus")?; + match status { + "trusted" | "managed" => continue, + "untrusted" | "modified" => {} + other => { + anyhow::bail!("Codex hooks/list preflight returned unknown trustStatus '{other}'") + } + } + anyhow::ensure!( + hook.get("isManaged").and_then(Value::as_bool) == Some(false), + "Codex hooks/list preflight returned a managed hook requiring trust" + ); + let key = hook + .get("key") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .context("Codex hooks/list preflight hook has no non-empty key")?; + let current_hash = hook + .get("currentHash") + .and_then(Value::as_str) + .filter(|value| value.starts_with("sha256:") && value.len() > "sha256:".len()) + .context("Codex hooks/list preflight hook has no typed currentHash")?; + if let Some(previous) = projected.insert(key.to_string(), current_hash.to_string()) { + anyhow::ensure!( + previous == current_hash, + "Codex hooks/list preflight returned conflicting hashes for one hook key" + ); + } + } + if projected.is_empty() { + return Ok(None); + } + + let mut state = toml::Table::new(); + for (key, current_hash) in projected { + let mut trust = toml::Table::new(); + trust.insert( + "trusted_hash".to_string(), + toml::Value::String(current_hash), + ); + state.insert(key, toml::Value::Table(trust)); + } + Ok(Some(HookTrustProjection { + count: state.len(), + override_value: format!("hooks.state={}", toml::Value::Table(state)), + })) +} + +fn insert_app_server_config_override( + server_args: &mut Vec, + override_value: String, +) -> Result<()> { + let listen = server_args + .iter() + .position(|argument| argument == "--listen") + .context("controlled Codex app-server argv has no --listen boundary")?; + server_args.splice(listen..listen, ["-c".to_string(), override_value]); + Ok(()) +} + fn controlled_tui_args( endpoint: &str, authored_args: &[String], @@ -3580,6 +3817,142 @@ mod tests { ); } + #[test] + fn remote_resume_projects_exact_hook_hashes_without_persisted_state() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = fs::canonicalize(tmp.path()).unwrap(); + let source = cwd.join(".codex/hooks.json"); + let untrusted_key = format!("{}:session_start:0:0", source.display()); + let modified_key = format!("{}:stop:1:0", source.display()); + let response = json!({ + "id": HOOK_TRUST_PREFLIGHT_REQUEST_ID, + "result": { + "data": [{ + "cwd": cwd, + "hooks": [ + { + "key": untrusted_key, + "currentHash": "sha256:one", + "trustStatus": "untrusted", + "isManaged": false, + "enabled": true + }, + { + "key": modified_key, + "currentHash": "sha256:two", + "trustStatus": "modified", + "isManaged": false, + "enabled": false + }, + { + "key": "already-trusted", + "currentHash": "sha256:three", + "trustStatus": "trusted", + "isManaged": false, + "enabled": true + }, + { + "key": "managed", + "currentHash": "sha256:four", + "trustStatus": "managed", + "isManaged": true, + "enabled": true + } + ] + }] + } + }); + + let projection = hook_trust_projection_from_response(&response, &cwd) + .unwrap() + .unwrap(); + assert_eq!(projection.count, 2); + let parsed: toml::Value = toml::from_str(&projection.override_value).unwrap(); + let state = parsed + .get("hooks") + .and_then(|hooks| hooks.get("state")) + .and_then(toml::Value::as_table) + .unwrap(); + assert_eq!( + state[&untrusted_key]["trusted_hash"].as_str(), + Some("sha256:one") + ); + assert_eq!( + state[&modified_key]["trusted_hash"].as_str(), + Some("sha256:two") + ); + assert!(!state.contains_key("already-trusted")); + assert!(!state.contains_key("managed")); + + let mut args = controlled_app_server_args( + "unix:///server.sock", + &["--dangerously-bypass-hook-trust".into(), "boot".into()], + ) + .unwrap(); + insert_app_server_config_override(&mut args, projection.override_value).unwrap(); + assert_eq!(args[args.len() - 4], "-c"); + assert!(args[args.len() - 3].starts_with("hooks.state=")); + assert_eq!(&args[args.len() - 2..], ["--listen", "unix:///server.sock"]); + } + + #[test] + fn hook_trust_projection_fails_closed_on_provider_shape_drift() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = fs::canonicalize(tmp.path()).unwrap(); + let response = json!({ + "result": { + "data": [{ + "cwd": cwd, + "hooks": [{ + "key": "hook", + "currentHash": "not-a-provider-hash", + "trustStatus": "untrusted", + "isManaged": false + }] + }] + } + }); + let error = hook_trust_projection_from_response(&response, &cwd).unwrap_err(); + assert!(error.to_string().contains("typed currentHash")); + + let response = json!({ + "result": { + "data": [{ + "cwd": cwd, + "hooks": [{ + "key": "hook", + "currentHash": "sha256:value", + "trustStatus": "future-status", + "isManaged": false + }] + }] + } + }); + let error = hook_trust_projection_from_response(&response, &cwd).unwrap_err(); + assert!(error.to_string().contains("unknown trustStatus")); + } + + #[test] + fn hook_preflight_uses_the_explicit_controlled_workspace() { + let tmp = tempfile::tempdir().unwrap(); + let explicit = tmp.path().join("workspace"); + fs::create_dir(&explicit).unwrap(); + assert_eq!( + controlled_hook_cwd(&[ + "--dangerously-bypass-hook-trust".into(), + "--cd".into(), + explicit.display().to_string(), + "boot".into(), + ]) + .unwrap(), + fs::canonicalize(explicit).unwrap() + ); + assert!(authored_bypasses_hook_trust(&[ + "--dangerously-bypass-hook-trust".into(), + "boot".into() + ])); + } + #[test] fn app_server_configuration_extraction_fails_closed_at_ambiguous_boundaries() { let missing = From 4aa4a3da5b7192461914312043478f68df4e6a59 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Wed, 12 Aug 2026 02:19:25 +0200 Subject: [PATCH 23/56] Constrain transient hook trust authorization --- src/codex_app_server.rs | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index 25ebda12..6fed2d89 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -1007,7 +1007,7 @@ fn run_controlled_owned( .open(state_dir.join("app-server.log"))?; let endpoint = format!("unix://{}", socket_path.display()); let mut server_args = controlled_app_server_args(&endpoint, &codex_argv[1..])?; - if resume_thread.is_some() && authored_bypasses_hook_trust(&codex_argv[1..]) { + if resume_thread.is_some() && authored_bypasses_hook_trust(&codex_argv[1..])? { let hook_cwd = controlled_hook_cwd(&codex_argv[1..])?; if let Some(projection) = preflight_hook_trust( &codex_argv[0], @@ -1207,10 +1207,11 @@ fn controlled_app_server_args(endpoint: &str, authored_args: &[String]) -> Resul Ok(args) } -fn authored_bypasses_hook_trust(authored_args: &[String]) -> bool { - authored_args +fn authored_bypasses_hook_trust(authored_args: &[String]) -> Result { + let boundary = interactive_root_prefix_end(authored_args)?; + Ok(authored_args[..boundary] .iter() - .any(|argument| argument == "--dangerously-bypass-hook-trust") + .any(|argument| argument == "--dangerously-bypass-hook-trust")) } /// Resolve the workspace whose non-managed hooks the remote TUI reviews before a resume. @@ -3947,10 +3948,17 @@ mod tests { .unwrap(), fs::canonicalize(explicit).unwrap() ); - assert!(authored_bypasses_hook_trust(&[ - "--dangerously-bypass-hook-trust".into(), - "boot".into() - ])); + assert!( + authored_bypasses_hook_trust(&[ + "--dangerously-bypass-hook-trust".into(), + "boot".into() + ]) + .unwrap() + ); + assert!( + !authored_bypasses_hook_trust(&["--".into(), "--dangerously-bypass-hook-trust".into()]) + .unwrap() + ); } #[test] From 5ec6e022b52662d2820060ce94cb16e39222ff1d Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Wed, 12 Aug 2026 15:00:02 +0200 Subject: [PATCH 24/56] Add Claude-owned MCP inbox watcher Proven on a real Claude session: the rendered MCP declaration caused Claude to spawn the watcher as its child; killing Claude removed the watcher, with no st2 task or DING sidecar. A self-authored distinctive token reached the model, which replied with the exact body token and archived the original. The first token run was blocked by a nonexistent scratch sender identity, a test-rig defect rather than a transport defect. The watcher refreshes native presence in its session loop. Known limit: if Claude hangs without closing stdio, the watcher remains attached to that hung session; no external heartbeat or supervisor is added. --- src/claude_mcp.rs | 138 +++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/main.rs | 11 ++++ src/materialize.rs | 53 ++++++++++++++++- src/reconcile.rs | 6 +- 5 files changed, 205 insertions(+), 4 deletions(-) create mode 100644 src/claude_mcp.rs diff --git a/src/claude_mcp.rs b/src/claude_mcp.rs new file mode 100644 index 00000000..b2dc6670 --- /dev/null +++ b/src/claude_mcp.rs @@ -0,0 +1,138 @@ +//! Minimal Claude channel watcher. +//! +//! The inbox is the durable source of truth. This process keeps only an ephemeral set of +//! filenames delivered during its current lifetime; a restart scans the inbox again. + +use std::collections::HashSet; +use std::io::{self, BufRead, Write}; +use std::path::Path; +use std::sync::mpsc::{self, RecvTimeoutError}; +use std::thread; +use std::time::{Duration, Instant}; + +use anyhow::{Context as _, Result}; +use serde_json::{Value, json}; + +use crate::message; + +const POLL: Duration = Duration::from_millis(250); + +fn channel_content(subject: Option<&str>, body: &str) -> String { + match subject.filter(|value| !value.is_empty()) { + Some(subject) => format!("Subject: {subject}\n\n{body}"), + None => body.to_owned(), + } +} + +pub fn run(catalog_root: &Path, identity: &str) -> Result<()> { + let agent_dir = message::resolve_agent_dir(catalog_root, identity, &crate::run::detect_host())? + .with_context(|| format!("Claude MCP agent '{identity}' is not declared"))?; + let inbox = message::inbox_dir(&agent_dir); + let status_path = crate::status::status_path(&agent_dir); + let (input_tx, input_rx) = mpsc::channel(); + thread::spawn(move || { + for line in io::stdin().lock().lines() { + if input_tx.send(line).is_err() { + break; + } + } + }); + let mut stdout = io::BufWriter::new(io::stdout().lock()); + let mut delivered = HashSet::new(); + let mut initialized = false; + let mut next_status_refresh = Instant::now(); + loop { + if Instant::now() >= next_status_refresh { + let _ = crate::status::refresh(&status_path); + next_status_refresh = Instant::now() + crate::status::STATUS_REFRESH; + } + match input_rx.recv_timeout(POLL) { + Ok(line) => { + let line = line.context("reading Claude MCP input")?; + if line.trim().is_empty() { + continue; + } + let request: Value = + serde_json::from_str(&line).context("decoding Claude MCP JSON")?; + match request.get("method").and_then(Value::as_str) { + Some("initialize") => { + let id = request.get("id").cloned().unwrap_or(Value::Null); + write_json( + &mut stdout, + &json!({"jsonrpc":"2.0","id":id,"result":{ + "protocolVersion": request.pointer("/params/protocolVersion").and_then(Value::as_str).unwrap_or("2025-06-18"), + "capabilities":{"tools":{},"experimental":{"claude/channel":{}}}, + "serverInfo":{"name":"st2","version":env!("CARGO_PKG_VERSION")} + }}), + )?; + } + Some("notifications/initialized") => { + initialized = true; + } + Some("tools/list") | Some("resources/list") | Some("prompts/list") => { + if let Some(id) = request.get("id") { + let field = if request["method"] == "tools/list" { + "tools" + } else if request["method"] == "resources/list" { + "resources" + } else { + "prompts" + }; + write_json( + &mut stdout, + &json!({"jsonrpc":"2.0","id":id,"result":{field:[]}}), + )?; + } + } + Some("ping") => { + if let Some(id) = request.get("id") { + write_json(&mut stdout, &json!({"jsonrpc":"2.0","id":id,"result":{}}))?; + } + } + _ => {} + } + } + Err(RecvTimeoutError::Timeout) => {} + // Claude owns this child over stdio. EOF is the session-lifetime + // boundary, so do not leave a detached watcher behind. + Err(RecvTimeoutError::Disconnected) => return Ok(()), + } + if initialized { + for msg in message::list_inbox(&inbox)? { + if delivered.insert(msg.filename.clone()) { + let content = channel_content(msg.subject.as_deref(), &msg.body); + write_json( + &mut stdout, + &json!({"jsonrpc":"2.0","method":"notifications/claude/channel","params":{ + "content": content, + "meta":{"from":msg.from,"messageFilename":msg.filename,"threadFilename":msg.in_reply_to.unwrap_or_else(|| msg.filename.clone()),"identity":identity} + }}), + )?; + } + } + } + stdout.flush()?; + thread::sleep(POLL); + } +} + +fn write_json(out: &mut impl Write, value: &Value) -> Result<()> { + serde_json::to_writer(&mut *out, value)?; + out.write_all(b"\n")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::channel_content; + + #[test] + fn channel_content_reuses_subject_and_body_envelope() { + assert_eq!( + channel_content(Some("subject"), "body"), + "Subject: subject\n\nbody" + ); + assert_eq!(channel_content(None, "body"), "body"); + assert_eq!(channel_content(Some(""), "body"), "body"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 8495b19d..c1f19f79 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ pub mod agents; pub mod catalog; pub mod catalog_lock; pub mod catalog_transaction; +pub mod claude_mcp; pub mod codex_app_server; pub mod context; pub mod ding; diff --git a/src/main.rs b/src/main.rs index 1f34f2a2..0fdec006 100644 --- a/src/main.rs +++ b/src/main.rs @@ -125,6 +125,12 @@ enum Command { #[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)] codex_argv: Vec, }, + /// Internal Claude MCP channel server started by Claude from its rendered project declaration. + #[command(hide = true)] + ClaudeMcp { + #[arg(long)] + identity: String, + }, /// Get or set an agent's presence status. No `--set` prints the status; no identity means yours /// (`$ST_AGENT`). Settable: offline | available | busy | away | dnd (`unknown` is derived). Status { @@ -845,6 +851,11 @@ fn main() -> Result<()> { codex_argv, ) } + Command::ClaudeMcp { identity } => { + let catalog = catalog_arg(None)?; + let catalog = catalog.canonicalize().unwrap_or(catalog); + st2::claude_mcp::run(&catalog, &identity) + } Command::Status { identity, set, ctx } => status_cmd(identity, set, ctx), Command::Rename(args) => presentation_cmd(st2::agent_author::PresentationField::Name, args), Command::Describe(args) => { diff --git a/src/materialize.rs b/src/materialize.rs index aaca96ec..cee8f905 100644 --- a/src/materialize.rs +++ b/src/materialize.rs @@ -245,7 +245,30 @@ pub(crate) fn catalog_owned_render_inputs( spec: &AgentSpec, this_host: &str, ) -> Result> { - let plan = parse_plan(spec)?; + let mut plan = parse_plan(spec)?; + if spec.delivery == Some(agent_spec::spec::DeliveryTransport::Mcp) { + // Claude owns this child: the project MCP declaration is rendered into + // the workspace and Claude starts the stdio server from it. st2 never + // reconciles or supervises the watcher as a sibling task. + let executable = std::env::current_exe() + .context("resolving st2 executable for Claude MCP declaration")?; + let catalog = root.display().to_string(); + let identity = spec.bus_id(this_host); + let content = serde_json::json!({ + "mcpServers": { + "st2": { + "type": "stdio", + "command": executable.to_string_lossy(), + "args": ["--catalog", catalog, "claude-mcp", "--identity", identity] + } + } + }) + .to_string(); + plan.ops.push(RenderOp::JsonUpsert { + destination: ".mcp.json".into(), + content, + }); + } let env = render_env(root, spec, this_host); let spec_dir = spec.path.parent().unwrap_or(root); let mut inputs = BTreeSet::new(); @@ -506,7 +529,19 @@ fn claims_for_agent( spec: &AgentSpec, this_host: &str, ) -> Result>> { - let plan = parse_plan(spec)?; + let mut plan = parse_plan(spec)?; + if spec.delivery == Some(agent_spec::spec::DeliveryTransport::Mcp) { + let executable = std::env::current_exe() + .context("resolving st2 executable for Claude MCP declaration")?; + let content = serde_json::json!({ + "mcpServers": {"st2": { + "type": "stdio", + "command": executable.to_string_lossy(), + "args": ["--catalog", root.display().to_string(), "claude-mcp", "--identity", spec.bus_id(this_host)] + }} + }).to_string(); + plan.ops.push(RenderOp::JsonUpsert { destination: ".mcp.json".into(), content }); + } if plan.ops.is_empty() { return Ok(BTreeMap::new()); } @@ -618,7 +653,19 @@ pub fn render_ownership_conflicts( /// Execute one agent's render plan in declaration order. pub fn materialize_agent(root: &Path, spec: &AgentSpec, this_host: &str) -> Result> { crate::reconcile::validate_task_identities(std::slice::from_ref(spec), this_host)?; - let plan = parse_plan(spec)?; + let mut plan = parse_plan(spec)?; + if spec.delivery == Some(agent_spec::spec::DeliveryTransport::Mcp) { + let executable = std::env::current_exe() + .context("resolving st2 executable for Claude MCP declaration")?; + let content = serde_json::json!({ + "mcpServers": {"st2": { + "type": "stdio", + "command": executable.to_string_lossy(), + "args": ["--catalog", root.display().to_string(), "claude-mcp", "--identity", spec.bus_id(this_host)] + }} + }).to_string(); + plan.ops.push(RenderOp::JsonUpsert { destination: ".mcp.json".into(), content }); + } if plan.ops.is_empty() { return Ok(Vec::new()); } diff --git a/src/reconcile.rs b/src/reconcile.rs index d2d0b45a..90340a22 100644 --- a/src/reconcile.rs +++ b/src/reconcile.rs @@ -86,7 +86,11 @@ pub fn compile_generated_tasks( context: &TaskCompileContext, ) -> Result<()> { compile_generated_ding_tasks(specs, this_host, context)?; - compile_app_server_agent_tasks(specs, this_host, context) + compile_app_server_agent_tasks(specs, this_host, context)?; + // Claude's MCP server is declared to Claude itself. It must not be lowered + // to an st2-owned companion task: that would give the supervisor a second + // lifetime to manage and break session ownership across restart. + Ok(()) } /// Replace only runner-generated DING markers with exact direct argv. Authored tasks never carry From 2c1de675dc5ea134be319cc786e03637da94fd3c Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Wed, 12 Aug 2026 15:00:09 +0200 Subject: [PATCH 25/56] Clarify Codex native ownership boundary The current app-server transport is implemented, but the intended destination is a session-owned watcher over durable inbox files, as specified for Claude; the control protocol is expected to be replaced by that shape. --- docs/vrs/01-ding/02-codex/spec.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/vrs/01-ding/02-codex/spec.md b/docs/vrs/01-ding/02-codex/spec.md index 3044bead..6c7910e6 100644 --- a/docs/vrs/01-ding/02-codex/spec.md +++ b/docs/vrs/01-ding/02-codex/spec.md @@ -1,5 +1,9 @@ # Codex harness specification +This document describes the transport as implemented today. The intended destination is that st2 +writes inbox files and a watcher owned by the session pushes them into the provider channel, as +specified for Claude. The control protocol described below is expected to be replaced by that shape. + The screen grammar by which DING recognizes a Codex composer. It realizes [`../requirements.md`](../requirements.md) through the mechanism in [`../spec.md`](../spec.md). From 4572156fb37fd991258a3b0947250a65a580a5ed Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Wed, 12 Aug 2026 16:46:27 +0200 Subject: [PATCH 26/56] Refresh Codex native presence from session loop The Codex app-server delivery loop owns the live provider session, so it now refreshes status::refresh alongside its inbox poll. This preserves busy/available and lets dnd age out without a sidecar or agent timer. Before this change converted app-server agents left presence stale; after it the session loop renews presence. --- src/codex_app_server.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index 6fed2d89..d57fd0ab 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -359,6 +359,9 @@ impl CodexInboxDelivery { if !due { return Ok(()); } + // Native delivery owns the live provider session, so it also owns the + // presence lease. Preserve busy/available and let dnd age out. + let _ = status::refresh(&status::status_path(&self.config.agent_dir)); let unread = message::list_inbox(&self.config.inbox)?; if self.state.as_ref().is_some_and(|state| { unread From 7b22c3bcffe5c79da36c859894cd21fa6974d70a Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Thu, 13 Aug 2026 20:51:48 +0200 Subject: [PATCH 27/56] Retry transient Codex handshake reads Darwin can report a timed Unix-socket read as EAGAIN/EWOULDBLOCK while the peer is briefly descheduled. Handshake reads now retry that transient for a bounded five-second window, while poll reads retain their timeout semantics. This targets the macOS parallel-test failures without serializing tests or changing FIFO/product delivery behavior. --- src/codex_app_server.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index d57fd0ab..874a5182 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -2253,12 +2253,25 @@ fn write_json_message(websocket: &mut WebSocket, value: &Value) -> R } fn read_json_message(websocket: &mut WebSocket) -> Result> { + // Darwin reports a timed Unix-socket read as EAGAIN/EWOULDBLOCK. During + // handshake the peer may briefly be descheduled; treat that transient as + // retryable instead of turning scheduler timing into a protocol failure. + let deadline = Instant::now() + Duration::from_secs(5); loop { let message = match websocket.read() { Ok(message) => message, Err(tungstenite::Error::ConnectionClosed | tungstenite::Error::AlreadyClosed) => { return Ok(None); } + Err(tungstenite::Error::Io(error)) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) && Instant::now() < deadline => + { + thread::sleep(Duration::from_millis(10)); + continue; + } Err(error) => return Err(error.into()), }; match message { From b8c61dbbfc8cf79bd5b6af0584d579f976685347 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Thu, 13 Aug 2026 20:56:46 +0200 Subject: [PATCH 28/56] Give Darwin Codex fixtures scheduler headroom The remaining macOS failures use test-only two-second peer/readiness budgets. Increase those fixture waits to ten seconds so parallel Darwin scheduling cannot turn a healthy handshake into a timeout. No production timeout or delivery behavior changes. --- src/codex_app_server.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index 874a5182..7aab1d46 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -2801,7 +2801,9 @@ mod tests { let server = thread::spawn(move || { let (stream, _) = listener.accept().unwrap(); stream - .set_read_timeout(Some(Duration::from_secs(2))) + // Parallel Darwin test runs can deschedule the in-process peer + // for longer than the Linux-oriented two-second budget. + .set_read_timeout(Some(Duration::from_secs(10))) .unwrap(); let mut websocket = tungstenite::accept(stream).unwrap(); assert_eq!( @@ -2898,7 +2900,7 @@ mod tests { ) }); assert!(matches!( - rx.recv_timeout(Duration::from_secs(2)).unwrap(), + rx.recv_timeout(Duration::from_secs(10)).unwrap(), ControlEvent::Bound )); server.join().unwrap(); @@ -3037,7 +3039,7 @@ mod tests { }); acknowledge_tui_thread_loaded(&rx); assert!(matches!( - rx.recv_timeout(Duration::from_secs(2)).unwrap(), + rx.recv_timeout(Duration::from_secs(10)).unwrap(), ControlEvent::Bound )); server.join().unwrap(); From 331585d7f254e55a407eedef0bf4018494d3c8e4 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Thu, 13 Aug 2026 21:00:19 +0200 Subject: [PATCH 29/56] Report unexpected Codex control event ordering The Darwin control-initialization failure is an assertion mismatch, not a timeout. Make the fixture panic with the actual first ControlEvent so the next Darwin run identifies whether an early Observed, Closed, or Failed event precedes Bound. This is diagnostic only; no production event ordering changed. --- src/codex_app_server.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index 7aab1d46..0a941d67 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -3139,10 +3139,8 @@ mod tests { tx, ) }); - assert!(matches!( - rx.recv_timeout(Duration::from_secs(2)).unwrap(), - ControlEvent::Bound - )); + let first_event = rx.recv_timeout(Duration::from_secs(2)).unwrap(); + assert!(matches!(first_event, ControlEvent::Bound), "first control event: {first_event:?}"); server.join().unwrap(); let _ = shutdown.shutdown(Shutdown::Both); pump.join().unwrap(); From ff343fb3b8b1f58a541f72267d92b47b30a510f4 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Thu, 13 Aug 2026 21:03:08 +0200 Subject: [PATCH 30/56] Avoid sub-millisecond Darwin socket timeout On a loaded Darwin host, a nearly expired control deadline can leave a non-zero Duration that converts to a zero timeval. macOS rejects set_read_timeout with EINVAL while Linux accepts it. Treat remaining budget below one millisecond as deadline expiry before the syscall. This preserves the bounded deadline and fixes a real native resume/control path, rather than extending test timeouts. --- src/codex_app_server.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index 0a941d67..36356301 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -1703,7 +1703,7 @@ fn wait_for_tui_loaded_thread( loop { let remaining = deadline.saturating_duration_since(Instant::now()); anyhow::ensure!( - !remaining.is_zero(), + remaining >= Duration::from_millis(1), "controlled Codex TUI did not load preserved thread {expected_thread_id} before control resume" ); websocket From 7d8635acc564111244119a69842e3ace89be5952 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Thu, 13 Aug 2026 21:07:50 +0200 Subject: [PATCH 31/56] Add Codex control pump operation diagnostics Preserve behavior while attaching operation context to every fallible step in the native control pump: delivery initialization, timeout setup, polling, binding, resume, persistence, observation, subscription, and writes. Darwin EINVAL reports now identify the exact failing operation. --- src/codex_app_server.rs | 53 +++++++++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 21 deletions(-) diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index 36356301..c48d5af1 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -1787,14 +1787,19 @@ fn pump_control( .map(|config| { CodexInboxDelivery::new(config, delivery_state_path.clone(), runtime.clone()) }) - .transpose()?; - websocket.get_ref().set_read_timeout(Some(CONTROL_POLL))?; + .transpose() + .context("initializing Codex inbox delivery")?; + websocket + .get_ref() + .set_read_timeout(Some(CONTROL_POLL)) + .context("setting Codex control poll timeout")?; if let Some(thread_id) = expected_resume { resume_ready .context("saved Codex binding has no TUI-start gate")? .recv() .context("controlled Codex TUI ended before control resume")?; - wait_for_tui_loaded_thread(&mut websocket, thread_id, tui_loaded_timeout)?; + wait_for_tui_loaded_thread(&mut websocket, thread_id, tui_loaded_timeout) + .context("waiting for Codex TUI thread load")?; let (diagnostic_tx, diagnostic_rx) = mpsc::channel(); events .send(ControlEvent::TuiThreadLoaded(diagnostic_tx)) @@ -1809,11 +1814,12 @@ fn pump_control( "id": CONTROL_SUBSCRIBE_REQUEST_ID, "params": { "threadId": thread_id } }), - )?; + ) + .context("sending Codex thread resume request")?; subscription_pending = true; } loop { - let message = match poll_json_message(&mut websocket)? { + let message = match poll_json_message(&mut websocket).context("polling Codex control socket")? { ControlRead::Message(message) => Some(message), ControlRead::Timeout => None, ControlRead::Closed => { @@ -1825,7 +1831,8 @@ fn pump_control( if let (Some(state), Some(delivery)) = (control_state.as_ref(), delivery.as_mut()) && let Some(request) = delivery.maybe_request(state)? { - write_json_message(&mut websocket, &request)?; + write_json_message(&mut websocket, &request) + .context("sending Codex delivery request")?; } continue; }; @@ -1842,10 +1849,10 @@ fn pump_control( ); subscription_pending = false; let mut bound = CodexControlState::new(runtime, thread_id.to_string()); - match bound.accept_subscription(&message)? { + match bound.accept_subscription(&message).context("accepting Codex resume subscription")? { SubscriptionAcceptance::Accepted { .. } => { if let Some(delivery) = delivery.as_mut() { - delivery.reconcile_resume(&message, &bound)?; + delivery.reconcile_resume(&message, &bound).context("reconciling Codex resume delivery")?; } } SubscriptionAcceptance::Deferred => anyhow::bail!( @@ -1855,26 +1862,28 @@ fn pump_control( atomic_json( binding_path, &CodexThreadBinding::new(runtime, thread_id.to_string()), - )?; - atomic_json(control_state_path, &bound)?; + ) + .context("persisting Codex resume binding")?; + atomic_json(control_state_path, &bound).context("persisting Codex control state")?; control_state = Some(bound); let _ = events.send(ControlEvent::Bound); continue; } - let Some(thread_id) = binding_candidate(&message)? else { + let Some(thread_id) = binding_candidate(&message).context("reading Codex thread binding candidate")? else { continue; }; atomic_json( binding_path, &CodexThreadBinding::new(runtime, thread_id.to_string()), - )?; + ) + .context("persisting Codex fresh binding")?; let mut bound = CodexControlState::new(runtime, thread_id.to_string()); // A fresh control client that observes the owning TUI's `thread/started` // notification is already subscribed to that thread's broadcasts. Before its // first turn there is no persisted rollout for a redundant `thread/resume`. bound.subscribed = true; - atomic_json(control_state_path, &bound)?; + atomic_json(control_state_path, &bound).context("persisting Codex fresh control state")?; control_state = Some(bound); let _ = events.send(ControlEvent::Bound); } @@ -1884,8 +1893,8 @@ fn pump_control( .context("Codex control state is unbound")?; let delivery_response = match delivery.as_mut() { Some(delivery) => { - delivery.accept_response(&message, &state.observed)? - || delivery.accept_typed_receipt(&message, state)? + delivery.accept_response(&message, &state.observed).context("accepting Codex delivery response")? + || delivery.accept_typed_receipt(&message, state).context("accepting Codex typed receipt")? } None => false, }; @@ -1899,20 +1908,20 @@ fn pump_control( "Codex control received an unexpected thread/resume response" ); subscription_pending = false; - match state.accept_subscription(&message)? { + match state.accept_subscription(&message).context("accepting Codex subscription")? { SubscriptionAcceptance::Accepted { changed } => { if let Some(delivery) = delivery.as_mut() { - delivery.reconcile_resume(&message, state)?; + delivery.reconcile_resume(&message, state).context("reconciling Codex subscription delivery")?; } changed } SubscriptionAcceptance::Deferred => false, } } else { - state.observe(&message)? + state.observe(&message).context("observing Codex control event")? }; if changed { - atomic_json(control_state_path, state)?; + atomic_json(control_state_path, state).context("persisting Codex observed control state")?; let _ = events.send(ControlEvent::Observed); } if !state.subscribed @@ -1926,13 +1935,15 @@ fn pump_control( "id": CONTROL_SUBSCRIBE_REQUEST_ID, "params": { "threadId": state.thread_id } }), - )?; + ) + .context("sending Codex subscription request")?; subscription_pending = true; } if let Some(delivery) = delivery.as_mut() && let Some(request) = delivery.maybe_request(state)? { - write_json_message(&mut websocket, &request)?; + write_json_message(&mut websocket, &request) + .context("sending Codex delivery request")?; } } })(); From d5a4adc8a8124143e9bff4bc9227fafb39c97d47 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Thu, 13 Aug 2026 21:10:56 +0200 Subject: [PATCH 32/56] Treat Darwin closed-socket timeout as control closure Darwin can return EINVAL from set_read_timeout when the Unix control socket closes concurrently. The Codex pump now maps InvalidInput at its poll-timeout setup to ControlEvent::Closed, preserving the normal peer-gone path; other timeout setup errors retain context and fail. Diagnostic operation contexts remain. --- src/codex_app_server.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index c48d5af1..56b5b6b5 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -1789,10 +1789,15 @@ fn pump_control( }) .transpose() .context("initializing Codex inbox delivery")?; - websocket - .get_ref() - .set_read_timeout(Some(CONTROL_POLL)) - .context("setting Codex control poll timeout")?; + if let Err(error) = websocket.get_ref().set_read_timeout(Some(CONTROL_POLL)) { + if error.kind() == std::io::ErrorKind::InvalidInput { + // Darwin can reject setsockopt after the peer has closed the + // Unix socket. Treat that race as the normal closed path. + let _ = events.send(ControlEvent::Closed); + return Ok(()); + } + return Err(error).context("setting Codex control poll timeout"); + } if let Some(thread_id) = expected_resume { resume_ready .context("saved Codex binding has no TUI-start gate")? From 60e0d8c73bd7f58844c87de7a9c75b460ef4da56 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Thu, 13 Aug 2026 21:14:13 +0200 Subject: [PATCH 33/56] Drain buffered Codex frames after Darwin close Do not convert Darwin InvalidInput from poll-timeout setup into immediate Closed. Mark the peer closed, clear the read timeout, and continue reading so buffered WebSocket frames are processed before EOF. This preserves already-delivered control messages while still ending through the normal Closed path. --- src/codex_app_server.rs | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index 56b5b6b5..48408b7b 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -1782,6 +1782,7 @@ fn pump_control( }; let mut control_state: Option = None; let mut subscription_pending = false; + let mut peer_closed = false; let delivery_state_path = control_state_path.with_file_name("delivery-state.json"); let mut delivery = delivery .map(|config| { @@ -1789,15 +1790,6 @@ fn pump_control( }) .transpose() .context("initializing Codex inbox delivery")?; - if let Err(error) = websocket.get_ref().set_read_timeout(Some(CONTROL_POLL)) { - if error.kind() == std::io::ErrorKind::InvalidInput { - // Darwin can reject setsockopt after the peer has closed the - // Unix socket. Treat that race as the normal closed path. - let _ = events.send(ControlEvent::Closed); - return Ok(()); - } - return Err(error).context("setting Codex control poll timeout"); - } if let Some(thread_id) = expected_resume { resume_ready .context("saved Codex binding has no TUI-start gate")? @@ -1824,6 +1816,19 @@ fn pump_control( subscription_pending = true; } loop { + if !peer_closed { + if let Err(error) = websocket.get_ref().set_read_timeout(Some(CONTROL_POLL)) { + if error.kind() == std::io::ErrorKind::InvalidInput { + // Darwin can reject setsockopt after the peer has closed + // the Unix socket. Keep reading: buffered WebSocket + // frames must be processed before EOF is reported. + peer_closed = true; + let _ = websocket.get_ref().set_read_timeout(None); + } else { + return Err(error).context("setting Codex control poll timeout"); + } + } + } let message = match poll_json_message(&mut websocket).context("polling Codex control socket")? { ControlRead::Message(message) => Some(message), ControlRead::Timeout => None, From 89bba7d314c56ab98ac54eddf5df13d9b2731f35 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Thu, 13 Aug 2026 21:17:59 +0200 Subject: [PATCH 34/56] Instrument Codex TUI gate event flow Add diagnostic context around TUI-loaded polling and explicit traces before requests and TuiThreadLoaded emission. No timeout or drain behavior changes. This identifies whether the resume gate stalls before receiving the loaded-thread response or before emitting its acknowledgement event. --- src/codex_app_server.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index 48408b7b..deedf68f 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -1691,6 +1691,7 @@ fn wait_for_tui_loaded_thread( ) -> Result<()> { let deadline = Instant::now() + timeout; loop { + eprintln!("codex control: requesting TUI-loaded thread list"); write_json_message( websocket, &json!({ @@ -1709,7 +1710,9 @@ fn wait_for_tui_loaded_thread( websocket .get_ref() .set_read_timeout(Some(remaining.min(CONTROL_POLL)))?; - let message = match poll_json_message(websocket)? { + let message = match poll_json_message(websocket) + .context("polling Codex TUI-loaded response")? + { ControlRead::Message(message) => message, ControlRead::Timeout => continue, ControlRead::Closed => anyhow::bail!( @@ -1798,6 +1801,7 @@ fn pump_control( wait_for_tui_loaded_thread(&mut websocket, thread_id, tui_loaded_timeout) .context("waiting for Codex TUI thread load")?; let (diagnostic_tx, diagnostic_rx) = mpsc::channel(); + eprintln!("codex control: emitting TuiThreadLoaded"); events .send(ControlEvent::TuiThreadLoaded(diagnostic_tx)) .context("recording that the Codex TUI loaded the preserved thread")?; From 564bff62b66fb63e1c9a0d7c7af2200e9e629656 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Thu, 13 Aug 2026 21:21:48 +0200 Subject: [PATCH 35/56] Give Codex TUI gate fixture Darwin headroom The TUI-loaded event arrives promptly in isolation and at limited parallelism, but default Darwin contention can exceed the test-only two-second wait. Raise the fixture acknowledgement budget to ten seconds, matching the other Darwin scheduler headroom; no product timeout changes. --- src/codex_app_server.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index deedf68f..0c341cf9 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -2432,7 +2432,7 @@ mod tests { fn acknowledge_tui_thread_loaded(events: &Receiver) { let ControlEvent::TuiThreadLoaded(acknowledge) = - events.recv_timeout(Duration::from_secs(2)).unwrap() + events.recv_timeout(Duration::from_secs(10)).unwrap() else { panic!("control did not report the TUI-loaded gate"); }; From 44fad3b5f70c2f64d0168cba55413e7a339b8c18 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Thu, 13 Aug 2026 23:49:10 +0200 Subject: [PATCH 36/56] Expose native Claude and Codex driver commands Add st2 driver codex and st2 driver claude command surfaces that delegate to the existing controlled Codex app-server and Claude session-owned MCP paths. No KDL or catalog changes; behavior remains the current native implementations. --- src/main.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/main.rs b/src/main.rs index 0fdec006..a3835c98 100644 --- a/src/main.rs +++ b/src/main.rs @@ -86,6 +86,9 @@ enum Command { /// or refresh hooks. #[command(subcommand)] Hooks(HooksCmd), + /// Provider-native harness drivers. These commands preserve the current native launch paths. + #[command(subcommand)] + Driver(DriverCmd), /// The ding sidecar: watch an agent's `resources/inbox` and poke its pty (`[DING] …`) on each new /// message. Busy does not suppress delivery; only fresh dnd defers FIFO. A startup backlog is /// coalesced into one recovery notice. Long-running — st2 keeps it alive as a task alongside the @@ -299,6 +302,24 @@ enum Command { }, } +#[derive(Subcommand)] +enum DriverCmd { + /// Run the existing controlled Codex app-server path. + Codex { + #[arg(long)] + identity: String, + #[arg(long)] + runtime_id: String, + #[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)] + argv: Vec, + }, + /// Run the existing Claude session-owned MCP server over stdio. + Claude { + #[arg(long)] + identity: String, + }, +} + #[derive(Subcommand)] enum AgentCmd { /// Author reversible whole-agent lifecycle intent in one canonical KDL declaration. @@ -856,6 +877,16 @@ fn main() -> Result<()> { let catalog = catalog.canonicalize().unwrap_or(catalog); st2::claude_mcp::run(&catalog, &identity) } + Command::Driver(DriverCmd::Codex { identity, runtime_id, argv }) => { + let catalog = catalog_arg(None)?; + let catalog = catalog.canonicalize().unwrap_or(catalog); + st2::codex_app_server::run_controlled(&catalog, identity, runtime_id, argv) + } + Command::Driver(DriverCmd::Claude { identity }) => { + let catalog = catalog_arg(None)?; + let catalog = catalog.canonicalize().unwrap_or(catalog); + st2::claude_mcp::run(&catalog, &identity) + } Command::Status { identity, set, ctx } => status_cmd(identity, set, ctx), Command::Rename(args) => presentation_cmd(st2::agent_author::PresentationField::Name, args), Command::Describe(args) => { From ea438066a3c01bf77497dc815b17043ffd7819f5 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Fri, 14 Aug 2026 10:04:32 +0200 Subject: [PATCH 37/56] Add typed harness driver declarations --- crates/agent-spec/src/kdl_format.rs | 144 ++++++++++++++++++++++++++- crates/agent-spec/src/lib.rs | 5 +- crates/agent-spec/src/spec.rs | 80 ++++++++++++++- crates/agent-spec/tests/discovery.rs | 132 +++++++++++++++++++++++- src/eval_run.rs | 1 + src/lib.rs | 4 +- src/run.rs | 3 + tests/reconcile.rs | 1 + tests/run.rs | 1 + 9 files changed, 361 insertions(+), 10 deletions(-) diff --git a/crates/agent-spec/src/kdl_format.rs b/crates/agent-spec/src/kdl_format.rs index d4541af5..9b83cc27 100644 --- a/crates/agent-spec/src/kdl_format.rs +++ b/crates/agent-spec/src/kdl_format.rs @@ -8,7 +8,7 @@ //! ignored. use crate::declared::{DeclaredDocument, DeclaredNode, DeclaredValue}; -use crate::spec::{RawResource, RawRestart, RawSpec, RawTask}; +use crate::spec::{ClaudeDriver, CodexDriver, RawResource, RawRestart, RawSpec, RawTask}; /// Lower an already parsed declaration document into the runner's raw representation. pub(crate) fn lower_declared_document(document: &DeclaredDocument) -> anyhow::Result> { @@ -141,6 +141,20 @@ fn agent_node_to_raw(node: &DeclaredNode) -> anyhow::Result { .ok_or_else(|| anyhow::anyhow!("agent `deliver` value must be a string"))?, )); } + "claude" => { + anyhow::ensure!( + raw.driver.claude.is_none(), + "agent declares `claude` more than once" + ); + raw.driver.claude = Some(claude_driver_node_to_raw(child)?); + } + "codex" => { + anyhow::ensure!( + raw.driver.codex.is_none(), + "agent declares `codex` more than once" + ); + raw.driver.codex = Some(codex_driver_node_to_raw(child)?); + } "env" => {} "pty" => { if let Some(name) = arg_string(child) { @@ -165,6 +179,134 @@ fn agent_node_to_raw(node: &DeclaredNode) -> anyhow::Result { Ok(raw) } +fn driver_string(node: &DeclaredNode, provider: &str, field: &str) -> anyhow::Result { + anyhow::ensure!( + node.type_name.is_none() + && node.children.is_empty() + && node.entries.len() == 1 + && node.entries[0].name.is_none(), + "agent `{provider}.{field}` must contain exactly one positional string" + ); + node.argument(0) + .and_then(DeclaredValue::as_str) + .map(String::from) + .ok_or_else(|| { + anyhow::anyhow!("agent `{provider}.{field}` must contain exactly one positional string") + }) +} + +fn driver_args(node: &DeclaredNode, provider: &str) -> anyhow::Result> { + anyhow::ensure!( + node.type_name.is_none() + && node.children.is_empty() + && node.entries.iter().all(|entry| entry.name.is_none()), + "agent `{provider}.args` must contain only positional strings" + ); + node.arguments() + .map(|value| { + value.as_str().map(String::from).ok_or_else(|| { + anyhow::anyhow!("agent `{provider}.args` must contain only positional strings") + }) + }) + .collect() +} + +fn driver_bool(node: &DeclaredNode, provider: &str, field: &str) -> anyhow::Result { + anyhow::ensure!( + node.type_name.is_none() + && node.children.is_empty() + && node.entries.len() == 1 + && node.entries[0].name.is_none(), + "agent `{provider}.{field}` must contain exactly one positional bool" + ); + node.argument(0) + .and_then(DeclaredValue::as_bool) + .ok_or_else(|| { + anyhow::anyhow!("agent `{provider}.{field}` must contain exactly one positional bool") + }) +} + +type CommonDriverFields = (Option, Option, bool, String, Vec); + +fn common_driver_fields( + node: &DeclaredNode, + provider: &str, + allow_dev_channels: bool, +) -> anyhow::Result { + anyhow::ensure!( + node.type_name.is_none() && node.entries.is_empty(), + "agent `{provider}` must be a child block without entries" + ); + let mut model = None; + let mut effort = None; + let mut dev_channels = None; + let mut prompt = None; + let mut args = None; + for child in &node.children { + match child.name.as_str() { + "model" => { + anyhow::ensure!(model.is_none(), "agent `{provider}` has duplicate `model`"); + model = Some(driver_string(child, provider, "model")?); + } + "effort" => { + anyhow::ensure!( + effort.is_none(), + "agent `{provider}` has duplicate `effort`" + ); + effort = Some(driver_string(child, provider, "effort")?); + } + "dev-channels" if allow_dev_channels => { + anyhow::ensure!( + dev_channels.is_none(), + "agent `{provider}` has duplicate `dev-channels`" + ); + dev_channels = Some(driver_bool(child, provider, "dev-channels")?); + } + "prompt" => { + anyhow::ensure!( + prompt.is_none(), + "agent `{provider}` has duplicate `prompt`" + ); + prompt = Some(driver_string(child, provider, "prompt")?); + } + "args" => { + anyhow::ensure!(args.is_none(), "agent `{provider}` has duplicate `args`"); + args = Some(driver_args(child, provider)?); + } + other => anyhow::bail!("agent `{provider}` has unsupported field `{other}`"), + } + } + Ok(( + model, + effort, + dev_channels.unwrap_or(false), + prompt.ok_or_else(|| anyhow::anyhow!("agent `{provider}` requires `prompt`"))?, + args.unwrap_or_default(), + )) +} + +fn claude_driver_node_to_raw(node: &DeclaredNode) -> anyhow::Result { + let (model, effort, dev_channels, prompt, args) = + common_driver_fields(node, "claude", true)?; + Ok(ClaudeDriver { + model, + effort, + dev_channels, + prompt, + args, + }) +} + +fn codex_driver_node_to_raw(node: &DeclaredNode) -> anyhow::Result { + let (model, effort, _, prompt, args) = common_driver_fields(node, "codex", false)?; + Ok(CodexDriver { + model, + effort, + prompt, + args, + }) +} + fn parse_presentation( node: &DeclaredNode, field: &str, diff --git a/crates/agent-spec/src/lib.rs b/crates/agent-spec/src/lib.rs index cfe26b4e..d911ba20 100644 --- a/crates/agent-spec/src/lib.rs +++ b/crates/agent-spec/src/lib.rs @@ -43,6 +43,7 @@ pub use discovery::{ path_defaults, }; pub use spec::{ - AgentDesiredState, AgentSpec, DeliveryTransport, JobType, Resource, Restart, RestartMode, Task, - TaskKind, TaskLifecycle, parse_duration, validate_desired_state_reason, + AgentDesiredState, AgentSpec, ClaudeDriver, CodexDriver, DeliveryTransport, Driver, JobType, + Resource, Restart, RestartMode, Task, TaskKind, TaskLifecycle, parse_duration, + validate_desired_state_reason, }; diff --git a/crates/agent-spec/src/spec.rs b/crates/agent-spec/src/spec.rs index d4b0afc8..1a75c3ad 100644 --- a/crates/agent-spec/src/spec.rs +++ b/crates/agent-spec/src/spec.rs @@ -5,10 +5,10 @@ //! stage's script; must NOT allocate a terminal, R09). st2 reads only the runner-normative subset: //! `identity`, presentation (`name`, `description`), `host`, `role` (metadata only), `type`, //! `workspace`, whole-agent desired state (plus legacy `retired`), `keep`, `supervisor`, -//! `restart{}`, `deliver`, task lifecycle, Resource bindings (declaration metadata), and the tasks. -//! Everything render-only (`harness`, `model`, `persona`, `permissions`, legacy `transport` -//! metadata, `strategy`, `meta{}`) is baked into the tasks/commands by the render layer and ignored -//! here. +//! `restart{}`, `deliver`, typed harness drivers, task lifecycle, Resource bindings (declaration +//! metadata), and the tasks. Everything else that is render-only (`harness`, `model`, `persona`, +//! `permissions`, legacy `transport` metadata, `strategy`, `meta{}`) is baked into the +//! tasks/commands by the render layer and ignored here. //! //! Three on-disk formats lower to this model: KDL (canonical, parsed by hand in `kdl_format`), and //! TOML/JSON (serde). Every spec is a `service` — `type = batch` is retired; evals run through the @@ -63,6 +63,49 @@ impl DeliveryTransport { } } +/// One typed harness driver declaration. +/// +/// The runner preserves this additive declaration field but does not execute or expand it. The st2 +/// command layer owns inspectable expansion into ordinary Agent Spec KDL primitives. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Driver { + Claude(ClaudeDriver), + Codex(CodexDriver), +} + +impl Driver { + pub fn name(&self) -> &'static str { + match self { + Self::Claude(_) => "claude", + Self::Codex(_) => "codex", + } + } +} + +/// Typed fields accepted by a `claude {}` driver block. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct ClaudeDriver { + pub model: Option, + pub effort: Option, + #[serde(default)] + pub dev_channels: bool, + pub prompt: String, + #[serde(default)] + pub args: Vec, +} + +/// Typed fields accepted by a `codex {}` driver block. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct CodexDriver { + pub model: Option, + pub effort: Option, + pub prompt: String, + #[serde(default)] + pub args: Vec, +} + impl AgentDesiredState { pub fn as_str(&self) -> &'static str { match self { @@ -120,6 +163,8 @@ pub struct AgentSpec { pub restart: Option, /// Provider-native delivery selected by `deliver`; `None` means legacy `ding` or no delivery. pub delivery: Option, + /// Additive typed harness declaration. Runtime paths do not read this field yet. + pub driver: Option, /// Named typed references used by the agent. st2 preserves these for readers but does not /// resolve them or assign launch, readiness, access, or lifecycle semantics. pub resources: Vec, @@ -440,6 +485,9 @@ pub(crate) struct RawSpec { /// Compact catalog form: select one provider-native delivery transport. #[serde(default, deserialize_with = "deserialize_explicit_optional")] pub deliver: Option>, + /// Direct `claude {}` or `codex {}` provider block. + #[serde(flatten)] + pub driver: RawDriver, /// Compact catalog form: reconciliation policy for the generated agent PTY. pub lifecycle: Option, /// `pty "" {}` / `[pty.]` — interactive tasks. @@ -450,6 +498,26 @@ pub(crate) struct RawSpec { pub exec: BTreeMap, } +/// The permissive raw envelope keeps the provider name at the same level in KDL, TOML, and JSON. +#[derive(Debug, Default, Deserialize)] +pub(crate) struct RawDriver { + pub(crate) claude: Option, + pub(crate) codex: Option, +} + +impl RawDriver { + fn lower(self, identity: &str) -> anyhow::Result> { + match (self.claude, self.codex) { + (None, None) => Ok(None), + (Some(driver), None) => Ok(Some(Driver::Claude(driver))), + (None, Some(driver)) => Ok(Some(Driver::Codex(driver))), + (Some(_), Some(_)) => anyhow::bail!( + "agent '{identity}' declares both `claude` and `codex`; choose one driver" + ), + } + } +} + #[derive(Debug, Default)] pub(crate) struct RawResources(BTreeMap); @@ -792,6 +860,8 @@ impl RawSpec { || self.argv.is_some() || self.ding || self.deliver.is_some() + || self.driver.claude.is_some() + || self.driver.codex.is_some() || !self.resource.0.is_empty() || !self.pty.is_empty() || !self.exec.is_empty() @@ -825,6 +895,7 @@ impl RawSpec { .as_deref() .map(DeliveryTransport::parse) .transpose()?; + let driver = self.driver.lower(&identity)?; anyhow::ensure!( !(self.ding && delivery.is_some()), "agent '{identity}' declares both `ding` and `deliver`; choose one transport" @@ -902,6 +973,7 @@ impl RawSpec { keep: self.keep, restart: self.restart.map(RawRestart::lower), delivery, + driver, resources, tasks, path, diff --git a/crates/agent-spec/tests/discovery.rs b/crates/agent-spec/tests/discovery.rs index baa5e654..59ce1a15 100644 --- a/crates/agent-spec/tests/discovery.rs +++ b/crates/agent-spec/tests/discovery.rs @@ -8,7 +8,9 @@ use std::fs; use std::path::Path; use std::time::Duration; -use agent_spec::spec::{DeliveryTransport, TaskKind, TaskLifecycle}; +use agent_spec::spec::{ + ClaudeDriver, CodexDriver, DeliveryTransport, Driver, TaskKind, TaskLifecycle, +}; use agent_spec::{ AgentDesiredState, AgentSpec, JobType, Resource, Task, discover, discover_strict, }; @@ -545,6 +547,134 @@ argv = ["claude", "--resume", "session id"] ); } +#[test] +fn typed_driver_blocks_lower_with_kdl_toml_and_json_parity() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "agents/h/claude-kdl/agent.kdl", + r#"agent "claude-kdl" { + claude { + model "opus" + effort "xhigh" + dev-channels #true + prompt "Start the assigned work." + args "--permission-mode" "bypassPermissions" + } +}"#, + ); + write( + tmp.path(), + "agents/h/claude-toml/agent.toml", + r#"identity = "claude-toml" + +[claude] +model = "opus" +effort = "xhigh" +dev-channels = true +prompt = "Start the assigned work." +args = ["--permission-mode", "bypassPermissions"] +"#, + ); + write( + tmp.path(), + "agents/h/claude-json/agent.json", + r#"{ + "identity": "claude-json", + "claude": { + "model": "opus", + "effort": "xhigh", + "dev-channels": true, + "prompt": "Start the assigned work.", + "args": ["--permission-mode", "bypassPermissions"] + } +}"#, + ); + write( + tmp.path(), + "agents/h/codex-kdl/agent.kdl", + r#"agent "codex-kdl" { + codex { + model "gpt-5.6-sol" + effort "xhigh" + prompt "Start the assigned work." + args "--dangerously-bypass-approvals-and-sandbox" + } +}"#, + ); + write( + tmp.path(), + "agents/h/codex-toml/agent.toml", + r#"identity = "codex-toml" + +[codex] +model = "gpt-5.6-sol" +effort = "xhigh" +prompt = "Start the assigned work." +args = ["--dangerously-bypass-approvals-and-sandbox"] +"#, + ); + write( + tmp.path(), + "agents/h/codex-json/agent.json", + r#"{ + "identity": "codex-json", + "codex": { + "model": "gpt-5.6-sol", + "effort": "xhigh", + "prompt": "Start the assigned work.", + "args": ["--dangerously-bypass-approvals-and-sandbox"] + } +}"#, + ); + + let found = discover(tmp.path()); + assert!(found.errors.is_empty(), "{:?}", found.errors); + let claude = Driver::Claude(ClaudeDriver { + model: Some("opus".into()), + effort: Some("xhigh".into()), + dev_channels: true, + prompt: "Start the assigned work.".into(), + args: vec!["--permission-mode".into(), "bypassPermissions".into()], + }); + for identity in ["claude-kdl", "claude-toml", "claude-json"] { + assert_eq!(find(&found.specs, identity).driver.as_ref(), Some(&claude)); + } + let codex = Driver::Codex(CodexDriver { + model: Some("gpt-5.6-sol".into()), + effort: Some("xhigh".into()), + prompt: "Start the assigned work.".into(), + args: vec!["--dangerously-bypass-approvals-and-sandbox".into()], + }); + for identity in ["codex-kdl", "codex-toml", "codex-json"] { + assert_eq!(find(&found.specs, identity).driver.as_ref(), Some(&codex)); + } +} + +#[test] +fn driver_blocks_reject_ambiguous_providers_and_untyped_fields() { + for (name, body) in [ + ( + "both", + r#"claude { prompt "go" }; codex { prompt "go" }"#, + ), + ("missing-prompt", r#"claude { model "opus" }"#), + ("wrong-bool", r#"claude { dev-channels "yes"; prompt "go" }"#), + ("codex-dev", r#"codex { dev-channels #true; prompt "go" }"#), + ("unknown", r#"claude { presence #true; prompt "go" }"#), + ] { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + &format!("agents/h/{name}/agent.kdl"), + &format!("agent \"{name}\" {{ {body} }}"), + ); + let found = discover(tmp.path()); + assert!(found.specs.is_empty(), "accepted {name}"); + assert_eq!(found.errors.len(), 1, "{name}: {:?}", found.errors); + } +} + #[test] fn presentation_metadata_lowers_from_kdl_toml_and_json_without_changing_identity() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src/eval_run.rs b/src/eval_run.rs index e3c0b9f0..8149ae39 100644 --- a/src/eval_run.rs +++ b/src/eval_run.rs @@ -120,6 +120,7 @@ pub fn spec_to_agent_specs(agents: &[SpecAgent], host: &str, root: &Path) -> Vec keep: false, restart: None, delivery: None, + driver: None, resources: Vec::new(), tasks, path: path.clone(), diff --git a/src/lib.rs b/src/lib.rs index c1f19f79..20bc10ec 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,8 +44,8 @@ pub use agent_spec::{discovery, spec}; pub use agent_spec::discovery::{Discovered, SpecError, discover, discover_strict}; pub use agent_spec::spec::{ - AgentDesiredState, AgentSpec, DeliveryTransport, JobType, Resource, Restart, RestartMode, Task, - TaskKind, TaskLifecycle, parse_duration, + AgentDesiredState, AgentSpec, ClaudeDriver, CodexDriver, DeliveryTransport, Driver, JobType, + Resource, Restart, RestartMode, Task, TaskKind, TaskLifecycle, parse_duration, }; pub use catalog_lock::CatalogLock; pub use exec_backend::ExecBackend; diff --git a/src/run.rs b/src/run.rs index b2eb46e2..65169368 100644 --- a/src/run.rs +++ b/src/run.rs @@ -2281,6 +2281,7 @@ mod tests { keep: false, restart: None, delivery: None, + driver: None, resources: vec![], tasks: vec![Task { kind: TaskKind::Pty, @@ -2331,6 +2332,7 @@ mod tests { keep: false, restart: None, delivery: None, + driver: None, resources: vec![], tasks: vec![Task { kind: TaskKind::Pty, @@ -2588,6 +2590,7 @@ mod tests { keep: false, restart: None, delivery: None, + driver: None, resources: vec![], tasks: vec![], path: std::path::PathBuf::from("/x"), diff --git a/tests/reconcile.rs b/tests/reconcile.rs index a9f670ac..55678410 100644 --- a/tests/reconcile.rs +++ b/tests/reconcile.rs @@ -399,6 +399,7 @@ fn spec( keep: false, restart: None, delivery: None, + driver: None, resources: Vec::new(), tasks, path: PathBuf::from(format!( diff --git a/tests/run.rs b/tests/run.rs index c5a21178..c1da5842 100644 --- a/tests/run.rs +++ b/tests/run.rs @@ -237,6 +237,7 @@ fn task_spec(identity: &str, host: Option<&str>, id: &str) -> AgentSpec { keep: false, restart: None, delivery: None, + driver: None, resources: vec![], tasks: vec![Task { kind: TaskKind::Exec, From 23511ef92c5a4628ba592354fd8a48bfe394848f Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Fri, 14 Aug 2026 10:07:56 +0200 Subject: [PATCH 38/56] Expand harness drivers to plain KDL --- src/driver.rs | 243 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 2 files changed, 244 insertions(+) create mode 100644 src/driver.rs diff --git a/src/driver.rs b/src/driver.rs new file mode 100644 index 00000000..cf28d97a --- /dev/null +++ b/src/driver.rs @@ -0,0 +1,243 @@ +//! Pure typed-driver expansion into ordinary hand-authorable Agent Spec KDL primitives. +//! +//! Expansion does not read files, inspect a harness, mutate a declaration, or execute a process. +//! Reconcile and materialization do not call this module. + +use agent_spec::spec::{AgentSpec, ClaudeDriver, CodexDriver, Driver}; +use anyhow::{Context, Result}; +use kdl::{KdlDocument, KdlEntry, KdlNode}; + +const ST2: &str = "st2"; +const CATALOG: &str = "$CATALOG"; +const CLAUDE_SERVER: &str = "st2"; + +/// Expand one typed driver into KDL nodes that can be written inside an `agent {}` block. +pub fn expand_driver(spec: &AgentSpec, this_host: &str) -> Result { + let driver = spec + .driver + .as_ref() + .with_context(|| format!("agent '{}' has no driver block", spec.identity))?; + anyhow::ensure!( + spec.host.is_some() || !this_host.is_empty(), + "agent '{}' has no host and driver expansion received no host fallback", + spec.identity + ); + let bus_id = spec.bus_id(this_host); + let mut output = match driver { + Driver::Claude(driver) => expand_claude(driver, &bus_id)?, + Driver::Codex(driver) => expand_codex(driver, &bus_id), + }; + output.autoformat(); + Ok(output) +} + +fn expand_codex(driver: &CodexDriver, bus_id: &str) -> KdlDocument { + let mut provider = vec!["codex".to_string()]; + if let Some(model) = &driver.model { + provider.extend(["--model".to_string(), model.clone()]); + } + if let Some(effort) = &driver.effort { + provider.extend([ + "-c".to_string(), + format!("model_reasoning_effort={effort}"), + ]); + } + provider.extend(driver.args.iter().cloned()); + provider.push(driver.prompt.clone()); + + let mut argv = vec![ + ST2.to_string(), + "--catalog".to_string(), + CATALOG.to_string(), + "driver".to_string(), + "codex".to_string(), + "--identity".to_string(), + bus_id.to_string(), + "--runtime-id".to_string(), + bus_id.to_string(), + "--".to_string(), + ]; + argv.extend(provider); + document([node("argv", argv)]) +} + +fn expand_claude(driver: &ClaudeDriver, bus_id: &str) -> Result { + let mcp = serde_json::json!({ + "mcpServers": { + CLAUDE_SERVER: { + "type": "stdio", + "command": ST2, + "args": [ + "--catalog", + CATALOG, + "driver", + "claude", + "--identity", + bus_id + ] + } + } + }); + let mcp = serde_json::to_string_pretty(&mcp)?; + let mut render = KdlNode::new("render"); + render.set_children(document([node( + "json-upsert", + vec![".mcp.json".to_string(), mcp], + )])); + + let mut argv = vec!["claude".to_string()]; + if let Some(model) = &driver.model { + argv.extend(["--model".to_string(), model.clone()]); + } + if let Some(effort) = &driver.effort { + argv.extend(["--effort".to_string(), effort.clone()]); + } + if driver.dev_channels { + argv.push("--dangerously-load-development-channels=server:st2".to_string()); + } + argv.extend(driver.args.iter().cloned()); + argv.push(driver.prompt.clone()); + Ok(document([render, node("argv", argv)])) +} + +fn node(name: &str, args: Vec) -> KdlNode { + let mut node = KdlNode::new(name); + node.entries_mut() + .extend(args.into_iter().map(KdlEntry::new)); + node +} + +fn document(nodes: [KdlNode; N]) -> KdlDocument { + let mut document = KdlDocument::new(); + document.nodes_mut().extend(nodes); + document +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use agent_spec::spec::{AgentDesiredState, JobType}; + use kdl::KdlValue; + + use super::*; + + fn spec(driver: Driver) -> AgentSpec { + AgentSpec { + identity: "worker".into(), + name: None, + description: None, + host: Some("host".into()), + role: None, + job_type: JobType::Service, + workspace: Some("/work".into()), + supervisor: None, + desired_state: AgentDesiredState::Running, + keep: false, + restart: None, + delivery: None, + driver: Some(driver), + resources: Vec::new(), + tasks: Vec::new(), + path: PathBuf::from("/catalog/agents/host/worker/agent.kdl"), + } + } + + fn strings(node: &KdlNode) -> Vec<&str> { + node.entries() + .iter() + .filter_map(|entry| match entry.value() { + KdlValue::String(value) => Some(value.as_str()), + _ => None, + }) + .collect() + } + + #[test] + fn codex_expands_to_one_plain_argv_with_typed_fields_before_verbatim_args() { + let output = expand_driver( + &spec(Driver::Codex(CodexDriver { + model: Some("gpt-5.6-sol".into()), + effort: Some("xhigh".into()), + prompt: "Start work.".into(), + args: vec!["--model".into(), "override".into()], + })), + "unused", + ) + .unwrap(); + + assert_eq!(output.nodes().len(), 1); + assert_eq!( + strings(output.get("argv").unwrap()), + [ + "st2", + "--catalog", + "$CATALOG", + "driver", + "codex", + "--identity", + "host.worker", + "--runtime-id", + "host.worker", + "--", + "codex", + "--model", + "gpt-5.6-sol", + "-c", + "model_reasoning_effort=xhigh", + "--model", + "override", + "Start work." + ] + ); + } + + #[test] + fn claude_expands_to_plain_render_and_argv_primitives() { + let output = expand_driver( + &spec(Driver::Claude(ClaudeDriver { + model: Some("opus".into()), + effort: Some("xhigh".into()), + dev_channels: true, + prompt: "Start work.".into(), + args: vec!["--model".into(), "override".into()], + })), + "unused", + ) + .unwrap(); + + assert_eq!(output.nodes().len(), 2); + let render = output.get("render").unwrap(); + let upsert = render.children().unwrap().get("json-upsert").unwrap(); + let upsert = strings(upsert); + assert_eq!(upsert[0], ".mcp.json"); + let mcp: serde_json::Value = serde_json::from_str(upsert[1]).unwrap(); + assert_eq!(mcp["mcpServers"]["st2"]["type"], "stdio"); + assert_eq!(mcp["mcpServers"]["st2"]["command"], "st2"); + assert_eq!( + mcp["mcpServers"]["st2"]["args"], + serde_json::json!([ + "--catalog", + "$CATALOG", + "driver", + "claude", + "--identity", + "host.worker" + ]) + ); + assert_eq!( + strings(output.get("argv").unwrap()), + [ + "claude", + "--model", + "opus", + "--effort", + "xhigh", + "--dangerously-load-development-channels=server:st2", + "--model", + "override", + "Start work." + ] + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 20bc10ec..710d30c1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,6 +14,7 @@ pub mod claude_mcp; pub mod codex_app_server; pub mod context; pub mod ding; +pub mod driver; pub mod eval_run; pub mod eval_spec; pub mod exec_backend; From 4da52d524e2bf77008cae0640a8f2de9da7851bd Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Fri, 14 Aug 2026 10:09:54 +0200 Subject: [PATCH 39/56] Snapshot harness driver expansion --- tests/driver_expansion.rs | 33 ++++++++++++++++++++++++++++ tests/fixtures/driver/claude.in.kdl | 11 ++++++++++ tests/fixtures/driver/claude.out.kdl | 4 ++++ tests/fixtures/driver/codex.in.kdl | 10 +++++++++ tests/fixtures/driver/codex.out.kdl | 1 + 5 files changed, 59 insertions(+) create mode 100644 tests/driver_expansion.rs create mode 100644 tests/fixtures/driver/claude.in.kdl create mode 100644 tests/fixtures/driver/claude.out.kdl create mode 100644 tests/fixtures/driver/codex.in.kdl create mode 100644 tests/fixtures/driver/codex.out.kdl diff --git a/tests/driver_expansion.rs b/tests/driver_expansion.rs new file mode 100644 index 00000000..62e27b01 --- /dev/null +++ b/tests/driver_expansion.rs @@ -0,0 +1,33 @@ +use std::fs; + +use st2::{discover, driver::expand_driver}; + +fn assert_snapshot(input: &str, expected: &str) { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("agents/host/worker/agent.kdl"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, input).unwrap(); + + let found = discover(temp.path()); + assert!(found.errors.is_empty(), "{:?}", found.errors); + assert_eq!(found.specs.len(), 1); + let actual = expand_driver(&found.specs[0], "unused").unwrap().to_string(); + assert_eq!(actual, expected); + expected.parse::().unwrap(); +} + +#[test] +fn claude_kdl_expansion_matches_snapshot() { + assert_snapshot( + include_str!("fixtures/driver/claude.in.kdl"), + include_str!("fixtures/driver/claude.out.kdl"), + ); +} + +#[test] +fn codex_kdl_expansion_matches_snapshot() { + assert_snapshot( + include_str!("fixtures/driver/codex.in.kdl"), + include_str!("fixtures/driver/codex.out.kdl"), + ); +} diff --git a/tests/fixtures/driver/claude.in.kdl b/tests/fixtures/driver/claude.in.kdl new file mode 100644 index 00000000..c4de36fa --- /dev/null +++ b/tests/fixtures/driver/claude.in.kdl @@ -0,0 +1,11 @@ +agent "worker" { + host "host" + workspace "/work" + claude { + model "opus" + effort "xhigh" + dev-channels #true + prompt "Start the assigned work." + args "--permission-mode" "bypassPermissions" "--model" "override" + } +} diff --git a/tests/fixtures/driver/claude.out.kdl b/tests/fixtures/driver/claude.out.kdl new file mode 100644 index 00000000..18564c84 --- /dev/null +++ b/tests/fixtures/driver/claude.out.kdl @@ -0,0 +1,4 @@ +render { + json-upsert .mcp.json "{\n \"mcpServers\": {\n \"st2\": {\n \"args\": [\n \"--catalog\",\n \"$CATALOG\",\n \"driver\",\n \"claude\",\n \"--identity\",\n \"host.worker\"\n ],\n \"command\": \"st2\",\n \"type\": \"stdio\"\n }\n }\n}" +} +argv claude --model opus --effort xhigh "--dangerously-load-development-channels=server:st2" --permission-mode bypassPermissions --model override "Start the assigned work." diff --git a/tests/fixtures/driver/codex.in.kdl b/tests/fixtures/driver/codex.in.kdl new file mode 100644 index 00000000..2dccc571 --- /dev/null +++ b/tests/fixtures/driver/codex.in.kdl @@ -0,0 +1,10 @@ +agent "worker" { + host "host" + workspace "/work" + codex { + model "gpt-5.6-sol" + effort "xhigh" + prompt "Start the assigned work." + args "--dangerously-bypass-approvals-and-sandbox" "--model" "override" + } +} diff --git a/tests/fixtures/driver/codex.out.kdl b/tests/fixtures/driver/codex.out.kdl new file mode 100644 index 00000000..bc48068a --- /dev/null +++ b/tests/fixtures/driver/codex.out.kdl @@ -0,0 +1 @@ +argv st2 --catalog $CATALOG driver codex --identity host.worker --runtime-id host.worker -- codex --model gpt-5.6-sol -c "model_reasoning_effort=xhigh" --dangerously-bypass-approvals-and-sandbox --model override "Start the assigned work." From 8f6c416b7b1766409fe00c96c8590e03a699ab5a Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Fri, 14 Aug 2026 10:21:04 +0200 Subject: [PATCH 40/56] Print typed harness driver expansion --- crates/agent-spec/src/discovery.rs | 12 +++++++ crates/agent-spec/src/lib.rs | 4 +-- src/lib.rs | 4 ++- src/main.rs | 54 +++++++++++++++++++++++++++- tests/driver_expansion.rs | 34 ++++++++++++++++++ tests/fixtures/driver/claude.in.kdl | 4 +-- tests/fixtures/driver/claude.out.kdl | 2 +- 7 files changed, 107 insertions(+), 7 deletions(-) diff --git a/crates/agent-spec/src/discovery.rs b/crates/agent-spec/src/discovery.rs index 7b5f883c..eca745c9 100644 --- a/crates/agent-spec/src/discovery.rs +++ b/crates/agent-spec/src/discovery.rs @@ -76,6 +76,18 @@ pub fn discover_strict(root: &Path) -> Discovered { discover_impl(root, true) } +/// Parse and lower one declaration file without walking its surrounding catalog. +/// +/// `root` supplies the same path defaults as [`discover`]. The returned warnings describe only +/// this file. +pub fn discover_file( + root: &Path, + path: &Path, +) -> anyhow::Result<(Vec, Vec)> { + let raws = parse_raw_file(path)?; + load_specs(root, path, raws) +} + fn discover_impl(root: &Path, strict: bool) -> Discovered { DISCOVERY_WALK_COUNT.set(DISCOVERY_WALK_COUNT.get() + 1); let mut out = Discovered::default(); diff --git a/crates/agent-spec/src/lib.rs b/crates/agent-spec/src/lib.rs index d911ba20..5b7697b5 100644 --- a/crates/agent-spec/src/lib.rs +++ b/crates/agent-spec/src/lib.rs @@ -39,8 +39,8 @@ pub use declared::{ parse_declared_document, parse_declared_file, }; pub use discovery::{ - Declared, Discovered, SpecError, discover, discover_strict, is_catalog_path, parse_declared, - path_defaults, + Declared, Discovered, SpecError, discover, discover_file, discover_strict, is_catalog_path, + parse_declared, path_defaults, }; pub use spec::{ AgentDesiredState, AgentSpec, ClaudeDriver, CodexDriver, DeliveryTransport, Driver, JobType, diff --git a/src/lib.rs b/src/lib.rs index 710d30c1..fede47e9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,7 +43,9 @@ mod watch; // `st2::spec::…` / `st2::discovery::…` keep working for the binary and the test suite. pub use agent_spec::{discovery, spec}; -pub use agent_spec::discovery::{Discovered, SpecError, discover, discover_strict}; +pub use agent_spec::discovery::{ + Discovered, SpecError, discover, discover_file, discover_strict, +}; pub use agent_spec::spec::{ AgentDesiredState, AgentSpec, ClaudeDriver, CodexDriver, DeliveryTransport, Driver, JobType, Resource, Restart, RestartMode, Task, TaskKind, TaskLifecycle, parse_duration, diff --git a/src/main.rs b/src/main.rs index a3835c98..a0e961cb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -86,7 +86,7 @@ enum Command { /// or refresh hooks. #[command(subcommand)] Hooks(HooksCmd), - /// Provider-native harness drivers. These commands preserve the current native launch paths. + /// Provider-native harness drivers and read-only typed-block expansion. #[command(subcommand)] Driver(DriverCmd), /// The ding sidecar: watch an agent's `resources/inbox` and poke its pty (`[DING] …`) on each new @@ -304,6 +304,17 @@ enum Command { #[derive(Subcommand)] enum DriverCmd { + /// Print one typed driver block as plain Agent Spec KDL without running it. + Expand { + /// KDL declaration that contains the typed driver block. + spec: PathBuf, + /// Select one local or fully qualified identity when the file contains multiple agents. + #[arg(long)] + agent: Option, + /// Host fallback when neither the declaration nor its catalog path supplies one. + #[arg(long)] + host: Option, + }, /// Run the existing controlled Codex app-server path. Codex { #[arg(long)] @@ -887,6 +898,10 @@ fn main() -> Result<()> { let catalog = catalog.canonicalize().unwrap_or(catalog); st2::claude_mcp::run(&catalog, &identity) } + Command::Driver(DriverCmd::Expand { spec, agent, host }) => { + let catalog = catalog_arg(None)?; + driver_expand_cmd(&catalog, &spec, agent.as_deref(), host.as_deref()) + } Command::Status { identity, set, ctx } => status_cmd(identity, set, ctx), Command::Rename(args) => presentation_cmd(st2::agent_author::PresentationField::Name, args), Command::Describe(args) => { @@ -1118,6 +1133,43 @@ fn main() -> Result<()> { } } +fn driver_expand_cmd( + catalog: &Path, + path: &Path, + agent: Option<&str>, + host: Option<&str>, +) -> Result<()> { + let (mut specs, warnings) = st2::discover_file(catalog, path) + .with_context(|| format!("reading driver declaration {}", path.display()))?; + for warning in warnings { + eprintln!("warning: {warning}"); + } + if let Some(agent) = agent { + specs.retain(|spec| { + spec.identity == agent || spec.bus_id(host.unwrap_or("")) == agent + }); + } + anyhow::ensure!( + specs.len() == 1, + if agent.is_some() { + format!( + "{} contains {} matching agent blocks; expected exactly one", + path.display(), + specs.len() + ) + } else { + format!( + "{} contains {} agent blocks; use --agent when it contains more than one", + path.display(), + specs.len() + ) + } + ); + let output = st2::driver::expand_driver(&specs[0], host.unwrap_or(""))?; + print!("{output}"); + Ok(()) +} + fn hooks_cmd(command: HooksCmd) -> Result<()> { match command { HooksCmd::Install { diff --git a/tests/driver_expansion.rs b/tests/driver_expansion.rs index 62e27b01..dc545b80 100644 --- a/tests/driver_expansion.rs +++ b/tests/driver_expansion.rs @@ -1,4 +1,6 @@ use std::fs; +use std::path::Path; +use std::process::Command; use st2::{discover, driver::expand_driver}; @@ -31,3 +33,35 @@ fn codex_kdl_expansion_matches_snapshot() { include_str!("fixtures/driver/codex.out.kdl"), ); } + +#[test] +fn cli_prints_each_snapshot_without_changing_its_input() { + let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/driver"); + for provider in ["claude", "codex"] { + let input = fixtures.join(format!("{provider}.in.kdl")); + let before = fs::read(&input).unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_st2")) + .args(["--catalog"]) + .arg(&fixtures) + .args(["driver", "expand"]) + .arg(&input) + .args( + (provider == "claude") + .then_some(["--agent", "Silber.fabric"]) + .into_iter() + .flatten(), + ) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + output.stdout, + fs::read(fixtures.join(format!("{provider}.out.kdl"))).unwrap() + ); + assert_eq!(fs::read(&input).unwrap(), before); + } +} diff --git a/tests/fixtures/driver/claude.in.kdl b/tests/fixtures/driver/claude.in.kdl index c4de36fa..5bab6aa9 100644 --- a/tests/fixtures/driver/claude.in.kdl +++ b/tests/fixtures/driver/claude.in.kdl @@ -1,5 +1,5 @@ -agent "worker" { - host "host" +agent "fabric" { + host "Silber" workspace "/work" claude { model "opus" diff --git a/tests/fixtures/driver/claude.out.kdl b/tests/fixtures/driver/claude.out.kdl index 18564c84..ab1b783a 100644 --- a/tests/fixtures/driver/claude.out.kdl +++ b/tests/fixtures/driver/claude.out.kdl @@ -1,4 +1,4 @@ render { - json-upsert .mcp.json "{\n \"mcpServers\": {\n \"st2\": {\n \"args\": [\n \"--catalog\",\n \"$CATALOG\",\n \"driver\",\n \"claude\",\n \"--identity\",\n \"host.worker\"\n ],\n \"command\": \"st2\",\n \"type\": \"stdio\"\n }\n }\n}" + json-upsert .mcp.json "{\n \"mcpServers\": {\n \"st2\": {\n \"args\": [\n \"--catalog\",\n \"$CATALOG\",\n \"driver\",\n \"claude\",\n \"--identity\",\n \"Silber.fabric\"\n ],\n \"command\": \"st2\",\n \"type\": \"stdio\"\n }\n }\n}" } argv claude --model opus --effort xhigh "--dangerously-load-development-channels=server:st2" --permission-mode bypassPermissions --model override "Start the assigned work." From 77be02489c2e34f66fa315ff2c23517cf2655114 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Fri, 14 Aug 2026 10:38:28 +0200 Subject: [PATCH 41/56] Reject conflicting driver launch sources --- src/validate.rs | 10 ++++++++++ tests/validate.rs | 25 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/validate.rs b/src/validate.rs index 634ecf4c..1ed2ff47 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -220,6 +220,16 @@ fn validate_scoped(root: &Path, this_host: Option<&str>) -> Report { )); } + if s.driver.is_some() && s.delivery.is_some() { + issues.push(Issue::error( + "driver-deliver-conflict", + rp.clone(), + ag.clone(), + "agent declares both a driver block and `deliver`; choose one launch source" + .to_string(), + )); + } + // An explicit identity+host pair is authoritative regardless of folder names. When either // field is omitted, path defaults remain part of placement and mismatches stay advisory. let explicit_placement = s.host.as_ref().is_some_and(|host| { diff --git a/tests/validate.rs b/tests/validate.rs index 646a65e7..dece6b72 100644 --- a/tests/validate.rs +++ b/tests/validate.rs @@ -177,6 +177,31 @@ fn shared_workspace_render_conflict_is_an_error() { // ---- errors ---------------------------------------------------------------------------------- +#[test] +fn a_driver_block_and_deliver_are_two_conflicting_launch_sources() { + let c = catalog(&[( + "h/worker/agent.kdl", + r#"agent "worker" { + host "h" + deliver "app-server" + claude { prompt "Start work." } + argv "claude" "Start work." +}"#, + )]); + + let report = validate(c.path()); + let issue = report + .issues + .iter() + .find(|issue| issue.code == "driver-deliver-conflict") + .unwrap(); + assert_eq!(issue.severity, Severity::Error); + assert_eq!( + issue.message, + "agent declares both a driver block and `deliver`; choose one launch source" + ); +} + #[test] fn type_batch_is_retired_and_flagged_unknown() { // `type = batch` is retired (native `st2 eval` replaces it) — a lingering batch spec is now an From 9a677a2a3f61bfe06ad9e0e7f87b0bce7f9a3865 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Fri, 14 Aug 2026 10:45:10 +0200 Subject: [PATCH 42/56] Compile driver blocks into agent tasks --- crates/agent-spec/src/spec.rs | 19 ++++--- src/reconcile.rs | 95 ++++++++++++++++++++++++++++++++++- tests/codex_app_server.rs | 65 ++++++++++++++++++++++++ 3 files changed, 171 insertions(+), 8 deletions(-) diff --git a/crates/agent-spec/src/spec.rs b/crates/agent-spec/src/spec.rs index 1a75c3ad..f1cf2103 100644 --- a/crates/agent-spec/src/spec.rs +++ b/crates/agent-spec/src/spec.rs @@ -393,12 +393,14 @@ impl AgentSpec { self.host.as_deref().unwrap_or(this_host) } - /// True once at least one authored task carries an explicit shell command or direct argv (i.e. - /// the job was rendered). A generated sidecar cannot make an otherwise-empty job runnable. + /// True when a driver can compile the launch or an authored task already contains one. + /// A generated sidecar cannot make an otherwise-empty job runnable. pub fn is_runnable(&self) -> bool { - self.tasks - .iter() - .any(|task| !task.derived && (task.command.is_some() || task.argv.is_some())) + self.driver.is_some() + || self + .tasks + .iter() + .any(|task| !task.derived && (task.command.is_some() || task.argv.is_some())) } /// True when the declaration selected legacy screen delivery or one native transport. @@ -896,6 +898,7 @@ impl RawSpec { .map(DeliveryTransport::parse) .transpose()?; let driver = self.driver.lower(&identity)?; + let has_driver = driver.is_some(); anyhow::ensure!( !(self.ding && delivery.is_some()), "agent '{identity}' declares both `ding` and `deliver`; choose one transport" @@ -906,7 +909,9 @@ impl RawSpec { self.argv.as_ref(), "compact task", )?; - if (self.command.is_some() || self.argv.is_some()) && self.pty.contains_key("agent") { + if (self.command.is_some() || self.argv.is_some() || has_driver) + && self.pty.contains_key("agent") + { anyhow::bail!( "agent '{identity}' declares both a compact launch and `pty \"agent\"`; choose one form" ); @@ -921,7 +926,7 @@ impl RawSpec { for (name, t) in self.exec { tasks.push(t.lower(&identity, TaskKind::Exec, name, &self.env)?); } - if self.command.is_some() || self.argv.is_some() { + if self.command.is_some() || self.argv.is_some() || has_driver { let lifecycle = parse_task_lifecycle(&identity, "compact task", self.lifecycle.as_deref())?; tasks.push(Task { diff --git a/src/reconcile.rs b/src/reconcile.rs index 90340a22..044a50e3 100644 --- a/src/reconcile.rs +++ b/src/reconcile.rs @@ -15,7 +15,8 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; -use agent_spec::spec::{AgentSpec, DeliveryTransport, TaskKind, TaskLifecycle}; +use agent_spec::spec::{AgentSpec, DeliveryTransport, Driver, TaskKind, TaskLifecycle}; +use kdl::KdlValue; /// Immutable inputs captured once before generated tasks are compiled. #[derive(Debug, Clone, PartialEq, Eq)] @@ -85,6 +86,7 @@ pub fn compile_generated_tasks( this_host: &str, context: &TaskCompileContext, ) -> Result<()> { + compile_driver_agent_tasks(specs, this_host, context)?; compile_generated_ding_tasks(specs, this_host, context)?; compile_app_server_agent_tasks(specs, this_host, context)?; // Claude's MCP server is declared to Claude itself. It must not be lowered @@ -93,6 +95,94 @@ pub fn compile_generated_tasks( Ok(()) } +/// Compile the shared printed driver expansion into the canonical agent task. +pub fn compile_driver_agent_tasks( + specs: &mut [AgentSpec], + this_host: &str, + context: &TaskCompileContext, +) -> Result<()> { + let st2_executable = context + .st2_executable + .to_str() + .context("running st2 executable path is not UTF-8")? + .to_owned(); + let catalog_root = context + .catalog_root + .to_str() + .context("catalog root is not UTF-8")? + .to_owned(); + + for spec in specs { + let Some(driver) = spec.driver.as_ref() else { + continue; + }; + let bus_id = spec.bus_id(this_host); + let expansion = crate::driver::expand_driver(spec, this_host)?; + let argv_nodes = expansion + .nodes() + .iter() + .filter(|node| node.name().value() == "argv") + .collect::>(); + let [argv_node] = argv_nodes.as_slice() else { + anyhow::bail!( + "agent '{bus_id}' driver expansion produced {} argv nodes; expected exactly one", + argv_nodes.len() + ); + }; + anyhow::ensure!( + argv_node.children().is_none() + && argv_node.entries().iter().all(|entry| { + entry.name().is_none() && matches!(entry.value(), KdlValue::String(_)) + }), + "agent '{bus_id}' driver expansion produced a non-string argv" + ); + let mut argv = argv_node + .entries() + .iter() + .map(|entry| match entry.value() { + KdlValue::String(value) => value.clone(), + _ => unreachable!("the argv shape check accepts only strings"), + }) + .collect::>(); + anyhow::ensure!( + !argv.is_empty(), + "agent '{bus_id}' driver expansion produced an empty argv" + ); + + if matches!(driver, Driver::Codex(_)) { + anyhow::ensure!( + argv.get(0).map(String::as_str) == Some("st2") + && argv.get(1).map(String::as_str) == Some("--catalog") + && argv.get(2).map(String::as_str) == Some("$CATALOG") + && argv.get(3).map(String::as_str) == Some("driver") + && argv.get(4).map(String::as_str) == Some("codex"), + "agent '{bus_id}' Codex driver expansion has an unexpected wrapper prefix" + ); + argv[0] = st2_executable.clone(); + argv[2] = catalog_root.clone(); + } + + let mut candidates = spec + .tasks + .iter_mut() + .filter(|task| !task.derived && task.name == "agent"); + let task = candidates.next().with_context(|| { + format!("agent '{bus_id}' driver has no canonical `agent` task") + })?; + anyhow::ensure!( + candidates.next().is_none(), + "agent '{bus_id}' driver has more than one canonical `agent` task" + ); + anyhow::ensure!( + task.kind == TaskKind::Pty, + "agent '{bus_id}' driver canonical task is not a PTY" + ); + task.command = None; + task.argv = Some(argv); + } + Ok(()) +} + /// Replace only runner-generated DING markers with exact direct argv. Authored tasks never carry /// `derived=true`, so source that happens to invoke `st2 ding` remains byte-for-byte unchanged. pub fn compile_generated_ding_tasks( @@ -163,6 +253,9 @@ pub fn compile_app_server_agent_tasks( .to_owned(); for spec in specs { + if spec.driver.is_some() { + continue; + } if spec.delivery != Some(DeliveryTransport::AppServer) { continue; } diff --git a/tests/codex_app_server.rs b/tests/codex_app_server.rs index b0f7d2f9..6bbe3af2 100644 --- a/tests/codex_app_server.rs +++ b/tests/codex_app_server.rs @@ -60,6 +60,71 @@ fn app_server_selector_wraps_the_canonical_argv_with_exact_owner_inputs() { ); } +#[test] +fn codex_driver_matches_deliver_after_normalizing_only_the_subcommand_alias() { + let tmp = tempfile::tempdir().unwrap(); + let legacy_path = tmp.path().join("legacy.kdl"); + let driver_path = tmp.path().join("driver.kdl"); + write( + &legacy_path, + r#"agent "worker" { + host "h" + deliver "app-server" + argv "codex" "--model" "gpt-test" "-c" "model_reasoning_effort=xhigh" "--model" "override" "boot" +} +"#, + ); + write( + &driver_path, + r#"agent "worker" { + host "h" + codex { + model "gpt-test" + effort "xhigh" + prompt "boot" + args "--model" "override" + } +} +"#, + ); + let (legacy, _) = st2::discover_file(tmp.path(), &legacy_path).unwrap(); + let (driver, _) = st2::discover_file(tmp.path(), &driver_path).unwrap(); + let mut legacy = legacy.into_iter().next().unwrap(); + let mut driver = driver.into_iter().next().unwrap(); + let compile_context = context(tmp.path()); + + compile_generated_tasks( + std::slice::from_mut(&mut legacy), + "h", + &compile_context, + ) + .unwrap(); + compile_generated_tasks( + std::slice::from_mut(&mut driver), + "h", + &compile_context, + ) + .unwrap(); + + let legacy_task = legacy + .tasks + .iter() + .find(|task| task.name == "agent") + .unwrap() + .clone(); + let mut driver_task = driver + .tasks + .iter() + .find(|task| task.name == "agent") + .unwrap() + .clone(); + let argv = driver_task.argv.as_mut().unwrap(); + assert_eq!(&argv[3..5], ["driver", "codex"]); + argv.splice(3..5, ["codex-app-server".to_string()]); + + assert_eq!(driver_task, legacy_task); +} + #[test] fn app_server_selector_rejects_shell_and_pre_remote_launches_without_mutating_them() { for (name, launch, expected) in [ From 138a9493a244dbd25f88cef3208c49a2f79a119a Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Fri, 14 Aug 2026 10:48:28 +0200 Subject: [PATCH 43/56] Materialize shared driver render expansion --- src/materialize.rs | 96 ++++++++++++++++++--------------------- tests/driver_expansion.rs | 86 +++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 51 deletions(-) diff --git a/src/materialize.rs b/src/materialize.rs index cee8f905..be0cc440 100644 --- a/src/materialize.rs +++ b/src/materialize.rs @@ -235,6 +235,47 @@ pub fn parse_plan(spec: &AgentSpec) -> Result { } } +/// Append render operations from the same pure driver expansion used by the print command. +fn parse_plan_with_driver(spec: &AgentSpec, this_host: &str) -> Result { + let mut plan = parse_plan(spec)?; + if spec.driver.is_none() { + return Ok(plan); + } + let expansion = crate::driver::expand_driver(spec, this_host)?; + for render in expansion + .nodes() + .iter() + .filter(|node| node.name().value() == "render") + { + plan.ops + .extend(parse_render_node(render, &spec.identity)?.ops); + } + Ok(plan) +} + +/// Add either the typed driver render or the unchanged legacy delivery render. +fn effective_plan(root: &Path, spec: &AgentSpec, this_host: &str) -> Result { + let mut plan = parse_plan_with_driver(spec, this_host)?; + if spec.driver.is_none() + && spec.delivery == Some(agent_spec::spec::DeliveryTransport::Mcp) + { + let executable = std::env::current_exe() + .context("resolving st2 executable for Claude MCP declaration")?; + let content = serde_json::json!({ + "mcpServers": {"st2": { + "type": "stdio", + "command": executable.to_string_lossy(), + "args": ["--catalog", root.display().to_string(), "claude-mcp", "--identity", spec.bus_id(this_host)] + }} + }).to_string(); + plan.ops.push(RenderOp::JsonUpsert { + destination: ".mcp.json".into(), + content, + }); + } + Ok(plan) +} + /// Catalog-owned files read by this agent's `render { copy ... }` operations. /// /// Absolute/external sources are deliberately absent: a declaration snapshot owns catalog bytes, @@ -245,30 +286,7 @@ pub(crate) fn catalog_owned_render_inputs( spec: &AgentSpec, this_host: &str, ) -> Result> { - let mut plan = parse_plan(spec)?; - if spec.delivery == Some(agent_spec::spec::DeliveryTransport::Mcp) { - // Claude owns this child: the project MCP declaration is rendered into - // the workspace and Claude starts the stdio server from it. st2 never - // reconciles or supervises the watcher as a sibling task. - let executable = std::env::current_exe() - .context("resolving st2 executable for Claude MCP declaration")?; - let catalog = root.display().to_string(); - let identity = spec.bus_id(this_host); - let content = serde_json::json!({ - "mcpServers": { - "st2": { - "type": "stdio", - "command": executable.to_string_lossy(), - "args": ["--catalog", catalog, "claude-mcp", "--identity", identity] - } - } - }) - .to_string(); - plan.ops.push(RenderOp::JsonUpsert { - destination: ".mcp.json".into(), - content, - }); - } + let plan = effective_plan(root, spec, this_host)?; let env = render_env(root, spec, this_host); let spec_dir = spec.path.parent().unwrap_or(root); let mut inputs = BTreeSet::new(); @@ -529,19 +547,7 @@ fn claims_for_agent( spec: &AgentSpec, this_host: &str, ) -> Result>> { - let mut plan = parse_plan(spec)?; - if spec.delivery == Some(agent_spec::spec::DeliveryTransport::Mcp) { - let executable = std::env::current_exe() - .context("resolving st2 executable for Claude MCP declaration")?; - let content = serde_json::json!({ - "mcpServers": {"st2": { - "type": "stdio", - "command": executable.to_string_lossy(), - "args": ["--catalog", root.display().to_string(), "claude-mcp", "--identity", spec.bus_id(this_host)] - }} - }).to_string(); - plan.ops.push(RenderOp::JsonUpsert { destination: ".mcp.json".into(), content }); - } + let plan = effective_plan(root, spec, this_host)?; if plan.ops.is_empty() { return Ok(BTreeMap::new()); } @@ -653,19 +659,7 @@ pub fn render_ownership_conflicts( /// Execute one agent's render plan in declaration order. pub fn materialize_agent(root: &Path, spec: &AgentSpec, this_host: &str) -> Result> { crate::reconcile::validate_task_identities(std::slice::from_ref(spec), this_host)?; - let mut plan = parse_plan(spec)?; - if spec.delivery == Some(agent_spec::spec::DeliveryTransport::Mcp) { - let executable = std::env::current_exe() - .context("resolving st2 executable for Claude MCP declaration")?; - let content = serde_json::json!({ - "mcpServers": {"st2": { - "type": "stdio", - "command": executable.to_string_lossy(), - "args": ["--catalog", root.display().to_string(), "claude-mcp", "--identity", spec.bus_id(this_host)] - }} - }).to_string(); - plan.ops.push(RenderOp::JsonUpsert { destination: ".mcp.json".into(), content }); - } + let plan = effective_plan(root, spec, this_host)?; if plan.ops.is_empty() { return Ok(Vec::new()); } @@ -869,7 +863,7 @@ pub fn materialize_agent(root: &Path, spec: &AgentSpec, this_host: &str) -> Resu /// Validate an agent's render declaration and all catalog-owned inputs without writing its workspace. pub fn validate_agent(root: &Path, spec: &AgentSpec, this_host: &str) -> Result<()> { crate::reconcile::validate_task_identities(std::slice::from_ref(spec), this_host)?; - let plan = parse_plan(spec)?; + let plan = parse_plan_with_driver(spec, this_host)?; if plan.ops.is_empty() { return Ok(()); } diff --git a/tests/driver_expansion.rs b/tests/driver_expansion.rs index dc545b80..2dc4c614 100644 --- a/tests/driver_expansion.rs +++ b/tests/driver_expansion.rs @@ -2,6 +2,8 @@ use std::fs; use std::path::Path; use std::process::Command; +use st2::materialize::materialize_agent; +use st2::reconcile::{TaskCompileContext, compile_generated_tasks}; use st2::{discover, driver::expand_driver}; fn assert_snapshot(input: &str, expected: &str) { @@ -65,3 +67,87 @@ fn cli_prints_each_snapshot_without_changing_its_input() { assert_eq!(fs::read(&input).unwrap(), before); } } + +#[test] +fn claude_driver_matches_deliver_after_normalizing_only_resolution_and_the_alias() { + let temp = tempfile::tempdir().unwrap(); + let catalog = temp.path().join("catalog"); + let legacy_workspace = temp.path().join("legacy-workspace"); + let driver_workspace = temp.path().join("driver-workspace"); + fs::create_dir_all(&legacy_workspace).unwrap(); + fs::create_dir_all(&driver_workspace).unwrap(); + let legacy_path = catalog.join("legacy.kdl"); + let driver_path = catalog.join("driver.kdl"); + fs::create_dir_all(&catalog).unwrap(); + fs::write( + &legacy_path, + format!( + r#"agent "worker" {{ + host "h" + workspace "{}" + deliver "mcp" + argv "claude" "--model" "opus" "--effort" "xhigh" "--dangerously-load-development-channels=server:st2" "--permission-mode" "bypassPermissions" "boot" +}} +"#, + legacy_workspace.display() + ), + ) + .unwrap(); + fs::write( + &driver_path, + format!( + r#"agent "worker" {{ + host "h" + workspace "{}" + claude {{ + model "opus" + effort "xhigh" + dev-channels #true + prompt "boot" + args "--permission-mode" "bypassPermissions" + }} +}} +"#, + driver_workspace.display() + ), + ) + .unwrap(); + let (legacy, _) = st2::discover_file(&catalog, &legacy_path).unwrap(); + let (driver, _) = st2::discover_file(&catalog, &driver_path).unwrap(); + let mut legacy = legacy.into_iter().next().unwrap(); + let mut driver = driver.into_iter().next().unwrap(); + let executable = catalog.join("bin/st2"); + fs::create_dir_all(executable.parent().unwrap()).unwrap(); + fs::write(&executable, "test binary").unwrap(); + let context = TaskCompileContext::new(catalog.clone(), executable).unwrap(); + + compile_generated_tasks(std::slice::from_mut(&mut legacy), "h", &context).unwrap(); + compile_generated_tasks(std::slice::from_mut(&mut driver), "h", &context).unwrap(); + let legacy_task = legacy + .tasks + .iter() + .find(|task| task.name == "agent") + .unwrap(); + let driver_task = driver + .tasks + .iter() + .find(|task| task.name == "agent") + .unwrap(); + assert_eq!(driver_task, legacy_task); + + materialize_agent(&catalog, &legacy, "h").unwrap(); + materialize_agent(&catalog, &driver, "h").unwrap(); + let legacy_mcp: serde_json::Value = + serde_json::from_slice(&fs::read(legacy_workspace.join(".mcp.json")).unwrap()).unwrap(); + let mut driver_mcp: serde_json::Value = + serde_json::from_slice(&fs::read(driver_workspace.join(".mcp.json")).unwrap()).unwrap(); + assert_eq!(driver_mcp["mcpServers"]["st2"]["command"], "st2"); + driver_mcp["mcpServers"]["st2"]["command"] = + legacy_mcp["mcpServers"]["st2"]["command"].clone(); + let args = driver_mcp["mcpServers"]["st2"]["args"] + .as_array_mut() + .unwrap(); + assert_eq!(&args[2..4], ["driver", "claude"]); + args.splice(2..4, [serde_json::Value::String("claude-mcp".into())]); + assert_eq!(driver_mcp, legacy_mcp); +} From e46a7029c5e436bfac31c1ea2013c977b9ff16ad Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Fri, 14 Aug 2026 10:50:03 +0200 Subject: [PATCH 44/56] Recognize driver delivery in doctor --- crates/agent-spec/src/spec.rs | 3 ++- src/main.rs | 2 +- tests/doctor.rs | 12 +++++++++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/agent-spec/src/spec.rs b/crates/agent-spec/src/spec.rs index f1cf2103..cf3d4885 100644 --- a/crates/agent-spec/src/spec.rs +++ b/crates/agent-spec/src/spec.rs @@ -405,7 +405,8 @@ impl AgentSpec { /// True when the declaration selected legacy screen delivery or one native transport. pub fn has_delivery_transport(&self) -> bool { - self.delivery.is_some() + self.driver.is_some() + || self.delivery.is_some() || self .tasks .iter() diff --git a/src/main.rs b/src/main.rs index a0e961cb..408ba202 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1600,7 +1600,7 @@ fn doctor_cmd(root: &Path, host: Option, require_supervisor: bool) -> Re if !spec.has_delivery_transport() { report_advisory( &format!("{bus_id} delivery transport missing"), - "declare `ding` or `deliver`; agent receives no DING", + "declare `ding`, `deliver`, or a driver block; agent receives no DING", ); } for task in &spec.tasks { diff --git a/tests/doctor.rs b/tests/doctor.rs index edb46f83..45187f24 100644 --- a/tests/doctor.rs +++ b/tests/doctor.rs @@ -390,7 +390,7 @@ fn missing_delivery_is_advisory_while_an_invalid_delivery_is_a_catalog_problem() ); assert!( stdout.contains( - "⚠ h.worker delivery transport missing — declare `ding` or `deliver`; agent receives no DING" + "⚠ h.worker delivery transport missing — declare `ding`, `deliver`, or a driver block; agent receives no DING" ), "{stdout}" ); @@ -405,6 +405,16 @@ fn missing_delivery_is_advisory_while_an_invalid_delivery_is_a_catalog_problem() assert!(declared.status.success(), "{stdout}"); assert!(!stdout.contains("delivery transport missing"), "{stdout}"); + fs::write( + &declaration, + r#"agent "worker" { host "h"; claude { prompt "Start work." } }"#, + ) + .unwrap(); + let driver = doctor(&catalog, &bin, &tmp.path().join("state")); + let stdout = String::from_utf8_lossy(&driver.stdout); + assert!(driver.status.success(), "{stdout}"); + assert!(!stdout.contains("delivery transport missing"), "{stdout}"); + fs::write( &declaration, r#"agent "worker" { host "h"; command "true"; deliver "mpc" }"#, From dd9d858a62163856e7a73dfe3b3e7a020cec2b80 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Fri, 14 Aug 2026 10:53:43 +0200 Subject: [PATCH 45/56] Preserve driver runtime safety gates --- src/driver.rs | 10 +++++++ src/hooks.rs | 57 +++++++++++++++++++++++++++++++++------ src/materialize.rs | 1 + src/reconcile.rs | 3 +++ src/validate.rs | 5 ++-- tests/codex_app_server.rs | 5 ++++ tests/driver_expansion.rs | 48 +++++++++++++++++++++++++++++++++ tests/validate.rs | 2 +- 8 files changed, 119 insertions(+), 12 deletions(-) diff --git a/src/driver.rs b/src/driver.rs index cf28d97a..32669deb 100644 --- a/src/driver.rs +++ b/src/driver.rs @@ -11,6 +11,16 @@ const ST2: &str = "st2"; const CATALOG: &str = "$CATALOG"; const CLAUDE_SERVER: &str = "st2"; +/// Reject two launch sources before expansion, task compilation, or workspace writes. +pub(crate) fn ensure_single_source(spec: &AgentSpec) -> Result<()> { + anyhow::ensure!( + spec.driver.is_none() || spec.delivery.is_none(), + "agent '{}' declares both a driver block and `deliver`; choose one launch source", + spec.identity + ); + Ok(()) +} + /// Expand one typed driver into KDL nodes that can be written inside an `agent {}` block. pub fn expand_driver(spec: &AgentSpec, this_host: &str) -> Result { let driver = spec diff --git a/src/hooks.rs b/src/hooks.rs index 33db5f52..3082caea 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -143,14 +143,17 @@ pub fn required_by_codex_agent( catalog_root: &Path, ) -> bool { spec.host.as_deref().is_none_or(|host| host == this_host) - && spec.tasks.iter().any(|task| { + && (matches!( + spec.driver.as_ref(), + Some(agent_spec::spec::Driver::Codex(_)) + ) || spec.tasks.iter().any(|task| { task.name == "agent" && (task.command.as_deref().is_some_and(command_invokes_codex) || task .argv .as_deref() .is_some_and(|argv| argv_invokes_codex(argv, catalog_root))) - }) + })) } pub(crate) fn launch_invokes_codex( @@ -170,12 +173,21 @@ fn argv_invokes_codex(argv: &[String], catalog_root: &Path) -> bool { } fn argv_invokes_codex_with(argv: &[String], expand: impl FnOnce(&str) -> String) -> bool { - argv.first().is_some_and(|program| { - let program = expand(program); - Path::new(&program) - .file_name() - .is_some_and(|name| name == "codex") - }) + let Some(program) = argv.first() else { + return false; + }; + let program = expand(program); + let Some(program) = Path::new(&program).file_name() else { + return false; + }; + if program == "codex" { + return true; + } + program == "st2" + && argv.get(1).map(String::as_str) == Some("--catalog") + && (argv.get(3).map(String::as_str) == Some("codex-app-server") + || (argv.get(3).map(String::as_str) == Some("driver") + && argv.get(4).map(String::as_str) == Some("codex"))) } /// Recognize the exact command shape emitted for Codex agents while accepting an absolute binary @@ -451,8 +463,37 @@ mod tests { &["$CODEX_BIN".into(), "resume".into()], |_| "/opt/bin/codex".into() )); + assert!(argv_invokes_codex( + &[ + "st2".into(), + "--catalog".into(), + "/catalog".into(), + "codex-app-server".into(), + ], + root + )); + assert!(argv_invokes_codex( + &[ + "st2".into(), + "--catalog".into(), + "/catalog".into(), + "driver".into(), + "codex".into(), + ], + root + )); assert!(!argv_invokes_codex(&[], root)); assert!(!argv_invokes_codex(&["codex-wrapper".into()], root)); + assert!(!argv_invokes_codex( + &[ + "st2".into(), + "--catalog".into(), + "/catalog".into(), + "driver".into(), + "claude".into(), + ], + root + )); } #[test] diff --git a/src/materialize.rs b/src/materialize.rs index be0cc440..27d4919e 100644 --- a/src/materialize.rs +++ b/src/materialize.rs @@ -255,6 +255,7 @@ fn parse_plan_with_driver(spec: &AgentSpec, this_host: &str) -> Result Result { + crate::driver::ensure_single_source(spec)?; let mut plan = parse_plan_with_driver(spec, this_host)?; if spec.driver.is_none() && spec.delivery == Some(agent_spec::spec::DeliveryTransport::Mcp) diff --git a/src/reconcile.rs b/src/reconcile.rs index 044a50e3..879069f0 100644 --- a/src/reconcile.rs +++ b/src/reconcile.rs @@ -86,6 +86,9 @@ pub fn compile_generated_tasks( this_host: &str, context: &TaskCompileContext, ) -> Result<()> { + for spec in specs.iter() { + crate::driver::ensure_single_source(spec)?; + } compile_driver_agent_tasks(specs, this_host, context)?; compile_generated_ding_tasks(specs, this_host, context)?; compile_app_server_agent_tasks(specs, this_host, context)?; diff --git a/src/validate.rs b/src/validate.rs index 1ed2ff47..87ba64c0 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -220,13 +220,12 @@ fn validate_scoped(root: &Path, this_host: Option<&str>) -> Report { )); } - if s.driver.is_some() && s.delivery.is_some() { + if let Err(error) = crate::driver::ensure_single_source(s) { issues.push(Issue::error( "driver-deliver-conflict", rp.clone(), ag.clone(), - "agent declares both a driver block and `deliver`; choose one launch source" - .to_string(), + error.to_string(), )); } diff --git a/tests/codex_app_server.rs b/tests/codex_app_server.rs index 6bbe3af2..3055a69c 100644 --- a/tests/codex_app_server.rs +++ b/tests/codex_app_server.rs @@ -92,6 +92,11 @@ fn codex_driver_matches_deliver_after_normalizing_only_the_subcommand_alias() { let mut legacy = legacy.into_iter().next().unwrap(); let mut driver = driver.into_iter().next().unwrap(); let compile_context = context(tmp.path()); + assert!(st2::hooks::required_by_codex_agent( + &driver, + "h", + tmp.path() + )); compile_generated_tasks( std::slice::from_mut(&mut legacy), diff --git a/tests/driver_expansion.rs b/tests/driver_expansion.rs index 2dc4c614..5680e588 100644 --- a/tests/driver_expansion.rs +++ b/tests/driver_expansion.rs @@ -116,6 +116,11 @@ fn claude_driver_matches_deliver_after_normalizing_only_resolution_and_the_alias let (driver, _) = st2::discover_file(&catalog, &driver_path).unwrap(); let mut legacy = legacy.into_iter().next().unwrap(); let mut driver = driver.into_iter().next().unwrap(); + assert!(!st2::hooks::required_by_codex_agent( + &driver, + "h", + &catalog + )); let executable = catalog.join("bin/st2"); fs::create_dir_all(executable.parent().unwrap()).unwrap(); fs::write(&executable, "test binary").unwrap(); @@ -151,3 +156,46 @@ fn claude_driver_matches_deliver_after_normalizing_only_resolution_and_the_alias args.splice(2..4, [serde_json::Value::String("claude-mcp".into())]); assert_eq!(driver_mcp, legacy_mcp); } + +#[test] +fn ambiguous_driver_source_neither_compiles_nor_materializes() { + let temp = tempfile::tempdir().unwrap(); + let catalog = temp.path().join("catalog"); + let workspace = temp.path().join("workspace"); + fs::create_dir_all(&workspace).unwrap(); + let path = catalog.join("agent.kdl"); + fs::create_dir_all(&catalog).unwrap(); + fs::write( + &path, + format!( + r#"agent "worker" {{ + host "h" + workspace "{}" + deliver "mcp" + claude {{ prompt "boot" }} +}} +"#, + workspace.display() + ), + ) + .unwrap(); + let (specs, _) = st2::discover_file(&catalog, &path).unwrap(); + let mut spec = specs.into_iter().next().unwrap(); + let before = spec.clone(); + let executable = catalog.join("bin/st2"); + fs::create_dir_all(executable.parent().unwrap()).unwrap(); + fs::write(&executable, "test binary").unwrap(); + let context = TaskCompileContext::new(catalog.clone(), executable).unwrap(); + + let compile_error = + compile_generated_tasks(std::slice::from_mut(&mut spec), "h", &context).unwrap_err(); + assert!(compile_error.to_string().contains("choose one launch source")); + assert_eq!(spec, before); + let materialize_error = materialize_agent(&catalog, &spec, "h").unwrap_err(); + assert!( + materialize_error + .to_string() + .contains("choose one launch source") + ); + assert!(!workspace.join(".mcp.json").exists()); +} diff --git a/tests/validate.rs b/tests/validate.rs index dece6b72..75cf8952 100644 --- a/tests/validate.rs +++ b/tests/validate.rs @@ -198,7 +198,7 @@ fn a_driver_block_and_deliver_are_two_conflicting_launch_sources() { assert_eq!(issue.severity, Severity::Error); assert_eq!( issue.message, - "agent declares both a driver block and `deliver`; choose one launch source" + "agent 'worker' declares both a driver block and `deliver`; choose one launch source" ); } From 6954bcb88666911b73e73cc2b0bf746f7ccc55bd Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Fri, 14 Aug 2026 10:56:18 +0200 Subject: [PATCH 46/56] Resolve driver wrappers to current binary --- crates/agent-spec/src/spec.rs | 5 ++--- src/driver.rs | 3 ++- src/hooks.rs | 2 +- src/materialize.rs | 40 ++++++++++++++++++++++++++++++++++- src/run.rs | 2 +- tests/codex_app_server.rs | 4 ++++ tests/driver_expansion.rs | 9 ++++---- 7 files changed, 54 insertions(+), 11 deletions(-) diff --git a/crates/agent-spec/src/spec.rs b/crates/agent-spec/src/spec.rs index cf3d4885..74815270 100644 --- a/crates/agent-spec/src/spec.rs +++ b/crates/agent-spec/src/spec.rs @@ -65,8 +65,7 @@ impl DeliveryTransport { /// One typed harness driver declaration. /// -/// The runner preserves this additive declaration field but does not execute or expand it. The st2 -/// command layer owns inspectable expansion into ordinary Agent Spec KDL primitives. +/// st2 expands this field into inspectable Agent Spec KDL before task and render compilation. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Driver { Claude(ClaudeDriver), @@ -163,7 +162,7 @@ pub struct AgentSpec { pub restart: Option, /// Provider-native delivery selected by `deliver`; `None` means legacy `ding` or no delivery. pub delivery: Option, - /// Additive typed harness declaration. Runtime paths do not read this field yet. + /// Typed harness declaration used by task and render compilation. pub driver: Option, /// Named typed references used by the agent. st2 preserves these for readers but does not /// resolve them or assign launch, readiness, access, or lifecycle semantics. diff --git a/src/driver.rs b/src/driver.rs index 32669deb..63a5cc58 100644 --- a/src/driver.rs +++ b/src/driver.rs @@ -1,7 +1,7 @@ //! Pure typed-driver expansion into ordinary hand-authorable Agent Spec KDL primitives. //! //! Expansion does not read files, inspect a harness, mutate a declaration, or execute a process. -//! Reconcile and materialization do not call this module. +//! Print, reconcile, and materialization use this same expansion. use agent_spec::spec::{AgentSpec, ClaudeDriver, CodexDriver, Driver}; use anyhow::{Context, Result}; @@ -23,6 +23,7 @@ pub(crate) fn ensure_single_source(spec: &AgentSpec) -> Result<()> { /// Expand one typed driver into KDL nodes that can be written inside an `agent {}` block. pub fn expand_driver(spec: &AgentSpec, this_host: &str) -> Result { + ensure_single_source(spec)?; let driver = spec .driver .as_ref() diff --git a/src/hooks.rs b/src/hooks.rs index 3082caea..954afeea 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -136,7 +136,7 @@ pub fn required_by_codex( .any(|spec| required_by_codex_agent(spec, this_host, catalog_root)) } -/// Whether one local declaration owns a Codex agent task. +/// Whether one local declaration owns a Codex agent launch. pub fn required_by_codex_agent( spec: &agent_spec::spec::AgentSpec, this_host: &str, diff --git a/src/materialize.rs b/src/materialize.rs index 27d4919e..0aadbe84 100644 --- a/src/materialize.rs +++ b/src/materialize.rs @@ -242,17 +242,55 @@ fn parse_plan_with_driver(spec: &AgentSpec, this_host: &str) -> Result Result<()> { + if plan.ops.is_empty() { + return Ok(()); + } + let executable = std::env::current_exe() + .context("resolving st2 executable for driver materialization")?; + for operation in &mut plan.ops { + let RenderOp::JsonUpsert { + destination, + content, + } = operation + else { + continue; + }; + anyhow::ensure!( + destination == ".mcp.json", + "agent '{agent}' driver expansion produced an unexpected JSON destination" + ); + let mut patch: serde_json::Value = serde_json::from_str(content)?; + let command = patch + .pointer_mut("/mcpServers/st2/command") + .with_context(|| { + format!("agent '{agent}' driver expansion has no st2 MCP command") + })?; + anyhow::ensure!( + command.as_str() == Some("st2"), + "agent '{agent}' driver expansion has an unexpected st2 MCP command" + ); + *command = serde_json::Value::String(executable.to_string_lossy().into_owned()); + *content = serde_json::to_string(&patch)?; + } + Ok(()) +} + /// Add either the typed driver render or the unchanged legacy delivery render. fn effective_plan(root: &Path, spec: &AgentSpec, this_host: &str) -> Result { crate::driver::ensure_single_source(spec)?; diff --git a/src/run.rs b/src/run.rs index 65169368..064e60f8 100644 --- a/src/run.rs +++ b/src/run.rs @@ -1518,7 +1518,7 @@ fn reconcile_pass( report.skipped = true; report .errors - .push(format!("compile generated DING tasks (pass skipped): {error:#}")); + .push(format!("compile generated tasks (pass skipped): {error:#}")); return report; } diff --git a/tests/codex_app_server.rs b/tests/codex_app_server.rs index 3055a69c..38274a36 100644 --- a/tests/codex_app_server.rs +++ b/tests/codex_app_server.rs @@ -69,6 +69,8 @@ fn codex_driver_matches_deliver_after_normalizing_only_the_subcommand_alias() { &legacy_path, r#"agent "worker" { host "h" + lifecycle "adopt-only" + env { CODEX_HOME "$CATALOG/codex" } deliver "app-server" argv "codex" "--model" "gpt-test" "-c" "model_reasoning_effort=xhigh" "--model" "override" "boot" } @@ -78,6 +80,8 @@ fn codex_driver_matches_deliver_after_normalizing_only_the_subcommand_alias() { &driver_path, r#"agent "worker" { host "h" + lifecycle "adopt-only" + env { CODEX_HOME "$CATALOG/codex" } codex { model "gpt-test" effort "xhigh" diff --git a/tests/driver_expansion.rs b/tests/driver_expansion.rs index 5680e588..3a812936 100644 --- a/tests/driver_expansion.rs +++ b/tests/driver_expansion.rs @@ -69,7 +69,7 @@ fn cli_prints_each_snapshot_without_changing_its_input() { } #[test] -fn claude_driver_matches_deliver_after_normalizing_only_resolution_and_the_alias() { +fn claude_driver_matches_deliver_after_normalizing_only_the_subcommand_alias() { let temp = tempfile::tempdir().unwrap(); let catalog = temp.path().join("catalog"); let legacy_workspace = temp.path().join("legacy-workspace"); @@ -146,9 +146,10 @@ fn claude_driver_matches_deliver_after_normalizing_only_resolution_and_the_alias serde_json::from_slice(&fs::read(legacy_workspace.join(".mcp.json")).unwrap()).unwrap(); let mut driver_mcp: serde_json::Value = serde_json::from_slice(&fs::read(driver_workspace.join(".mcp.json")).unwrap()).unwrap(); - assert_eq!(driver_mcp["mcpServers"]["st2"]["command"], "st2"); - driver_mcp["mcpServers"]["st2"]["command"] = - legacy_mcp["mcpServers"]["st2"]["command"].clone(); + assert_eq!( + driver_mcp["mcpServers"]["st2"]["command"], + legacy_mcp["mcpServers"]["st2"]["command"] + ); let args = driver_mcp["mcpServers"]["st2"]["args"] .as_array_mut() .unwrap(); From ba3f9b9b7e087e321a455c9ed4fb19697960afe5 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Fri, 14 Aug 2026 10:57:20 +0200 Subject: [PATCH 47/56] Keep driver compilation clippy clean --- src/reconcile.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reconcile.rs b/src/reconcile.rs index 879069f0..23b86123 100644 --- a/src/reconcile.rs +++ b/src/reconcile.rs @@ -154,7 +154,7 @@ pub fn compile_driver_agent_tasks( if matches!(driver, Driver::Codex(_)) { anyhow::ensure!( - argv.get(0).map(String::as_str) == Some("st2") + argv.first().map(String::as_str) == Some("st2") && argv.get(1).map(String::as_str) == Some("--catalog") && argv.get(2).map(String::as_str) == Some("$CATALOG") && argv.get(3).map(String::as_str) == Some("driver") From 277b35eaa36639b241c1c4e9f7bce196268bfc10 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Fri, 14 Aug 2026 11:02:29 +0200 Subject: [PATCH 48/56] Compile driver tasks for inventory --- src/task_inventory.rs | 11 ++++++++- tests/task_inventory_cli.rs | 46 +++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/task_inventory.rs b/src/task_inventory.rs index 4c50556f..067c744a 100644 --- a/src/task_inventory.rs +++ b/src/task_inventory.rs @@ -280,8 +280,17 @@ pub fn inventory( .collect::>(); let mut desired = Vec::new(); let mut runtime_owners: BTreeMap> = BTreeMap::new(); + let mut compiled_specs = found.specs.clone(); + let compilation = crate::reconcile::TaskCompileContext::current(catalog.to_path_buf()) + .and_then(|context| { + crate::reconcile::compile_generated_tasks(&mut compiled_specs, host, &context) + }); + if let Err(error) = compilation { + push_error(&mut errors, format!("compile desired tasks: {error:#}")); + compiled_specs.clear(); + } - for spec in &found.specs { + for spec in &compiled_specs { if spec.resolved_host(host) != host { continue; } diff --git a/tests/task_inventory_cli.rs b/tests/task_inventory_cli.rs index 2390054c..5a512354 100644 --- a/tests/task_inventory_cli.rs +++ b/tests/task_inventory_cli.rs @@ -199,6 +199,52 @@ fn tasks_cli_emits_stable_complete_generation_without_mutation() { assert!(!state.exists(), "read-only inventory created runtime state"); } +#[test] +fn driver_and_deliver_have_identical_end_to_end_inventory() { + let (tmp, catalog, bin) = fixture("[]"); + let declaration = catalog.join("agents/h/worker/agent.kdl"); + let legacy = r#" +agent "worker" { + host "h" + deliver "mcp" + argv "claude" "--model" "opus" "--effort" "xhigh" "boot" +} +"#; + let driver = r#" +agent "worker" { + host "h" + claude { + model "opus" + effort "xhigh" + prompt "boot" + } +} +"#; + fs::write(&declaration, legacy).unwrap(); + let legacy = tasks(&catalog, &bin, &tmp.path().join("state")); + assert!( + legacy.status.success(), + "{}", + String::from_utf8_lossy(&legacy.stderr) + ); + fs::write(&declaration, driver).unwrap(); + let driver = tasks(&catalog, &bin, &tmp.path().join("state")); + assert!( + driver.status.success(), + "{}", + String::from_utf8_lossy(&driver.stderr) + ); + + let legacy: serde_json::Value = serde_json::from_slice(&legacy.stdout).unwrap(); + let driver: serde_json::Value = serde_json::from_slice(&driver.stdout).unwrap(); + assert_eq!(driver, legacy); + assert_eq!(driver["tasks"].as_array().unwrap().len(), 1); + assert_eq!(driver["tasks"][0]["agent"], "h.worker"); + assert_eq!(driver["tasks"][0]["task"], "agent"); + assert_eq!(driver["tasks"][0]["kind"], "pty"); + assert_eq!(driver["tasks"][0]["lifecycle"], "service"); +} + #[test] fn suspended_agent_projects_task_absence_and_agent_rationale_separately() { let (tmp, catalog, bin) = fixture("[]"); From b21f6d746f632568a0d614b8585c9f237c1b646a Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Fri, 14 Aug 2026 22:47:13 +0200 Subject: [PATCH 49/56] Compile driver launches before inspection --- crates/agent-spec/src/spec.rs | 11 ++-- crates/agent-spec/tests/discovery.rs | 8 ++- src/eval_run.rs | 97 ++++++++++++++++++---------- src/main.rs | 8 ++- src/validate.rs | 42 +++++++++--- tests/validate.rs | 60 +++++++++++++++++ 6 files changed, 175 insertions(+), 51 deletions(-) diff --git a/crates/agent-spec/src/spec.rs b/crates/agent-spec/src/spec.rs index 74815270..faa9fe75 100644 --- a/crates/agent-spec/src/spec.rs +++ b/crates/agent-spec/src/spec.rs @@ -392,14 +392,13 @@ impl AgentSpec { self.host.as_deref().unwrap_or(this_host) } - /// True when a driver can compile the launch or an authored task already contains one. + /// True when a compiled or authored task contains a launch. + /// Callers that accept driver blocks must compile generated tasks before this check. /// A generated sidecar cannot make an otherwise-empty job runnable. pub fn is_runnable(&self) -> bool { - self.driver.is_some() - || self - .tasks - .iter() - .any(|task| !task.derived && (task.command.is_some() || task.argv.is_some())) + self.tasks + .iter() + .any(|task| !task.derived && (task.command.is_some() || task.argv.is_some())) } /// True when the declaration selected legacy screen delivery or one native transport. diff --git a/crates/agent-spec/tests/discovery.rs b/crates/agent-spec/tests/discovery.rs index 59ce1a15..1ab097e2 100644 --- a/crates/agent-spec/tests/discovery.rs +++ b/crates/agent-spec/tests/discovery.rs @@ -638,7 +638,9 @@ args = ["--dangerously-bypass-approvals-and-sandbox"] args: vec!["--permission-mode".into(), "bypassPermissions".into()], }); for identity in ["claude-kdl", "claude-toml", "claude-json"] { - assert_eq!(find(&found.specs, identity).driver.as_ref(), Some(&claude)); + let spec = find(&found.specs, identity); + assert_eq!(spec.driver.as_ref(), Some(&claude)); + assert!(!spec.is_runnable()); } let codex = Driver::Codex(CodexDriver { model: Some("gpt-5.6-sol".into()), @@ -647,7 +649,9 @@ args = ["--dangerously-bypass-approvals-and-sandbox"] args: vec!["--dangerously-bypass-approvals-and-sandbox".into()], }); for identity in ["codex-kdl", "codex-toml", "codex-json"] { - assert_eq!(find(&found.specs, identity).driver.as_ref(), Some(&codex)); + let spec = find(&found.specs, identity); + assert_eq!(spec.driver.as_ref(), Some(&codex)); + assert!(!spec.is_runnable()); } } diff --git a/src/eval_run.rs b/src/eval_run.rs index 8149ae39..83d5e407 100644 --- a/src/eval_run.rs +++ b/src/eval_run.rs @@ -132,7 +132,6 @@ pub fn spec_to_agent_specs(agents: &[SpecAgent], host: &str, root: &Path) -> Vec #[derive(Debug)] struct CanonicalEvalTeam { specs: Vec, - runtime_tasks: Vec, routes: BTreeMap, } @@ -168,6 +167,25 @@ fn task_is_launchable(task: &Task) -> bool { task.command.is_some() || task.argv.is_some() } +/// Project runtime inventory only after generated launch tasks are compiled. +fn eval_runtime_tasks(specs: &[AgentSpec], host: &str) -> Vec { + let mut tasks = specs + .iter() + .flat_map(|spec| { + spec.tasks + .iter() + .filter(|task| task_is_launchable(task)) + .map(|task| EvalRuntimeTask { + agent_id: spec.bus_id(host), + runtime_id: task_runtime_id(spec, task, host), + is_pty: task.kind == TaskKind::Pty, + }) + }) + .collect::>(); + tasks.sort_by(|left, right| left.runtime_id.cmp(&right.runtime_id)); + tasks +} + /// Discover the sole declaration authority for a `canonical-agents` eval after its fixture and run /// steps have populated the hermetic catalog. This deliberately consumes the shared Agent Spec /// parser instead of projecting the compact eval grammar into a second, partial declaration. @@ -228,7 +246,6 @@ fn load_canonical_eval_team(catalog: &Path, host: &str) -> Result::new(); - let mut runtime_tasks = Vec::new(); let mut routes = BTreeMap::new(); for spec in &local_specs { let bus_id = spec.bus_id(host); @@ -241,9 +258,6 @@ fn load_canonical_eval_team(catalog: &Path, host: &str) -> Result Result Result Result>(); ( team.specs, - team.runtime_tasks, participants, Some(team.routes), ) } else { let specs = spec_to_agent_specs(&compact_agents, host, catalog); - let runtime_tasks = specs - .iter() - .flat_map(|spec| { - spec.tasks - .iter() - .filter(|task| task_is_launchable(task)) - .map(|task| EvalRuntimeTask { - agent_id: spec.bus_id(host), - runtime_id: task_runtime_id(spec, task, host), - is_pty: task.kind == TaskKind::Pty, - }) - }) - .collect::>(); let participants = specs .iter() .map(|spec| spec.bus_id(host)) .collect::>(); - (specs, runtime_tasks, participants, None) + (specs, participants, None) }; compile_generated_tasks(&mut specs, host, task_context)?; + let runtime_tasks = eval_runtime_tasks(&specs, host); let task_ids = runtime_tasks .iter() .map(|task| task.runtime_id.clone()) @@ -1558,8 +1549,11 @@ mod tests { let team = load_canonical_eval_team(catalog.path(), "evalhost").unwrap(); assert_eq!(team.specs.len(), 2); + let context = TaskCompileContext::current(catalog.path().to_path_buf()).unwrap(); + let mut compiled = team.specs.clone(); + compile_generated_tasks(&mut compiled, "evalhost", &context).unwrap(); assert_eq!( - team.runtime_tasks + eval_runtime_tasks(&compiled, "evalhost") .iter() .map(|task| task.runtime_id.as_str()) .collect::>(), @@ -1568,6 +1562,38 @@ mod tests { assert!(team.specs.iter().all(|spec| spec.path.ends_with("agent.kdl"))); } + #[test] + fn canonical_eval_runtime_inventory_compiles_driver_launches() { + let catalog = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(catalog.path().join("worker")).unwrap(); + write_eval_agent( + catalog.path(), + "agents/evalhost/worker/agent.kdl", + r#"agent "worker" { + host "evalhost" + workspace "$CATALOG/worker" + claude { prompt "Start the assigned work." } +} +"#, + ); + + let team = load_canonical_eval_team(catalog.path(), "evalhost").unwrap(); + assert!(!team.specs[0].is_runnable()); + let context = TaskCompileContext::current(catalog.path().to_path_buf()).unwrap(); + let mut compiled = team.specs.clone(); + compile_generated_tasks(&mut compiled, "evalhost", &context).unwrap(); + + assert!(compiled[0].is_runnable()); + assert_eq!( + eval_runtime_tasks(&compiled, "evalhost"), + [EvalRuntimeTask { + agent_id: "evalhost.worker".into(), + runtime_id: "evalhost.worker".into(), + is_pty: true, + }] + ); + } + #[test] fn canonical_eval_team_projects_local_path_independent_agents_and_tears_down_every_task() { let catalog = tempfile::tempdir().unwrap(); @@ -1600,7 +1626,12 @@ mod tests { ["evalhost.local"] ); assert_eq!( - team.runtime_tasks, + { + let context = TaskCompileContext::current(catalog.path().to_path_buf()).unwrap(); + let mut compiled = team.specs.clone(); + compile_generated_tasks(&mut compiled, "evalhost", &context).unwrap(); + eval_runtime_tasks(&compiled, "evalhost") + }, [ EvalRuntimeTask { agent_id: "evalhost.local".into(), diff --git a/src/main.rs b/src/main.rs index 408ba202..e1c1a085 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3042,11 +3042,15 @@ fn ls(root: &Path) -> Result<()> { let _catalog_lock = st2::CatalogLock::shared(root) .context("acquire shared catalog-authoring lock for catalog listing")?; let found = discover(root); + let mut specs = found.specs.clone(); + let task_context = st2::reconcile::TaskCompileContext::current(root.to_path_buf())?; + st2::reconcile::compile_generated_tasks(&mut specs, &detect_host(), &task_context) + .context("compile generated tasks for catalog listing")?; - if found.specs.is_empty() { + if specs.is_empty() { println!("no specs found under {}", root.display()); } - for spec in &found.specs { + for spec in &specs { let host = spec.host.as_deref().unwrap_or(""); let kind = match spec.job_type { st2::JobType::Service => "service", diff --git a/src/validate.rs b/src/validate.rs index 87ba64c0..72b2b7b5 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -124,6 +124,18 @@ fn validate_scoped(root: &Path, this_host: Option<&str>) -> Report { let root = &root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); let d = discover(root); let mut issues = Vec::new(); + let task_context = match crate::reconcile::TaskCompileContext::current(root.to_path_buf()) { + Ok(context) => Some(context), + Err(error) => { + issues.push(Issue::error( + "launch-compile-error", + ".".to_string(), + None, + format!("cannot prepare generated task compilation: {error:#}"), + )); + None + } + }; // 1. Files that looked like specs but did not parse/resolve — discovery already caught these. for e in &d.errors { @@ -204,6 +216,17 @@ fn validate_scoped(root: &Path, this_host: Option<&str>) -> Report { Some(host) => s.resolved_host(host) == host, None => true, }; + let compiled = task_context.as_ref().map(|context| { + let mut compiled = s.clone(); + let compile_host = this_host.or(s.host.as_deref()).unwrap_or(""); + crate::reconcile::compile_generated_tasks( + std::slice::from_mut(&mut compiled), + compile_host, + context, + ) + .map(|()| compiled) + .map_err(|error| format!("{error:#}")) + }); // Duplicate bus id — the runner cannot run two agents under one .. let bid = s.bus_id(collision_host); @@ -220,13 +243,13 @@ fn validate_scoped(root: &Path, this_host: Option<&str>) -> Report { )); } - if let Err(error) = crate::driver::ensure_single_source(s) { - issues.push(Issue::error( - "driver-deliver-conflict", - rp.clone(), - ag.clone(), - error.to_string(), - )); + if let Some(Err(error)) = &compiled { + let code = if s.driver.is_some() && s.delivery.is_some() { + "driver-deliver-conflict" + } else { + "launch-compile-error" + }; + issues.push(Issue::error(code, rp.clone(), ag.clone(), error.clone())); } // An explicit identity+host pair is authoritative regardless of folder names. When either @@ -263,7 +286,10 @@ fn validate_scoped(root: &Path, this_host: Option<&str>) -> Report { // A rendered service agent must be runnable. Batch jobs legitimately carry no pty/exec tasks // (their work is in stages/run) — never flag them here. - if s.job_type == JobType::Service && !s.is_runnable() { + if let Some(Ok(compiled)) = &compiled + && s.job_type == JobType::Service + && !compiled.is_runnable() + { issues.push(Issue::error( "not-runnable", rp.clone(), diff --git a/tests/validate.rs b/tests/validate.rs index 75cf8952..04719dcf 100644 --- a/tests/validate.rs +++ b/tests/validate.rs @@ -416,6 +416,44 @@ fn an_unrendered_service_is_not_runnable() { assert!(has(&validate(c.path()), "not-runnable", Severity::Error)); } +#[test] +fn fleet_validation_compiles_remote_driver_launches() { + let c = catalog(&[( + "Silber/worker/agent.kdl", + r#"agent "worker" { + host "Silber" + workspace "/tmp" + claude { prompt "boot" } +}"#, + )]); + let found = st2::discover(c.path()); + assert!(!found.specs[0].is_runnable()); + + for report in [validate(c.path()), validate_for_host(c.path(), "droppy")] { + assert_eq!(report.errors(), 0, "unexpected issues: {:?}", report.issues); + assert_eq!(report.warnings(), 0, "unexpected issues: {:?}", report.issues); + } +} + +#[test] +fn validation_reports_shared_task_compiler_errors() { + let c = catalog(&[( + "h/worker/agent.kdl", + r#"agent "worker" { + host "h" + workspace "/tmp" + command "codex" + deliver "app-server" +}"#, + )]); + + assert!(has( + &validate_for_host(c.path(), "h"), + "launch-compile-error", + Severity::Error + )); +} + #[test] fn a_generated_ding_sidecar_is_not_authored_runnable_work() { let c = catalog(&[("hetz/w/agent.kdl", r#"agent "w" { host "hetz"; ding }"#)]); @@ -439,6 +477,28 @@ fn ls_marks_a_generated_ding_only_agent_as_unrendered() { ); } +#[test] +fn ls_compiles_driver_launches_before_display() { + let c = catalog(&[( + "h/worker/agent.kdl", + r#"agent "worker" { host "h"; claude { prompt "boot" } }"#, + )]); + let output = std::process::Command::new(env!("CARGO_BIN_EXE_st2")) + .arg("--catalog") + .arg(c.path()) + .arg("ls") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(!stdout.contains("UNRENDERED"), "{stdout}"); + assert!(stdout.contains(r#"argv ["claude", "boot"]"#), "{stdout}"); +} + #[test] fn a_missing_render_source_is_an_error() { let workspace = tempfile::tempdir().unwrap(); From ceeb5b8c7964e413521de3faa62e0ef024b18727 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Fri, 14 Aug 2026 22:50:37 +0200 Subject: [PATCH 50/56] Reap app-server process groups --- src/codex_app_server.rs | 106 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 98 insertions(+), 8 deletions(-) diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index 0c341cf9..90817e0d 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -17,6 +17,7 @@ use std::os::unix::ffi::OsStrExt as _; use std::os::unix::fs::{FileTypeExt as _, OpenOptionsExt as _, PermissionsExt as _}; use std::os::unix::io::AsRawFd as _; use std::os::unix::net::UnixStream; +use std::os::unix::process::CommandExt as _; use std::path::{Path, PathBuf}; use std::process::{Child, Command, ExitStatus, Stdio}; use std::sync::mpsc::{self, Receiver, Sender}; @@ -1024,12 +1025,13 @@ fn run_controlled_owned( } } diagnostics.record("appServerStarting", json!({}))?; - let mut server = Command::new(&codex_argv[0]) + let mut server_command = Command::new(&codex_argv[0]); + server_command .args(server_args) .stdin(Stdio::null()) .stdout(log.try_clone()?) - .stderr(log) - .spawn() + .stderr(log); + let mut server = spawn_process_group(&mut server_command) .with_context(|| format!("starting {} app-server", codex_argv[0]))?; let result = diagnostics .record("appServerStarted", json!({ "pid": server.id() })) @@ -1044,7 +1046,7 @@ fn run_controlled_owned( diagnostics, ) }); - terminate_child(&mut server); + terminate_process_group(&mut server); let _ = fs::remove_file(&socket_path); result } @@ -1294,12 +1296,13 @@ fn preflight_hook_trust( diagnostics: &mut WrapperDiagnostics, ) -> Result> { diagnostics.record("hookTrustPreflightStarting", json!({}))?; - let mut server = Command::new(codex) + let mut server_command = Command::new(codex); + server_command .args(server_args) .stdin(Stdio::null()) .stdout(log.try_clone()?) - .stderr(log.try_clone()?) - .spawn() + .stderr(log.try_clone()?); + let mut server = spawn_process_group(&mut server_command) .with_context(|| format!("starting {codex} hook-trust preflight app-server"))?; let result = diagnostics .record("hookTrustPreflightStarted", json!({ "pid": server.id() })) @@ -1308,7 +1311,7 @@ fn preflight_hook_trust( let mut websocket = initialize_control(control)?; query_hook_trust_projection(&mut websocket, cwd) }); - terminate_child(&mut server); + terminate_process_group(&mut server); let _ = fs::remove_file(socket_path); let projection = result?; diagnostics.record( @@ -2352,6 +2355,30 @@ fn poll_json_message(websocket: &mut WebSocket) -> Result std::io::Result { + unsafe { + command.pre_exec(|| { + if libc::setsid() == -1 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } + }); + } + command.spawn() +} + +fn terminate_process_group(child: &mut Child) { + let process_group = child.id() as i32; + unsafe { + libc::kill(-process_group, libc::SIGKILL); + } + let _ = child.kill(); + let _ = child.wait(); +} + fn terminate_child(child: &mut Child) { match child.try_wait() { Ok(Some(_)) => {} @@ -2368,6 +2395,24 @@ mod tests { use std::os::unix::fs::PermissionsExt; use std::os::unix::net::UnixListener; + #[cfg(target_os = "linux")] + fn linux_process_state(pid: i32) -> Option { + std::fs::read_to_string(format!("/proc/{pid}/stat")) + .ok()? + .rsplit_once(") ")? + .1 + .chars() + .next() + } + + fn process_can_retain_cleanup_resources(pid: i32) -> bool { + #[cfg(target_os = "linux")] + if linux_process_state(pid) == Some('Z') { + return false; + } + crate::host_lock::process_alive(pid) + } + #[test] fn protocol_version_gate_accepts_only_the_exact_allowlist() { let tmp = tempfile::tempdir().unwrap(); @@ -4002,6 +4047,51 @@ mod tests { ); } + #[test] + fn process_group_cleanup_reaps_a_native_launcher_descendant() { + let temporary = tempfile::tempdir().unwrap(); + let descendant_pidfile = temporary.path().join("descendant.pid"); + let mut command = Command::new("sh"); + command + .arg("-c") + .arg( + r#"sh -c 'printf "%s" "$$" > "$DESCENDANT_PIDFILE"; exec sleep 60' & sleep 60"#, + ) + .env("DESCENDANT_PIDFILE", &descendant_pidfile) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let mut launcher = spawn_process_group(&mut command).unwrap(); + let deadline = Instant::now() + Duration::from_secs(1); + while !descendant_pidfile.is_file() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + let descendant = std::fs::read_to_string(&descendant_pidfile) + .expect("the launcher did not create its native descendant") + .parse::() + .unwrap(); + assert!( + process_can_retain_cleanup_resources(descendant), + "the native descendant was not alive before cleanup" + ); + + terminate_process_group(&mut launcher); + let deadline = Instant::now() + Duration::from_secs(1); + while process_can_retain_cleanup_resources(descendant) && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + let survived = process_can_retain_cleanup_resources(descendant); + if survived { + unsafe { + libc::kill(descendant, libc::SIGKILL); + } + } + assert!( + !survived, + "native descendant {descendant} survived process-group cleanup" + ); + } + #[test] fn app_server_configuration_extraction_fails_closed_at_ambiguous_boundaries() { let missing = From 67be911088ab545d16771739ab77ab875f742bef Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Fri, 14 Aug 2026 22:52:59 +0200 Subject: [PATCH 51/56] Prove idle native presence refresh --- src/codex_app_server.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index 90817e0d..3f306bb0 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -2566,6 +2566,25 @@ mod tests { assert_eq!(message::list_inbox(&config.inbox).unwrap().len(), 1); } + #[test] + fn idle_session_refreshes_stale_presence_without_inbox_activity() { + let tmp = tempfile::tempdir().unwrap(); + let config = delivery_config(tmp.path()); + let presence = status::status_path(&config.agent_dir); + status::set_state(&presence, status::State::Available).unwrap(); + std::fs::File::open(&presence) + .unwrap() + .set_modified(SystemTime::now() - status::STATUS_STALE - Duration::from_secs(1)) + .unwrap(); + assert_eq!(status::read_state(&presence), status::State::Unknown); + + let mut delivery = inbox_delivery(tmp.path(), config); + delivery.refresh_if_due().unwrap(); + + assert_eq!(status::read_state(&presence), status::State::Available); + assert!(delivery.head.is_none()); + } + #[test] fn a_rejected_exact_steer_has_no_fallback_and_remains_retryable_after_state_changes() { let tmp = tempfile::tempdir().unwrap(); From 5e17b08b7858143b190dbc683deb667ca6ef3b0c Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Fri, 14 Aug 2026 23:41:01 +0200 Subject: [PATCH 52/56] Keep replicated presence alive --- INVARIANTS.md | 2 +- README.md | 10 +- src/claude_mcp.rs | 11 +- src/claude_session.rs | 163 +++++++++++++++++ src/codex_app_server.rs | 48 ++++- src/ding/mod.rs | 13 +- src/driver.rs | 38 +++- src/lib.rs | 1 + src/main.rs | 22 ++- src/reconcile.rs | 108 +++++++++-- src/status.rs | 264 ++++++++++++++++++++------- tests/catalog_apply.rs | 16 +- tests/codex_app_server.rs | 27 ++- tests/driver_expansion.rs | 59 +++++- tests/fixtures/driver/claude.out.kdl | 2 +- tests/status_agents.rs | 6 +- tests/validate.rs | 6 +- 17 files changed, 669 insertions(+), 127 deletions(-) create mode 100644 src/claude_session.rs diff --git a/INVARIANTS.md b/INVARIANTS.md index feeef1bb..3cd53242 100644 --- a/INVARIANTS.md +++ b/INVARIANTS.md @@ -19,7 +19,7 @@ materialization, messaging, DING, or presence must preserve them. | **Bounded DING PTY probe churn** | An unsafe or active composer retains its FIFO notice but deferred delivery retries use a bounded backoff, so each inbox poll cannot spawn another short-lived PTY probe. | `src/ding/mod.rs::deferred_delivery_backoff_bounds_short_lived_pty_attempts` | | **Agent-declared presence discipline** | The shipped bus contract requires agents to declare `busy` before executing work, use `available` only while yielding or ready, and reserve `dnd` for an explicit hold. Both native harnesses materialize that contract. Busy remains observable but does not suppress DING; fresh `dnd` is the only delivery gate. | `tests/native_only.rs::clean_path_executes_the_maintained_native_authoring_guide`; `src/ding/mod.rs::pending_delivery_ignores_busy_but_respects_fresh_dnd_archive_and_retry` | | **Stable roster JSON** | `st2 agents --json [--enrich]` preserves field names, order, null handling, presence, typed desired state and rationale, the retirement compatibility projection, opaque declared Resource descriptors, activity, and inbox counts. Presence remains independent from desired lifecycle. | `src/agents.rs::agents_json_has_stable_wire_shape`; `src/agents.rs::agents_json_preserves_opaque_declared_resource_descriptors`; `tests/status_agents.rs::roster_json_and_human_output_distinguish_retirement_from_presence`; `tests/status_agents.rs::roster_keeps_presence_separate_from_suspended_desired_state` | -| **Agent-declared presence** | Refresh preserves non-DND declared status and only advances liveness; a missing status starts as `available`, while `dnd` is never refreshed and an unrefreshed declaration ages to `unknown`. | `src/status.rs::refresh_preserves_value_and_bumps_mtime`; `src/status.rs::refresh_leaves_dnd_to_age_out`; `src/status.rs::refresh_missing_writes_available_default`; `src/status.rs::stale_mtime_reads_as_unknown_regardless_of_contents` | +| **Agent-declared presence** | Refresh preserves non-DND declared status and advances liveness with a replicated content timestamp. New readers use that timestamp, while old readers remain compatible with the first-line state and new readers accept a legacy bare state. A missing status starts as `available`. `dnd` is never refreshed and ages to `unknown`. The outer Codex and Claude session wrappers own a five-minute heartbeat while their provider remains alive. | `src/status.rs::refresh_preserves_value_and_changes_the_replicated_bytes`; `src/status.rs::timestamped_status_keeps_the_old_reader_first_line`; `src/status.rs::timestamped_content_controls_freshness_after_replication`; `src/status.rs::legacy_bare_word_uses_mtime_during_a_mixed_fleet_upgrade`; `src/status.rs::writer_and_replica_carry_the_same_fresh_timestamp_while_idle`; `src/status.rs::refresh_leaves_dnd_to_age_out`; `src/status.rs::refresh_missing_writes_available_default`; `src/claude_session.rs::idle_provider_refreshes_presence_without_mcp_input`; `src/codex_app_server.rs::inbox_fallback_does_not_write_a_fifteen_second_presence_heartbeat` | | **Retirement health** | A retired declaration is healthy only after every declared task ID is absent. Any live or dead declared task record reports incomplete retirement; retired declarations do not require presence. Live declarations retain their existing task and presence checks. | `tests/doctor.rs::retired_declaration_is_healthy_when_tasks_and_presence_are_absent`; `tests/doctor.rs::retired_declaration_is_unhealthy_while_a_declared_task_is_alive`; `tests/doctor.rs::retired_declaration_is_unhealthy_while_a_dead_task_record_remains` | | **Suspension health** | A suspended declaration is healthy when no declared task is live and every retained dead record is explicitly keep-pinned. It requires no presence, but this weaker result never proves retirement. Resume preserves ordinary keep and adopt-only policy. | `tests/doctor.rs::suspended_declaration_is_healthy_when_tasks_are_absent_without_presence`; `tests/doctor.rs::suspended_declaration_distinguishes_live_dead_keep_and_dead_nonkeep`; `tests/reconcile.rs::resuming_uses_ordinary_reconcile_and_does_not_override_keep` | | **Crash loops surface** | A task parked by a fail-mode restart policy notifies its supervisor once over the bus. | `tests/run.rs::surface_crash_loop_notifies_the_supervisor_over_the_bus` | diff --git a/README.md b/README.md index 812033f9..12bc1c34 100644 --- a/README.md +++ b/README.md @@ -475,9 +475,13 @@ contract; renderer changes can defer delivery and remain an explicit design gap. Agents must declare `busy` before actively executing work and return to `available` only when yielding or ready for new work, but `busy` never suppresses DING. Fresh `dnd` is the only delivery -hold. The sidecar does not refresh `dnd`, so an abandoned hold becomes stale after 15 minutes and -delivery resumes. New arrivals remain FIFO, same-filename archive receipts shadow and clean restored -inbox duplicates, and failed or uncertain PTY operations retain the notice for safe retry. Unsafe +hold. Each status record keeps the state on its first line. New writers add +`updated-at-unix-ms ` on the second line. New readers use that timestamp and accept a +legacy bare state. Old readers keep using the first line and file mtime. A live session owner +refreshes non-DND presence every five minutes. This changes the replicated bytes and stays below the +15-minute stale limit. A session owner does not refresh `dnd`, so an abandoned hold becomes stale +and delivery resumes. New arrivals remain FIFO. Same-filename archive receipts shadow and clean +restored inbox duplicates. Failed or uncertain PTY operations retain the notice for safe retry. Unsafe delivery retries use a bounded backoff, so an active composer cannot make the sidecar spawn a fresh PTY probe on every inbox poll. On start or restart, the sidecar first adopts an exact staged recovery/backlog notice when present, then sends diff --git a/src/claude_mcp.rs b/src/claude_mcp.rs index b2dc6670..e6d7317e 100644 --- a/src/claude_mcp.rs +++ b/src/claude_mcp.rs @@ -1,14 +1,15 @@ //! Minimal Claude channel watcher. //! //! The inbox is the durable source of truth. This process keeps only an ephemeral set of -//! filenames delivered during its current lifetime; a restart scans the inbox again. +//! filenames delivered during its current lifetime; a restart scans the inbox again. The outer +//! Claude session wrapper owns presence because Claude can close this child before the session ends. use std::collections::HashSet; use std::io::{self, BufRead, Write}; use std::path::Path; use std::sync::mpsc::{self, RecvTimeoutError}; use std::thread; -use std::time::{Duration, Instant}; +use std::time::Duration; use anyhow::{Context as _, Result}; use serde_json::{Value, json}; @@ -28,7 +29,6 @@ pub fn run(catalog_root: &Path, identity: &str) -> Result<()> { let agent_dir = message::resolve_agent_dir(catalog_root, identity, &crate::run::detect_host())? .with_context(|| format!("Claude MCP agent '{identity}' is not declared"))?; let inbox = message::inbox_dir(&agent_dir); - let status_path = crate::status::status_path(&agent_dir); let (input_tx, input_rx) = mpsc::channel(); thread::spawn(move || { for line in io::stdin().lock().lines() { @@ -40,12 +40,7 @@ pub fn run(catalog_root: &Path, identity: &str) -> Result<()> { let mut stdout = io::BufWriter::new(io::stdout().lock()); let mut delivered = HashSet::new(); let mut initialized = false; - let mut next_status_refresh = Instant::now(); loop { - if Instant::now() >= next_status_refresh { - let _ = crate::status::refresh(&status_path); - next_status_refresh = Instant::now() + crate::status::STATUS_REFRESH; - } match input_rx.recv_timeout(POLL) { Ok(line) => { let line = line.context("reading Claude MCP input")?; diff --git a/src/claude_session.rs b/src/claude_session.rs new file mode 100644 index 00000000..01e901f9 --- /dev/null +++ b/src/claude_session.rs @@ -0,0 +1,163 @@ +//! Controlled Claude launch with a session-owned presence lease. +//! +//! Claude can close its stdio MCP child after startup. That child cannot prove that the interactive +//! provider still lives. This wrapper launches the provider and refreshes presence while that exact +//! child remains alive. It uses the provider's existing terminal process group. + +use std::os::unix::process::CommandExt as _; +use std::path::Path; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread; +use std::time::{Duration, Instant}; + +use anyhow::{Context as _, Result}; + +use crate::{message, status}; + +const PROVIDER_POLL: Duration = Duration::from_millis(250); +const STOP_GRACE: Duration = Duration::from_secs(5); + +static STOP: AtomicBool = AtomicBool::new(false); + +extern "C" fn on_stop_signal(_signal: libc::c_int) { + STOP.store(true, Ordering::SeqCst); +} + +extern "C" fn on_interrupt_signal(_signal: libc::c_int) {} + +fn install_signal_handler() { + STOP.store(false, Ordering::SeqCst); + let handler = on_stop_signal as extern "C" fn(libc::c_int) as libc::sighandler_t; + let interrupt = on_interrupt_signal as extern "C" fn(libc::c_int) as libc::sighandler_t; + unsafe { + libc::signal(libc::SIGTERM, handler); + // The terminal also sends SIGINT to this wrapper. Keep the wrapper alive while Claude + // handles that interactive interrupt itself. + libc::signal(libc::SIGINT, interrupt); + } +} + +/// Run one interactive Claude provider and maintain its presence until it exits. +pub fn run( + catalog_root: &Path, + identity: String, + runtime_id: String, + claude_argv: Vec, +) -> Result<()> { + let agent_dir = + message::resolve_agent_dir(catalog_root, &identity, &crate::run::detect_host())? + .with_context(|| format!("Claude driver agent '{identity}' is not declared"))?; + anyhow::ensure!( + !claude_argv.is_empty(), + "Claude driver '{runtime_id}' has no provider argv" + ); + install_signal_handler(); + run_provider( + &status::status_path(&agent_dir), + &claude_argv, + status::STATUS_REFRESH, + PROVIDER_POLL, + &STOP, + ) + .with_context(|| format!("running Claude driver '{runtime_id}'")) +} + +fn run_provider( + status_path: &Path, + argv: &[String], + refresh_interval: Duration, + poll: Duration, + stop: &AtomicBool, +) -> Result<()> { + let (program, args) = argv + .split_first() + .context("Claude provider argv is empty")?; + let mut command = Command::new(program); + command + .args(args) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + unsafe { + command.pre_exec(|| { + libc::signal(libc::SIGINT, libc::SIG_DFL); + libc::signal(libc::SIGTERM, libc::SIG_DFL); + Ok(()) + }); + } + let mut child = command + .spawn() + .with_context(|| format!("starting Claude provider {program}"))?; + let mut next_refresh = Instant::now(); + loop { + if stop.load(Ordering::SeqCst) { + return stop_provider_group(&mut child); + } + if let Some(exit) = child.try_wait().context("checking Claude provider")? { + return completed_provider(exit); + } + let now = Instant::now(); + if now >= next_refresh { + let _ = status::refresh(status_path); + next_refresh = now + refresh_interval; + } + thread::sleep(poll.min(next_refresh.saturating_duration_since(Instant::now()))); + } +} + +fn completed_provider(exit: ExitStatus) -> Result<()> { + anyhow::ensure!(exit.success(), "Claude provider exited with {exit}"); + Ok(()) +} + +fn stop_provider_group(child: &mut Child) -> Result<()> { + let process_group = unsafe { libc::getpgrp() }; + anyhow::ensure!( + process_group > 1, + "refusing to signal process group {process_group}" + ); + unsafe { + libc::kill(-process_group, libc::SIGTERM); + } + let deadline = Instant::now() + STOP_GRACE; + while Instant::now() < deadline { + if child.try_wait()?.is_some() { + return Ok(()); + } + thread::sleep(Duration::from_millis(25)); + } + unsafe { + libc::kill(-process_group, libc::SIGKILL); + } + let _ = child.wait(); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn idle_provider_refreshes_presence_without_mcp_input() { + let tmp = tempfile::tempdir().unwrap(); + let presence = status::status_path(tmp.path()); + status::set_state(&presence, status::State::Available).unwrap(); + let before = fs::read_to_string(&presence).unwrap(); + let stop = AtomicBool::new(false); + + run_provider( + &presence, + &["sh".into(), "-c".into(), "sleep 0.12".into()], + Duration::from_millis(25), + Duration::from_millis(5), + &stop, + ) + .unwrap(); + + let after = fs::read_to_string(&presence).unwrap(); + assert_ne!(after, before); + assert_eq!(status::read_state(&presence), status::State::Available); + } +} diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index 3f306bb0..f7901628 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -300,7 +300,8 @@ struct CodexInboxDelivery { runtime: CodexRuntime, wake: Receiver<()>, _watcher: Option, - next_refresh: Instant, + next_inbox_refresh: Instant, + next_presence_refresh: Instant, head: Option, suppressed: bool, state: Option, @@ -330,7 +331,8 @@ impl CodexInboxDelivery { runtime, wake, _watcher: watcher, - next_refresh: Instant::now(), + next_inbox_refresh: Instant::now(), + next_presence_refresh: Instant::now(), head: None, suppressed: false, state, @@ -353,16 +355,20 @@ impl CodexInboxDelivery { } fn refresh_if_due(&mut self) -> Result<()> { - let mut due = Instant::now() >= self.next_refresh; + let now = Instant::now(); + if now >= self.next_presence_refresh { + // This wrapper owns the live provider session. It therefore owns the presence lease. + // Preserve busy or available, and let dnd age out. + let _ = status::refresh(&status::status_path(&self.config.agent_dir)); + self.next_presence_refresh = now + status::STATUS_REFRESH; + } + let mut due = now >= self.next_inbox_refresh; while self.wake.try_recv().is_ok() { due = true; } if !due { return Ok(()); } - // Native delivery owns the live provider session, so it also owns the - // presence lease. Preserve busy/available and let dnd age out. - let _ = status::refresh(&status::status_path(&self.config.agent_dir)); let unread = message::list_inbox(&self.config.inbox)?; if self.state.as_ref().is_some_and(|state| { unread @@ -381,7 +387,7 @@ impl CodexInboxDelivery { self.head = unread.into_iter().next(); self.suppressed = status::read_state(&status::status_path(&self.config.agent_dir)) == status::State::Dnd; - self.next_refresh = Instant::now() + INBOX_REFRESH_FALLBACK; + self.next_inbox_refresh = Instant::now() + INBOX_REFRESH_FALLBACK; Ok(()) } @@ -2556,7 +2562,7 @@ mod tests { } status::set_state(&status::status_path(&config.agent_dir), status::State::Dnd).unwrap(); - delivery.next_refresh = Instant::now(); + delivery.next_inbox_refresh = Instant::now(); assert_eq!( delivery .maybe_request(&subscribed_state(CodexObservedState::Idle)) @@ -2571,7 +2577,8 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let config = delivery_config(tmp.path()); let presence = status::status_path(&config.agent_dir); - status::set_state(&presence, status::State::Available).unwrap(); + std::fs::create_dir_all(&config.agent_dir).unwrap(); + std::fs::write(&presence, "available\n").unwrap(); std::fs::File::open(&presence) .unwrap() .set_modified(SystemTime::now() - status::STATUS_STALE - Duration::from_secs(1)) @@ -2582,9 +2589,30 @@ mod tests { delivery.refresh_if_due().unwrap(); assert_eq!(status::read_state(&presence), status::State::Available); + assert!( + std::fs::read_to_string(&presence) + .unwrap() + .contains("updated-at-unix-ms ") + ); assert!(delivery.head.is_none()); } + #[test] + fn inbox_fallback_does_not_write_a_fifteen_second_presence_heartbeat() { + let tmp = tempfile::tempdir().unwrap(); + let config = delivery_config(tmp.path()); + let presence = status::status_path(&config.agent_dir); + status::set_state(&presence, status::State::Available).unwrap(); + let before = std::fs::read_to_string(&presence).unwrap(); + let mut delivery = inbox_delivery(tmp.path(), config); + delivery.next_inbox_refresh = Instant::now(); + delivery.next_presence_refresh = Instant::now() + status::STATUS_REFRESH; + + delivery.refresh_if_due().unwrap(); + + assert_eq!(std::fs::read_to_string(&presence).unwrap(), before); + } + #[test] fn a_rejected_exact_steer_has_no_fallback_and_remains_retryable_after_state_changes() { let tmp = tempfile::tempdir().unwrap(); @@ -2764,7 +2792,7 @@ mod tests { &filename, ) .unwrap(); - replacement.next_refresh = Instant::now(); + replacement.next_inbox_refresh = Instant::now(); assert_eq!(replacement.maybe_request(&idle).unwrap(), None); assert!( !state_path.exists(), diff --git a/src/ding/mod.rs b/src/ding/mod.rs index d578e111..68b96e8e 100644 --- a/src/ding/mod.rs +++ b/src/ding/mod.rs @@ -868,7 +868,7 @@ impl SessionWatch { pub struct DingConfig { /// Fallback poll cadence and liveness-check cadence. pub poll: Duration, - /// Presence mtime refresh cadence while the target session is alive. + /// Presence refresh cadence while the target session is alive. pub status_refresh: Duration, } @@ -3207,10 +3207,15 @@ Enter to select · ↑/↓ to navigate · Esc to cancel"; assert_eq!(pending.len(), 1, "fresh dnd suppresses delivery"); let stale = std::time::SystemTime::now() - status::STATUS_STALE - Duration::from_secs(1); - std::fs::File::open(&status_path) + let stale_ms = stale + .duration_since(std::time::UNIX_EPOCH) .unwrap() - .set_modified(stale) - .unwrap(); + .as_millis(); + std::fs::write( + &status_path, + format!("dnd\nupdated-at-unix-ms {stale_ms}\n"), + ) + .unwrap(); flush_without_catalog(Some(&status_path), &mut pending, &poker); assert!( pending.is_empty(), diff --git a/src/driver.rs b/src/driver.rs index 63a5cc58..b180da46 100644 --- a/src/driver.rs +++ b/src/driver.rs @@ -96,18 +96,32 @@ fn expand_claude(driver: &ClaudeDriver, bus_id: &str) -> Result { vec![".mcp.json".to_string(), mcp], )])); - let mut argv = vec!["claude".to_string()]; + let mut provider = vec!["claude".to_string()]; if let Some(model) = &driver.model { - argv.extend(["--model".to_string(), model.clone()]); + provider.extend(["--model".to_string(), model.clone()]); } if let Some(effort) = &driver.effort { - argv.extend(["--effort".to_string(), effort.clone()]); + provider.extend(["--effort".to_string(), effort.clone()]); } if driver.dev_channels { - argv.push("--dangerously-load-development-channels=server:st2".to_string()); + provider.push("--dangerously-load-development-channels=server:st2".to_string()); } - argv.extend(driver.args.iter().cloned()); - argv.push(driver.prompt.clone()); + provider.extend(driver.args.iter().cloned()); + provider.push(driver.prompt.clone()); + + let mut argv = vec![ + ST2.to_string(), + "--catalog".to_string(), + CATALOG.to_string(), + "driver".to_string(), + "claude-session".to_string(), + "--identity".to_string(), + bus_id.to_string(), + "--runtime-id".to_string(), + bus_id.to_string(), + "--".to_string(), + ]; + argv.extend(provider); Ok(document([render, node("argv", argv)])) } @@ -204,7 +218,7 @@ mod tests { } #[test] - fn claude_expands_to_plain_render_and_argv_primitives() { + fn claude_expands_to_a_channel_render_and_session_owned_launch() { let output = expand_driver( &spec(Driver::Claude(ClaudeDriver { model: Some("opus".into()), @@ -239,6 +253,16 @@ mod tests { assert_eq!( strings(output.get("argv").unwrap()), [ + "st2", + "--catalog", + "$CATALOG", + "driver", + "claude-session", + "--identity", + "host.worker", + "--runtime-id", + "host.worker", + "--", "claude", "--model", "opus", diff --git a/src/lib.rs b/src/lib.rs index fede47e9..dc13a6a5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ pub mod catalog; pub mod catalog_lock; pub mod catalog_transaction; pub mod claude_mcp; +pub mod claude_session; pub mod codex_app_server; pub mod context; pub mod ding; diff --git a/src/main.rs b/src/main.rs index e1c1a085..68b7bf9e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -329,6 +329,15 @@ enum DriverCmd { #[arg(long)] identity: String, }, + /// Run Claude under the session-owned presence wrapper. + ClaudeSession { + #[arg(long)] + identity: String, + #[arg(long)] + runtime_id: String, + #[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)] + argv: Vec, + }, } #[derive(Subcommand)] @@ -898,6 +907,15 @@ fn main() -> Result<()> { let catalog = catalog.canonicalize().unwrap_or(catalog); st2::claude_mcp::run(&catalog, &identity) } + Command::Driver(DriverCmd::ClaudeSession { + identity, + runtime_id, + argv, + }) => { + let catalog = catalog_arg(None)?; + let catalog = catalog.canonicalize().unwrap_or(catalog); + st2::claude_session::run(&catalog, identity, runtime_id, argv) + } Command::Driver(DriverCmd::Expand { spec, agent, host }) => { let catalog = catalog_arg(None)?; driver_expand_cmd(&catalog, &spec, agent.as_deref(), host.as_deref()) @@ -1622,7 +1640,7 @@ fn doctor_cmd(root: &Path, host: Option, require_supervisor: bool) -> Re &mut problems, false, &format!("{bus_id} presence missing"), - "no status file — is its ding refreshing?", + "no status file — is its session owner refreshing presence?", ); } else { let state = st2::status::read_state(&path); @@ -1630,7 +1648,7 @@ fn doctor_cmd(root: &Path, host: Option, require_supervisor: bool) -> Re &mut problems, state != st2::status::State::Unknown, &format!("{bus_id} presence fresh (is `{}`)", state.as_str()), - "rotted to `unknown` — is its ding refreshing?", + "rotted to `unknown` — is its session owner refreshing presence?", ); } } diff --git a/src/reconcile.rs b/src/reconcile.rs index 23b86123..5c95f125 100644 --- a/src/reconcile.rs +++ b/src/reconcile.rs @@ -90,11 +90,11 @@ pub fn compile_generated_tasks( crate::driver::ensure_single_source(spec)?; } compile_driver_agent_tasks(specs, this_host, context)?; + compile_claude_session_agent_tasks(specs, this_host, context)?; compile_generated_ding_tasks(specs, this_host, context)?; compile_app_server_agent_tasks(specs, this_host, context)?; - // Claude's MCP server is declared to Claude itself. It must not be lowered - // to an st2-owned companion task: that would give the supervisor a second - // lifetime to manage and break session ownership across restart. + // Claude's MCP server remains declared to Claude itself. The canonical task wrapper owns only + // the provider lifetime and its presence lease. It does not add a supervisor-owned companion. Ok(()) } @@ -152,18 +152,20 @@ pub fn compile_driver_agent_tasks( "agent '{bus_id}' driver expansion produced an empty argv" ); - if matches!(driver, Driver::Codex(_)) { - anyhow::ensure!( - argv.first().map(String::as_str) == Some("st2") - && argv.get(1).map(String::as_str) == Some("--catalog") - && argv.get(2).map(String::as_str) == Some("$CATALOG") - && argv.get(3).map(String::as_str) == Some("driver") - && argv.get(4).map(String::as_str) == Some("codex"), - "agent '{bus_id}' Codex driver expansion has an unexpected wrapper prefix" - ); - argv[0] = st2_executable.clone(); - argv[2] = catalog_root.clone(); - } + let wrapper = match driver { + Driver::Codex(_) => "codex", + Driver::Claude(_) => "claude-session", + }; + anyhow::ensure!( + argv.first().map(String::as_str) == Some("st2") + && argv.get(1).map(String::as_str) == Some("--catalog") + && argv.get(2).map(String::as_str) == Some("$CATALOG") + && argv.get(3).map(String::as_str) == Some("driver") + && argv.get(4).map(String::as_str) == Some(wrapper), + "agent '{bus_id}' driver expansion has an unexpected {wrapper} wrapper prefix" + ); + argv[0] = st2_executable.clone(); + argv[2] = catalog_root.clone(); let mut candidates = spec .tasks @@ -186,6 +188,82 @@ pub fn compile_driver_agent_tasks( Ok(()) } +/// Route legacy MCP delivery through the same Claude session wrapper as a typed driver. +pub fn compile_claude_session_agent_tasks( + specs: &mut [AgentSpec], + this_host: &str, + context: &TaskCompileContext, +) -> Result<()> { + let st2_executable = context + .st2_executable + .to_str() + .context("running st2 executable path is not UTF-8")? + .to_owned(); + let catalog_root = context + .catalog_root + .to_str() + .context("catalog root is not UTF-8")? + .to_owned(); + + for spec in specs { + if spec.driver.is_some() || spec.delivery != Some(DeliveryTransport::Mcp) { + continue; + } + let bus_id = spec.bus_id(this_host); + let mut candidates = spec + .tasks + .iter_mut() + .filter(|task| !task.derived && task.name == "agent"); + let task = candidates.next().with_context(|| { + format!( + "agent '{bus_id}' selects `deliver \"mcp\"` but has no canonical `agent` task" + ) + })?; + anyhow::ensure!( + candidates.next().is_none(), + "agent '{bus_id}' selects `deliver \"mcp\"` with more than one canonical `agent` task" + ); + anyhow::ensure!( + task.kind == TaskKind::Pty, + "agent '{bus_id}' selects `deliver \"mcp\"` for a non-PTY canonical task" + ); + let provider = match (&task.command, &task.argv) { + (None, Some(argv)) => argv.clone(), + (Some(command), None) => { + vec!["sh".to_string(), "-c".to_string(), command.clone()] + } + (None, None) => Vec::new(), + (Some(_), Some(_)) => { + unreachable!("discovery rejects tasks carrying both command and argv") + } + }; + anyhow::ensure!( + !provider.is_empty(), + "agent '{bus_id}' selects `deliver \"mcp\"` with an empty canonical argv" + ); + let runtime_id = task + .id + .clone() + .unwrap_or_else(|| format!("{bus_id}.{}", task.name)); + let mut argv = vec![ + st2_executable.clone(), + "--catalog".to_string(), + catalog_root.clone(), + "driver".to_string(), + "claude-session".to_string(), + "--identity".to_string(), + bus_id, + "--runtime-id".to_string(), + runtime_id, + "--".to_string(), + ]; + argv.extend(provider); + task.command = None; + task.argv = Some(argv); + } + Ok(()) +} + /// Replace only runner-generated DING markers with exact direct argv. Authored tasks never carry /// `derived=true`, so source that happens to invoke `st2 ding` remains byte-for-byte unchanged. pub fn compile_generated_ding_tasks( diff --git a/src/status.rs b/src/status.rs index e0650359..f09ec4b2 100644 --- a/src/status.rs +++ b/src/status.rs @@ -1,26 +1,26 @@ //! Native per-agent presence status. //! -//! A `status` file (sibling of `agent.kdl` in the agent's dir) holds exactly one word: one of the -//! settable states. `unknown` is DERIVED, never written — a status whose mtime is older than -//! [`STATUS_STALE`] reads as `unknown` regardless of its contents, so a crashed/gone agent stops -//! reading as its last set value. Reads are permissive (missing → `offline`, corrupt → `offline`). -//! Writes are atomic (tmp + rename) so a concurrent reader never sees a partial file. The ding -//! periodically re-writes an agent's own non-DND status to bump the mtime *preserving the value* so -//! a healthy-but-idle agent never rots to `unknown` (the failure that read the whole fleet `unknown` -//! for 45 min). `dnd` is intentionally not refreshed: an abandoned hold ages to `unknown` after the -//! same stale window. The vocabulary, stale window, and file semantics are stable. +//! A `status` file is a sibling of `agent.kdl` in the agent directory. The first line holds one +//! settable state. The optional second line holds `updated-at-unix-ms `. Old readers use +//! the first line and file mtime. New readers use the content timestamp when present and accept the +//! old bare state during a mixed-fleet upgrade. Thus, each heartbeat changes the replicated bytes. +//! `unknown` is derived and is never written. Reads are permissive: missing and corrupt files become +//! `offline`. Writes are atomic, so a reader never sees a partial file. A live session refreshes its +//! non-DND status. `dnd` is not refreshed, so an abandoned hold ages to `unknown`. use std::fs; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::{Duration, SystemTime}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; -/// A status file older than this reads as `unknown` no matter its contents. +/// A status record older than this reads as `unknown` no matter which state it contains. pub const STATUS_STALE: Duration = Duration::from_secs(15 * 60); -/// How often a live agent's status should be refreshed to stay inside the stale window — 5 min gives -/// a 3× safety margin (two missed refreshes before `unknown`). +/// A live session refreshes presence every five minutes. This permits two missed writes before the +/// 15-minute stale limit and produces 288 replicated writes each day. pub const STATUS_REFRESH: Duration = Duration::from_secs(5 * 60); +const UPDATED_AT_PREFIX: &str = "updated-at-unix-ms "; + /// Presence state. `Unknown` is derived from staleness and is never written to disk. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum State { @@ -72,38 +72,92 @@ pub fn status_path(agent_dir: &Path) -> PathBuf { agent_dir.join("status") } -/// Read an agent's effective presence. Order matters: missing file → `offline`; -/// mtime older than [`STATUS_STALE`] → `unknown` (regardless of contents); unreadable → `offline`; -/// else the first line's word if valid, else `offline` (never trust a corrupt file). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct StatusRecord { + state: State, + updated_at_unix_ms: Option, +} + +/// Read an agent's effective presence. A timestamped record uses its content timestamp. A legacy +/// bare state uses mtime. Missing, unreadable, and corrupt files read as `offline`. pub fn read_state(status_path: &Path) -> State { + read_state_at(status_path, SystemTime::now()) +} + +fn read_state_at(status_path: &Path, now: SystemTime) -> State { let meta = match fs::metadata(status_path) { Ok(m) => m, Err(_) => return State::Offline, // missing }; - if let Ok(mtime) = meta.modified() - && let Ok(age) = SystemTime::now().duration_since(mtime) - && age >= STATUS_STALE - { - return State::Unknown; // stale → not trustworthy - } let raw = match fs::read_to_string(status_path) { Ok(r) => r, Err(_) => return State::Offline, }; - let first = raw.lines().next().unwrap_or("").trim(); - State::parse_any(first).unwrap_or(State::Offline) + let Some(record) = parse_record(&raw) else { + return State::Offline; + }; + let stale = match record.updated_at_unix_ms { + Some(updated_at) => content_timestamp_is_stale(now, updated_at), + None => meta + .modified() + .ok() + .and_then(|mtime| now.duration_since(mtime).ok()) + .is_some_and(|age| age >= STATUS_STALE), + }; + if stale { State::Unknown } else { record.state } +} + +fn parse_record(raw: &str) -> Option { + let mut lines = raw.lines(); + let state = State::parse_any(lines.next().unwrap_or("").trim())?; + let updated_at_unix_ms = match lines.next() { + None => None, + Some(line) => { + let value = line.trim().strip_prefix(UPDATED_AT_PREFIX)?; + Some(value.parse().ok()?) + } + }; + if lines.next().is_some() { + return None; + } + Some(StatusRecord { + state, + updated_at_unix_ms, + }) +} + +fn content_timestamp_is_stale(now: SystemTime, updated_at_unix_ms: u128) -> bool { + now.duration_since(UNIX_EPOCH) + .ok() + .and_then(|duration| duration.as_millis().checked_sub(updated_at_unix_ms)) + .is_some_and(|age_ms| age_ms >= STATUS_STALE.as_millis()) } -/// Set an agent's presence to a settable `state`, atomically (tmp sibling + rename), writing -/// `\n`. Creates the agent dir if missing. +/// Set an agent's presence atomically. The first line remains compatible with old readers. The +/// second line gives content-based synchronizers a changed value and gives new readers freshness. pub fn set_state(status_path: &Path, state: State) -> anyhow::Result<()> { - write_atomic(status_path, state.as_str()) + set_state_at(status_path, state, SystemTime::now()) +} + +fn set_state_at(status_path: &Path, state: State, now: SystemTime) -> anyhow::Result<()> { + write_atomic(status_path, &encode_record(state, now)?) +} + +fn encode_record(state: State, now: SystemTime) -> anyhow::Result { + let updated_at_unix_ms = now + .duration_since(UNIX_EPOCH) + .map_err(|_| anyhow::anyhow!("presence timestamp is before the Unix epoch"))? + .as_millis(); + Ok(format!( + "{}\n{UPDATED_AT_PREFIX}{updated_at_unix_ms}\n", + state.as_str() + )) } /// Outcome of a [`refresh`] call. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RefreshOutcome { - /// File present + a valid settable state → re-wrote the same value, mtime bumped. + /// A valid settable state received a new content timestamp. Refreshed, /// File recorded `dnd` → left untouched so an abandoned hold ages out. LeftDnd, @@ -117,12 +171,15 @@ pub enum RefreshOutcome { Error, } -/// Bump an agent's status mtime so a live-but-idle agent never rots to `unknown`, preserving the -/// recorded value. Missing → write `available`. A valid non-DND value → re-write the same value. -/// `dnd`, `unknown`, or corrupt → leave untouched (never renew a hold or invent a value). Atomic. +/// Write a new content timestamp while preserving the state. Missing becomes `available`. `dnd`, +/// `unknown`, and corrupt records stay unchanged. The write is atomic. pub fn refresh(status_path: &Path) -> RefreshOutcome { + refresh_at(status_path, SystemTime::now()) +} + +fn refresh_at(status_path: &Path, now: SystemTime) -> RefreshOutcome { if !status_path.exists() { - return match write_atomic(status_path, State::Available.as_str()) { + return match set_state_at(status_path, State::Available, now) { Ok(()) => RefreshOutcome::WroteDefault, Err(_) => RefreshOutcome::Error, }; @@ -131,19 +188,18 @@ pub fn refresh(status_path: &Path) -> RefreshOutcome { Ok(r) => r, Err(_) => return RefreshOutcome::Error, }; - let first = raw.lines().next().unwrap_or("").trim(); - if first == "unknown" { + let Some(record) = parse_record(&raw) else { + return RefreshOutcome::LeftCorrupt; + }; + if record.state == State::Unknown { return RefreshOutcome::LeftUnknown; } - if first == "dnd" { + if record.state == State::Dnd { return RefreshOutcome::LeftDnd; } - match State::parse_settable(first) { - Some(s) => match write_atomic(status_path, s.as_str()) { - Ok(()) => RefreshOutcome::Refreshed, - Err(_) => RefreshOutcome::Error, - }, - None => RefreshOutcome::LeftCorrupt, + match set_state_at(status_path, record.state, now) { + Ok(()) => RefreshOutcome::Refreshed, + Err(_) => RefreshOutcome::Error, } } @@ -153,7 +209,7 @@ fn write_atomic(path: &Path, value: &str) -> anyhow::Result<()> { let dir = path.parent().unwrap_or(Path::new(".")); fs::create_dir_all(dir)?; let tmp = dir.join(tmp_name()); - fs::write(&tmp, format!("{value}\n"))?; + fs::write(&tmp, value)?; // rename over the target — atomic on the same filesystem. if let Err(e) = fs::rename(&tmp, path) { let _ = fs::remove_file(&tmp); // best-effort cleanup @@ -179,6 +235,10 @@ mod tests { use super::*; use std::time::Duration as Dur; + fn at(unix_ms: u64) -> SystemTime { + UNIX_EPOCH + Dur::from_millis(unix_ms) + } + #[test] fn missing_is_offline() { let tmp = tempfile::tempdir().unwrap(); @@ -189,6 +249,7 @@ mod tests { fn set_then_read_roundtrips_each_settable_state() { let tmp = tempfile::tempdir().unwrap(); let sp = status_path(tmp.path()); + let written_at = 1_786_741_730_761; for st in [ State::Offline, State::Available, @@ -196,16 +257,29 @@ mod tests { State::Away, State::Dnd, ] { - set_state(&sp, st).unwrap(); - assert_eq!(read_state(&sp), st); - // file content is exactly `\n` (wire format). + set_state_at(&sp, st, at(written_at)).unwrap(); + assert_eq!(read_state_at(&sp, at(written_at + 1)), st); assert_eq!( fs::read_to_string(&sp).unwrap(), - format!("{}\n", st.as_str()) + format!("{}\nupdated-at-unix-ms {written_at}\n", st.as_str()) ); } } + #[test] + fn timestamped_status_keeps_the_old_reader_first_line() { + let tmp = tempfile::tempdir().unwrap(); + let sp = status_path(tmp.path()); + set_state_at(&sp, State::Available, at(1_786_741_730_761)).unwrap(); + + let raw = fs::read_to_string(&sp).unwrap(); + assert_eq!(raw.lines().next(), Some("available")); + assert_eq!( + State::parse_any(raw.lines().next().unwrap()), + Some(State::Available) + ); + } + #[test] fn unknown_is_not_settable() { assert!(State::parse_settable("unknown").is_none()); @@ -219,14 +293,16 @@ mod tests { let sp = status_path(tmp.path()); fs::write(&sp, "garbage\n").unwrap(); assert_eq!(read_state(&sp), State::Offline); + + fs::write(&sp, "available\nupdated-at-unix-ms nope\n").unwrap(); + assert_eq!(read_state(&sp), State::Offline); } #[test] - fn stale_mtime_reads_as_unknown_regardless_of_contents() { + fn legacy_bare_word_uses_mtime_during_a_mixed_fleet_upgrade() { let tmp = tempfile::tempdir().unwrap(); let sp = status_path(tmp.path()); - set_state(&sp, State::Busy).unwrap(); - // Backdate the mtime past the stale window. + fs::write(&sp, "busy\n").unwrap(); let old = SystemTime::now() - STATUS_STALE - Dur::from_secs(60); let f = fs::File::open(&sp).unwrap(); f.set_modified(old).unwrap(); @@ -234,38 +310,100 @@ mod tests { } #[test] - fn refresh_preserves_value_and_bumps_mtime() { + fn timestamped_content_controls_freshness_after_replication() { let tmp = tempfile::tempdir().unwrap(); let sp = status_path(tmp.path()); - set_state(&sp, State::Busy).unwrap(); - // Backdate → would read unknown… + let now = SystemTime::now(); let old = SystemTime::now() - STATUS_STALE - Dur::from_secs(60); + + set_state_at(&sp, State::Available, now).unwrap(); fs::File::open(&sp).unwrap().set_modified(old).unwrap(); + assert_eq!(read_state(&sp), State::Available); + + set_state_at(&sp, State::Busy, old).unwrap(); + fs::File::open(&sp) + .unwrap() + .set_modified(SystemTime::now()) + .unwrap(); assert_eq!(read_state(&sp), State::Unknown); - // …refresh keeps `busy` but bumps mtime, so it reads busy again. - assert_eq!(refresh(&sp), RefreshOutcome::Refreshed); - assert_eq!(read_state(&sp), State::Busy); + } + + #[test] + fn refresh_preserves_value_and_changes_the_replicated_bytes() { + let tmp = tempfile::tempdir().unwrap(); + let sp = status_path(tmp.path()); + let first = at(1_786_741_000_000); + let second = first + STATUS_REFRESH; + set_state_at(&sp, State::Busy, first).unwrap(); + let before = fs::read_to_string(&sp).unwrap(); + + assert_eq!(refresh_at(&sp, second), RefreshOutcome::Refreshed); + + let after = fs::read_to_string(&sp).unwrap(); + assert_ne!(after, before); + assert_eq!( + parse_record(&after).unwrap().updated_at_unix_ms, + Some(1_786_741_300_000) + ); + assert_eq!(read_state_at(&sp, second), State::Busy); + } + + #[test] + fn writer_and_replica_carry_the_same_fresh_timestamp_while_idle() { + let tmp = tempfile::tempdir().unwrap(); + let writer = tmp.path().join("writer-status"); + let replica = tmp.path().join("replica-status"); + let first = at(1_786_741_000_000); + let second = first + STATUS_REFRESH; + set_state_at(&writer, State::Available, first).unwrap(); + fs::write(&replica, fs::read(&writer).unwrap()).unwrap(); + + assert_eq!(refresh_at(&writer, second), RefreshOutcome::Refreshed); + fs::write(&replica, fs::read(&writer).unwrap()).unwrap(); + + let writer_raw = fs::read_to_string(&writer).unwrap(); + let replica_raw = fs::read_to_string(&replica).unwrap(); + assert_eq!(replica_raw, writer_raw); + assert_eq!( + parse_record(&writer_raw).unwrap().updated_at_unix_ms, + Some(1_786_741_300_000) + ); + let observed = second + STATUS_REFRESH - Dur::from_millis(1); + assert_eq!(read_state_at(&writer, observed), State::Available); + assert_eq!(read_state_at(&replica, observed), State::Available); } #[test] fn refresh_missing_writes_available_default() { let tmp = tempfile::tempdir().unwrap(); let sp = status_path(tmp.path()); - assert_eq!(refresh(&sp), RefreshOutcome::WroteDefault); - assert_eq!(read_state(&sp), State::Available); + let now = at(1_786_741_730_761); + assert_eq!(refresh_at(&sp, now), RefreshOutcome::WroteDefault); + assert_eq!(read_state_at(&sp, now), State::Available); + assert_eq!( + fs::read_to_string(&sp).unwrap(), + "available\nupdated-at-unix-ms 1786741730761\n" + ); } #[test] fn refresh_leaves_dnd_to_age_out() { let tmp = tempfile::tempdir().unwrap(); let sp = status_path(tmp.path()); - set_state(&sp, State::Dnd).unwrap(); - let before = fs::metadata(&sp).unwrap().modified().unwrap(); - std::thread::sleep(Dur::from_millis(5)); + let written_at = at(1_786_741_000_000); + set_state_at(&sp, State::Dnd, written_at).unwrap(); + let before = fs::read_to_string(&sp).unwrap(); - assert_eq!(refresh(&sp), RefreshOutcome::LeftDnd); - assert_eq!(fs::metadata(&sp).unwrap().modified().unwrap(), before); - assert_eq!(read_state(&sp), State::Dnd); + assert_eq!( + refresh_at(&sp, written_at + STATUS_REFRESH), + RefreshOutcome::LeftDnd + ); + assert_eq!(fs::read_to_string(&sp).unwrap(), before); + assert_eq!(read_state_at(&sp, written_at), State::Dnd); + assert_eq!( + read_state_at(&sp, written_at + STATUS_STALE), + State::Unknown + ); } #[test] diff --git a/tests/catalog_apply.rs b/tests/catalog_apply.rs index c63254fe..1cc2e187 100644 --- a/tests/catalog_apply.rs +++ b/tests/catalog_apply.rs @@ -2064,6 +2064,7 @@ fn marker_time_state_routes_existing_orphans_but_never_flat_falls_back_for_new_a let temp = tempfile::tempdir().unwrap(); let catalog = temp.path().join("catalog"); write_agent(&catalog, "old", false); + write_agent(&catalog, "sender", false); let old = agent_dir(&catalog, "old"); fs::create_dir_all(old.join("resources/inbox")).unwrap(); write_agent(&catalog, "mix.sup", false); @@ -2191,7 +2192,9 @@ fn marker_time_state_routes_existing_orphans_but_never_flat_falls_back_for_new_a .next() .is_some() ); - assert_eq!(fs::read_to_string(old.join("status")).unwrap(), "busy\n"); + let presence = fs::read_to_string(old.join("status")).unwrap(); + assert!(presence.starts_with("busy\nupdated-at-unix-ms ")); + assert_eq!(presence.lines().count(), 2); let phantom = send(&catalog, "host.new", "too early"); assert!(!phantom.status.success()); @@ -2208,6 +2211,7 @@ fn marker_time_state_routes_existing_orphans_but_never_flat_falls_back_for_new_a assert!(agent_dir(&catalog, "new").join("agent.kdl").is_file()); let dotted = temp.path().join("dotted-catalog"); + write_agent(&dotted, "sender", false); for path in [ "agents/a/b.c/resources/inbox", "agents/a.b/c/resources/inbox", @@ -2221,6 +2225,7 @@ fn marker_time_state_routes_existing_orphans_but_never_flat_falls_back_for_new_a "agents/a/b.c/agent.kdl", "agents/a.b/c/agent.kdl", "agents/a.b/only/agent.kdl", + "agents/host/sender/agent.kdl", ], ); let ambiguous_qualified = send(&dotted, "a.b.c", "ambiguous qualified"); @@ -2246,6 +2251,7 @@ fn state_remains_addressable_after_its_spec_is_deleted_mid_apply() { let temp = tempfile::tempdir().unwrap(); let catalog = temp.path().join("catalog"); write_agent(&catalog, "old", false); + write_agent(&catalog, "sender", false); let old = agent_dir(&catalog, "old"); fs::create_dir_all(old.join("resources/inbox")).unwrap(); let prepared = temp.path().join("prepared"); @@ -2314,6 +2320,7 @@ fn marker_time_message_write_remains_bound_to_its_retained_agent_capability() { let temp = tempfile::tempdir().unwrap(); let catalog = temp.path().join("catalog"); write_agent(&catalog, "old", false); + write_agent(&catalog, "sender", false); fs::create_dir_all(agent_dir(&catalog, "old").join("resources/inbox")).unwrap(); let prepared = temp.path().join("prepared"); let before = snapshot(&catalog, &prepared); @@ -2452,10 +2459,9 @@ fn marker_time_status_write_remains_bound_to_its_retained_agent_capability() { "{}", String::from_utf8_lossy(&state.stderr) ); - assert_eq!( - fs::read_to_string(retained_host.join("old/status")).unwrap(), - "busy\n" - ); + let presence = fs::read_to_string(retained_host.join("old/status")).unwrap(); + assert!(presence.starts_with("busy\nupdated-at-unix-ms ")); + assert_eq!(presence.lines().count(), 2); assert!(!outside.join("old/status").exists()); fs::remove_file(catalog.join("agents/host")).unwrap(); fs::rename(&retained_host, catalog.join("agents/host")).unwrap(); diff --git a/tests/codex_app_server.rs b/tests/codex_app_server.rs index 38274a36..a4757802 100644 --- a/tests/codex_app_server.rs +++ b/tests/codex_app_server.rs @@ -164,14 +164,35 @@ fn app_server_selector_rejects_shell_and_pre_remote_launches_without_mutating_th } #[test] -fn mcp_selector_does_not_rewrite_the_authored_launch() { +fn mcp_selector_wraps_the_authored_claude_launch_with_session_ownership() { let tmp = tempfile::tempdir().unwrap(); write( &tmp.path().join("agents/h/worker/agent.kdl"), r#"agent "worker" { host "h"; deliver "mcp"; argv "claude" "boot" }"#, ); let mut found = st2::discover(tmp.path()); - let before = found.specs.clone(); compile_generated_tasks(&mut found.specs, "h", &context(tmp.path())).unwrap(); - assert_eq!(found.specs, before); + + let task = &found.specs[0].tasks[0]; + assert_eq!(task.command, None); + assert_eq!( + task.argv.as_deref(), + Some( + [ + tmp.path().join("bin/st2").display().to_string(), + "--catalog".into(), + tmp.path().display().to_string(), + "driver".into(), + "claude-session".into(), + "--identity".into(), + "h.worker".into(), + "--runtime-id".into(), + "h.worker".into(), + "--".into(), + "claude".into(), + "boot".into(), + ] + .as_slice() + ) + ); } diff --git a/tests/driver_expansion.rs b/tests/driver_expansion.rs index 3a812936..ae9d3953 100644 --- a/tests/driver_expansion.rs +++ b/tests/driver_expansion.rs @@ -124,7 +124,7 @@ fn claude_driver_matches_deliver_after_normalizing_only_the_subcommand_alias() { let executable = catalog.join("bin/st2"); fs::create_dir_all(executable.parent().unwrap()).unwrap(); fs::write(&executable, "test binary").unwrap(); - let context = TaskCompileContext::new(catalog.clone(), executable).unwrap(); + let context = TaskCompileContext::new(catalog.clone(), executable.clone()).unwrap(); compile_generated_tasks(std::slice::from_mut(&mut legacy), "h", &context).unwrap(); compile_generated_tasks(std::slice::from_mut(&mut driver), "h", &context).unwrap(); @@ -138,6 +138,23 @@ fn claude_driver_matches_deliver_after_normalizing_only_the_subcommand_alias() { .iter() .find(|task| task.name == "agent") .unwrap(); + let executable_string = executable.to_string_lossy().into_owned(); + let catalog_string = catalog.to_string_lossy().into_owned(); + assert_eq!( + &driver_task.argv.as_ref().unwrap()[..10], + [ + executable_string.as_str(), + "--catalog", + catalog_string.as_str(), + "driver", + "claude-session", + "--identity", + "h.worker", + "--runtime-id", + "h.worker", + "--", + ] + ); assert_eq!(driver_task, legacy_task); materialize_agent(&catalog, &legacy, "h").unwrap(); @@ -158,6 +175,46 @@ fn claude_driver_matches_deliver_after_normalizing_only_the_subcommand_alias() { assert_eq!(driver_mcp, legacy_mcp); } +#[test] +fn legacy_claude_shell_launch_keeps_its_source_under_the_session_wrapper() { + let temp = tempfile::tempdir().unwrap(); + let catalog = temp.path().join("catalog"); + let path = catalog.join("agent.kdl"); + fs::create_dir_all(&catalog).unwrap(); + fs::write( + &path, + r#"agent "worker" { + host "h" + deliver "mcp" + command "exec claude boot" +} +"#, + ) + .unwrap(); + let (mut specs, _) = st2::discover_file(&catalog, &path).unwrap(); + let executable = catalog.join("bin/st2"); + fs::create_dir_all(executable.parent().unwrap()).unwrap(); + fs::write(&executable, "test binary").unwrap(); + let context = TaskCompileContext::new(catalog, executable).unwrap(); + + compile_generated_tasks(&mut specs, "h", &context).unwrap(); + + let argv = specs[0].tasks[0].argv.as_ref().unwrap(); + assert_eq!( + &argv[3..10], + [ + "driver", + "claude-session", + "--identity", + "h.worker", + "--runtime-id", + "h.worker", + "--", + ] + ); + assert_eq!(&argv[10..], ["sh", "-c", "exec claude boot"]); +} + #[test] fn ambiguous_driver_source_neither_compiles_nor_materializes() { let temp = tempfile::tempdir().unwrap(); diff --git a/tests/fixtures/driver/claude.out.kdl b/tests/fixtures/driver/claude.out.kdl index ab1b783a..d77d7798 100644 --- a/tests/fixtures/driver/claude.out.kdl +++ b/tests/fixtures/driver/claude.out.kdl @@ -1,4 +1,4 @@ render { json-upsert .mcp.json "{\n \"mcpServers\": {\n \"st2\": {\n \"args\": [\n \"--catalog\",\n \"$CATALOG\",\n \"driver\",\n \"claude\",\n \"--identity\",\n \"Silber.fabric\"\n ],\n \"command\": \"st2\",\n \"type\": \"stdio\"\n }\n }\n}" } -argv claude --model opus --effort xhigh "--dangerously-load-development-channels=server:st2" --permission-mode bypassPermissions --model override "Start the assigned work." +argv st2 --catalog $CATALOG driver claude-session --identity Silber.fabric --runtime-id Silber.fabric -- claude --model opus --effort xhigh "--dangerously-load-development-channels=server:st2" --permission-mode bypassPermissions --model override "Start the assigned work." diff --git a/tests/status_agents.rs b/tests/status_agents.rs index 553fa770..1c5a8e93 100644 --- a/tests/status_agents.rs +++ b/tests/status_agents.rs @@ -438,19 +438,19 @@ agent "two" { assert_eq!(selected[0]["name"], "Second Agent Spec"); } -/// A status file older than the stale window projects as `unknown` in the roster, no matter its value. +/// A legacy status file older than the stale window projects as `unknown` in the roster. #[test] fn roster_derives_unknown_from_a_stale_status() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); write(root, "hetz/idle/agent.kdl", &agent_kdl("idle", "hetz")); let sp = status_path(&root.join("hetz/idle")); - set_state(&sp, State::Available).unwrap(); + fs::write(&sp, "available\n").unwrap(); // Fresh → available. assert_eq!(roster(root, "hetz")[0].status, State::Available); - // Backdate the status file past the stale window → unknown. + // Backdate the legacy status file past the stale window → unknown. let old = SystemTime::now() - st2::status::STATUS_STALE - Duration::from_secs(60); fs::File::open(&sp).unwrap().set_modified(old).unwrap(); assert_eq!(roster(root, "hetz")[0].status, State::Unknown); diff --git a/tests/validate.rs b/tests/validate.rs index 04719dcf..23927050 100644 --- a/tests/validate.rs +++ b/tests/validate.rs @@ -496,7 +496,11 @@ fn ls_compiles_driver_launches_before_display() { ); let stdout = String::from_utf8_lossy(&output.stdout); assert!(!stdout.contains("UNRENDERED"), "{stdout}"); - assert!(stdout.contains(r#"argv ["claude", "boot"]"#), "{stdout}"); + assert!( + stdout.contains(r#""driver", "claude-session""#) + && stdout.contains(r#""--", "claude", "boot"]"#), + "{stdout}" + ); } #[test] From 3687a0bdeb2095c5c7f0dc42527dbf97b393d5ac Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Sat, 15 Aug 2026 00:33:18 +0200 Subject: [PATCH 53/56] Conform presence records to version 1 --- INVARIANTS.md | 4 +- README.md | 14 +- docs/vrs/spec.md | 162 ++++++++++- src/agents.rs | 36 +-- src/codex_app_server.rs | 2 +- src/ding/mod.rs | 2 +- src/status.rs | 581 ++++++++++++++++++++++++++-------------- tests/catalog_apply.rs | 4 +- tests/status_agents.rs | 82 +++++- 9 files changed, 626 insertions(+), 261 deletions(-) diff --git a/INVARIANTS.md b/INVARIANTS.md index 3cd53242..21ef1de5 100644 --- a/INVARIANTS.md +++ b/INVARIANTS.md @@ -18,8 +18,8 @@ materialization, messaging, DING, or presence must preserve them. | **Mutation-only filesystem wakeups** | Supervisor and DING filesystem watchers ignore read/open access events and wake early only for create, modify, rename, or remove events. Their own catalog and inbox reads therefore cannot bypass the bounded timer cadence or form a Linux inotify CPU loop. | `src/watch.rs::only_mutations_wake_watch_loops`; `src/watch.rs::linux_reads_are_silent_but_real_mutations_wake`; `src/ding/mod.rs::idle_ding_does_not_spin_on_its_own_inbox_reads`; `src/run.rs::idle_supervisor_does_not_spin_on_its_own_catalog_reads` | | **Bounded DING PTY probe churn** | An unsafe or active composer retains its FIFO notice but deferred delivery retries use a bounded backoff, so each inbox poll cannot spawn another short-lived PTY probe. | `src/ding/mod.rs::deferred_delivery_backoff_bounds_short_lived_pty_attempts` | | **Agent-declared presence discipline** | The shipped bus contract requires agents to declare `busy` before executing work, use `available` only while yielding or ready, and reserve `dnd` for an explicit hold. Both native harnesses materialize that contract. Busy remains observable but does not suppress DING; fresh `dnd` is the only delivery gate. | `tests/native_only.rs::clean_path_executes_the_maintained_native_authoring_guide`; `src/ding/mod.rs::pending_delivery_ignores_busy_but_respects_fresh_dnd_archive_and_retry` | -| **Stable roster JSON** | `st2 agents --json [--enrich]` preserves field names, order, null handling, presence, typed desired state and rationale, the retirement compatibility projection, opaque declared Resource descriptors, activity, and inbox counts. Presence remains independent from desired lifecycle. | `src/agents.rs::agents_json_has_stable_wire_shape`; `src/agents.rs::agents_json_preserves_opaque_declared_resource_descriptors`; `tests/status_agents.rs::roster_json_and_human_output_distinguish_retirement_from_presence`; `tests/status_agents.rs::roster_keeps_presence_separate_from_suspended_desired_state` | -| **Agent-declared presence** | Refresh preserves non-DND declared status and advances liveness with a replicated content timestamp. New readers use that timestamp, while old readers remain compatible with the first-line state and new readers accept a legacy bare state. A missing status starts as `available`. `dnd` is never refreshed and ages to `unknown`. The outer Codex and Claude session wrappers own a five-minute heartbeat while their provider remains alive. | `src/status.rs::refresh_preserves_value_and_changes_the_replicated_bytes`; `src/status.rs::timestamped_status_keeps_the_old_reader_first_line`; `src/status.rs::timestamped_content_controls_freshness_after_replication`; `src/status.rs::legacy_bare_word_uses_mtime_during_a_mixed_fleet_upgrade`; `src/status.rs::writer_and_replica_carry_the_same_fresh_timestamp_while_idle`; `src/status.rs::refresh_leaves_dnd_to_age_out`; `src/status.rs::refresh_missing_writes_available_default`; `src/claude_session.rs::idle_provider_refreshes_presence_without_mcp_input`; `src/codex_app_server.rs::inbox_fallback_does_not_write_a_fifteen_second_presence_heartbeat` | +| **Stable roster JSON** | `st2 agents --json [--enrich]` preserves field names, order, null handling, presence, typed desired state and rationale, the retirement compatibility projection, opaque declared Resource descriptors, origin-timed activity, and inbox counts. Presence remains independent from desired lifecycle. | `src/agents.rs::agents_json_has_stable_wire_shape`; `src/agents.rs::agents_json_preserves_opaque_declared_resource_descriptors`; `tests/status_agents.rs::roster_json_and_human_output_distinguish_retirement_from_presence`; `tests/status_agents.rs::roster_keeps_presence_separate_from_suspended_desired_state`; `tests/status_agents.rs::roster_uses_version_1_origin_time_for_last_activity` | +| **Agent-declared presence** | Refresh preserves non-DND declared status and advances the version 1 heartbeat. A missing status starts as `available`. Legacy DND migrates without renewing its hold. Version 1 DND is not refreshed. Stale, malformed, or implausibly future heartbeats read as `unknown`. The outer Codex and Claude session wrappers own a five-minute heartbeat while their provider remains alive. | `src/status.rs::refresh_preserves_value_and_changes_heartbeat_bytes`; `src/status.rs::refresh_upgrades_legacy_dnd_without_renewing_the_hold`; `src/status.rs::refresh_missing_writes_available_default`; `src/status.rs::version_1_staleness_and_future_skew_are_bounded`; `src/status.rs::malformed_versioned_record_is_unknown_without_mtime_fallback`; `src/claude_session.rs::idle_provider_refreshes_presence_without_mcp_input`; `src/codex_app_server.rs::inbox_fallback_does_not_write_a_fifteen_second_presence_heartbeat` | | **Retirement health** | A retired declaration is healthy only after every declared task ID is absent. Any live or dead declared task record reports incomplete retirement; retired declarations do not require presence. Live declarations retain their existing task and presence checks. | `tests/doctor.rs::retired_declaration_is_healthy_when_tasks_and_presence_are_absent`; `tests/doctor.rs::retired_declaration_is_unhealthy_while_a_declared_task_is_alive`; `tests/doctor.rs::retired_declaration_is_unhealthy_while_a_dead_task_record_remains` | | **Suspension health** | A suspended declaration is healthy when no declared task is live and every retained dead record is explicitly keep-pinned. It requires no presence, but this weaker result never proves retirement. Resume preserves ordinary keep and adopt-only policy. | `tests/doctor.rs::suspended_declaration_is_healthy_when_tasks_are_absent_without_presence`; `tests/doctor.rs::suspended_declaration_distinguishes_live_dead_keep_and_dead_nonkeep`; `tests/reconcile.rs::resuming_uses_ordinary_reconcile_and_does_not_override_keep` | | **Crash loops surface** | A task parked by a fail-mode restart policy notifies its supervisor once over the bus. | `tests/run.rs::surface_crash_loop_notifies_the_supervisor_over_the_bus` | diff --git a/README.md b/README.md index 12bc1c34..577a0bc5 100644 --- a/README.md +++ b/README.md @@ -475,12 +475,14 @@ contract; renderer changes can defer delivery and remain an explicit design gap. Agents must declare `busy` before actively executing work and return to `available` only when yielding or ready for new work, but `busy` never suppresses DING. Fresh `dnd` is the only delivery -hold. Each status record keeps the state on its first line. New writers add -`updated-at-unix-ms ` on the second line. New readers use that timestamp and accept a -legacy bare state. Old readers keep using the first line and file mtime. A live session owner -refreshes non-DND presence every five minutes. This changes the replicated bytes and stays below the -15-minute stale limit. A session owner does not refresh `dnd`, so an abandoned hold becomes stale -and delivery resumes. New arrivals remain FIFO. Same-filename archive receipts shadow and clean +hold. Each status record keeps the state on its first line. Version 1 writers add `v1 ` on +the second line. New readers use that origin timestamp and accept a legacy bare state. Old readers +keep using the first line and file mtime. A live session owner refreshes non-DND presence every five +minutes. This changes the replicated bytes and stays below the 15-minute stale limit. A legacy +`dnd` upgrades once with its existing mtime, then remains unchanged. A malformed versioned record or +a timestamp more than 60 seconds in the future reads as `unknown` without an mtime fallback. Version +1 status contributes its origin timestamp to `lastActivity`. New arrivals remain FIFO. +Same-filename archive receipts shadow and clean restored inbox duplicates. Failed or uncertain PTY operations retain the notice for safe retry. Unsafe delivery retries use a bounded backoff, so an active composer cannot make the sidecar spawn a fresh PTY probe on every inbox poll. On start diff --git a/docs/vrs/spec.md b/docs/vrs/spec.md index 3a687e0b..0bdb21cd 100644 --- a/docs/vrs/spec.md +++ b/docs/vrs/spec.md @@ -682,10 +682,10 @@ sender intent → atomic inbox file → sender row → DING attempt → agent re ## State and scope -- **R08:** Presence and activity status are separate signals. The catalog must - also expose the agent's current plan and step with explicit freshness so a - human or supervising agent can understand progress without PTY inspection. - Current presence/status files provide only part of this contract; the +- **R08:** Presence and activity status are separate signals. The version 1 + presence record below defines presence freshness. The catalog must also + expose the agent's current plan and step with explicit freshness so a human + or supervising agent can understand progress without PTY inspection. The canonical plan-progress shape is not yet specified. - **R09:** Durable work state is external to the model transcript and is restored into replacement sessions through declared workspace files and @@ -693,6 +693,150 @@ sender intent → atomic inbox file → sender row → DING attempt → agent re - **R10:** Fleet identities are agents. General-purpose identity kinds are unsupported. +### Presence record and freshness (R08) + +This section answers the presence part of DQ3. The version 1 implementation +follows this contract. + +#### Version 1 record + +The presence record path is `/status`. + +The status file uses this exact version 1 shape: + +```text +available +v1 1785802653486 +``` + +Line one is one settable state: `offline`, `available`, `busy`, `away`, or +`dnd`. `unknown` remains derived and is never written. + +Line two is `v1`, one ASCII space, and an unsigned base-10 timestamp. The +timestamp counts milliseconds from the Unix epoch. + +The record ends with one newline. It has no other non-empty lines. Both lines +form one atomic record. + +The state remains on line one for old readers. An old reader can ignore line +two and continue to parse the state. + +#### Writers and atomicity + +`st2 status --set` writes the requested state with the current timestamp. A +live session owner refreshes valid non-DND records every five minutes. + +A missing record becomes `available` with the current timestamp. The session +owner does not refresh `dnd`, `unknown`, or malformed records. + +Every new writer emits version 1. It writes a temporary sibling and atomically +renames the complete record over the target. + +A healthy periodic refresh changes the timestamp bytes. Replication can order +that content change without using the source file mtime. + +#### Clock, freshness, and skew + +The timestamp uses the writer's UTC wall clock. A monotonic clock cannot cross +a process restart or a host boundary. + +Participating hosts must keep their UTC clocks within sixty seconds. A larger +clock error makes cross-host presence unknown. + +The stale interval remains fifteen minutes. A valid record is fresh while its +age is less than fifteen minutes. + +A record becomes `unknown` when its age reaches fifteen minutes. This rule +applies to every settable state, including `offline` and `dnd`. + +A timestamp up to sixty seconds in the reader's future is allowed. The reader +uses zero age for this bounded future value. + +A timestamp more than sixty seconds in the future produces `unknown`. The +reader does not use file mtime as a fallback for malformed version 1. + +Current readers treat a future legacy status mtime as fresh because they cannot +calculate its age. A sufficiently future `dnd` mtime can therefore suppress +delivery until the reader's clock catches up. The version 1 skew rules close +this defect. They clamp only bounded future time and map larger future time to +`unknown`. + +An unrecognized state still produces `offline`. A literal `unknown` produces +`unknown`. A valid state with a malformed version, timestamp, or extra line +also produces `unknown`. + +The sixty-second allowance is smaller than the five-minute refresh margin. It +can extend a fresh DND hold by no more than sixty seconds. + +#### Why readers use origin time + +st2 does not require one catalog transport. Fabric is preferred, and Git over +SSH or a plain copy remains supported. + +Git does not preserve file modification times. A checkout gives files the +checkout time. Therefore, presence freshness lives in record bytes. No +supported transport must preserve file metadata. + +Replica arrival time measures transport delay, not agent activity. The +embedded writer time protects presence freshness, DND expiry, and the status +contribution to `lastActivity`. + +The same reason applies to the context boot freshness check. A replica arrival +must not make old context appear fresh. This proposal does not change the +context record. + +#### DND behavior + +A fresh `dnd` record suppresses DING delivery. The sidecar leaves its timestamp +unchanged, so an abandoned hold ages out. + +A stale or invalid DND record does not suppress delivery. It reads as +`unknown`, which preserves the existing fresh-DND rule. + +Replication delay cannot renew a DND hold. The reader uses the embedded write +time, not the replica materialization time. + +#### Legacy rollout + +A legacy record contains one valid state line and no version line. The first +version 1 reader release uses legacy file mtime for freshness. + +Version 1 writers never emit a legacy record. A live non-DND session owner +upgrades its legacy record at its next five-minute refresh. + +A version 1 session owner upgrades a legacy DND record once. It uses the legacy +mtime as the embedded timestamp, so the migration cannot renew the hold. + +After that migration, the sidecar does not refresh DND. If the legacy mtime is +unavailable, the sidecar leaves the record unchanged. + +A malformed two-line record is not legacy. Readers must not hide a bad version +1 record behind the legacy mtime fallback. + +Fallback removal is a separate reviewed change. Removal requires all three +receipts below: + +1. Every supported deployed status writer emits version 1. +2. Two fleet scans, separated by fifteen minutes, find no active legacy record. +3. No supported or retained rollback binary can emit a legacy record. + +After removal, a one-line record produces `unknown`. No presence freshness +decision then depends on status file mtime. + +#### `lastActivity` + +For a version 1 status record, `lastActivity` uses the embedded timestamp. It +does not use the replica materialization mtime. + +The reader clamps an allowed future timestamp to its current time. It omits a +malformed version 1 timestamp from the activity calculation. + +Inbox and archive entries continue to use their local file mtimes. During the +legacy window, a one-line status record also contributes its file mtime. + +This choice reports when the agent wrote its heartbeat. A delayed replica +cannot make an old heartbeat appear to be new agent activity. + ## Provider session-start restoration (R07, R09, R17, R33) ```text @@ -810,11 +954,11 @@ the resident supervisor continues to reconcile the complete local catalog. changes may defer delivery. Resolve the remaining gap with a stronger evented signal or other measured classifier; a small on-device model is an optional experiment, not a required architecture. -- **DQ3 Catalog agent state:** Define the catalog paths, schemas, freshness - rules, and atomic update semantics for presence, activity status, current - plan, and current plan step. Prove that stale state is distinguishable and - that a supervisor can follow plan progress without inspecting a PTY before - adding the shape to `AGENT-SPEC.md`. +- **DQ3 Remaining catalog agent state:** The R08 presence record above + defines the presence path, schema, freshness, and atomic update rules. + Activity status, current plan, and current plan step remain undefined. Prove + their stale-state and supervisor-following behavior before adding their shape + to `AGENT-SPEC.md`. - **DQ4 Relaunch boundary (R29-R30):** Preserve R11's nondisruptive adoption while making launch drift visible. For each declared task, derive the desired launch fingerprint from a deterministic, versioned encoding of only: diff --git a/src/agents.rs b/src/agents.rs index 767d652f..f4977cd5 100644 --- a/src/agents.rs +++ b/src/agents.rs @@ -4,7 +4,7 @@ use std::fs; use std::path::{Path, PathBuf}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::UNIX_EPOCH; use serde::Serialize; @@ -31,8 +31,8 @@ pub struct AgentRow { pub desired_state_reason: Option, /// Typed Resource bindings declared directly by the agent. pub resources: Vec, - /// Newest mtime (unix ms) across the agent's inbox, archive, and status file; `None` if nothing - /// has been touched. `--enrich` only. + /// Newest activity time across inbox, archive, and status. Version 1 status uses its embedded + /// writer timestamp; message files and legacy status use local mtime. `--enrich` only. pub last_activity_ms: Option, /// Count of canonical message files in the agent's inbox. `--enrich` only. pub inbox: usize, @@ -62,7 +62,7 @@ pub fn roster_from_discovered(found: &Discovered, this_host: &str) -> Vec usize { .unwrap_or(0) } -/// Newest mtime (unix ms) across the agent's inbox files, archive files, and status file. `None` if -/// none of those exist. -fn newest_mtime_ms(agent_dir: &Path) -> Option { +/// Newest activity time across status and message state. A version 1 status contributes its origin +/// timestamp. Inbox, archive, and legacy status retain their local-mtime behavior. +fn newest_activity_ms(agent_dir: &Path) -> Option { let mut candidates: Vec = Vec::new(); for dir in [ message::inbox_dir(agent_dir), @@ -161,20 +161,19 @@ fn newest_mtime_ms(agent_dir: &Path) -> Option { candidates.extend(rd.flatten().map(|e| e.path())); } } - candidates.push(status::status_path(agent_dir)); - - let mut newest: Option = None; + let mut newest = status::activity_time_ms(&status::status_path(agent_dir)); for p in candidates { if let Ok(m) = fs::metadata(&p) && let Ok(t) = m.modified() - && newest.is_none_or(|n| t > n) + && let Ok(duration) = t.duration_since(UNIX_EPOCH) { - newest = Some(t); + let timestamp = duration.as_secs_f64() * 1000.0; + if newest.is_none_or(|current| timestamp > current) { + newest = Some(timestamp); + } } } newest - .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) - .map(|d| d.as_secs_f64() * 1000.0) } #[cfg(test)] @@ -232,14 +231,7 @@ mod tests { #[test] fn agents_json_preserves_opaque_declared_resource_descriptors() { - let mut resource_row = row( - "hetz.worker", - State::Available, - None, - false, - None, - 0, - ); + let mut resource_row = row("hetz.worker", State::Available, None, false, None, 0); resource_row.resources.push( Resource::new( "work".into(), diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index f7901628..c741248b 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -2592,7 +2592,7 @@ mod tests { assert!( std::fs::read_to_string(&presence) .unwrap() - .contains("updated-at-unix-ms ") + .contains("\nv1 ") ); assert!(delivery.head.is_none()); } diff --git a/src/ding/mod.rs b/src/ding/mod.rs index 68b96e8e..05d60ef3 100644 --- a/src/ding/mod.rs +++ b/src/ding/mod.rs @@ -3213,7 +3213,7 @@ Enter to select · ↑/↓ to navigate · Esc to cancel"; .as_millis(); std::fs::write( &status_path, - format!("dnd\nupdated-at-unix-ms {stale_ms}\n"), + format!("dnd\nv1 {stale_ms}\n"), ) .unwrap(); flush_without_catalog(Some(&status_path), &mut pending, &poker); diff --git a/src/status.rs b/src/status.rs index f09ec4b2..39250a7f 100644 --- a/src/status.rs +++ b/src/status.rs @@ -1,25 +1,29 @@ //! Native per-agent presence status. //! -//! A `status` file is a sibling of `agent.kdl` in the agent directory. The first line holds one -//! settable state. The optional second line holds `updated-at-unix-ms `. Old readers use -//! the first line and file mtime. New readers use the content timestamp when present and accept the -//! old bare state during a mixed-fleet upgrade. Thus, each heartbeat changes the replicated bytes. -//! `unknown` is derived and is never written. Reads are permissive: missing and corrupt files become -//! `offline`. Writes are atomic, so a reader never sees a partial file. A live session refreshes its -//! non-DND status. `dnd` is not refreshed, so an abandoned hold ages to `unknown`. +//! A `status` file (sibling of `agent.kdl` in the agent's dir) stores a settable state and an +//! embedded Unix-millisecond heartbeat. `unknown` is DERIVED, never written. A heartbeat older than +//! [`STATUS_STALE`] reads as `unknown`, so a crashed agent stops reading as its last set value. +//! Writes are atomic (tmp + rename), and the live session owner refreshes valid non-DND records +//! every [`STATUS_REFRESH`]. Legacy one-line records use mtime during the bounded migration. New writers +//! always emit version 1, whose freshness survives transports that do not preserve file metadata. +//! `dnd` is not refreshed after migration, so an abandoned hold ages to `unknown`. use std::fs; +use std::io::Read as _; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, UNIX_EPOCH}; -/// A status record older than this reads as `unknown` no matter which state it contains. +/// A valid status heartbeat at least this old reads as `unknown`. pub const STATUS_STALE: Duration = Duration::from_secs(15 * 60); -/// A live session refreshes presence every five minutes. This permits two missed writes before the -/// 15-minute stale limit and produces 288 replicated writes each day. +/// How often a live agent's status should be refreshed to stay inside the stale window — 5 min gives +/// a 3× safety margin (two missed refreshes before `unknown`). pub const STATUS_REFRESH: Duration = Duration::from_secs(5 * 60); +/// Maximum accepted positive difference between a writer's UTC clock and the reader's clock. +pub const STATUS_FUTURE_SKEW: Duration = Duration::from_secs(60); -const UPDATED_AT_PREFIX: &str = "updated-at-unix-ms "; +const RECORD_VERSION: &str = "v1"; +const RECORD_PREFIX: &str = "v1 "; /// Presence state. `Unknown` is derived from staleness and is never written to disk. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -31,7 +35,6 @@ pub enum State { Dnd, Unknown, } - impl State { pub fn as_str(self) -> &'static str { match self { @@ -73,91 +76,44 @@ pub fn status_path(agent_dir: &Path) -> PathBuf { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct StatusRecord { - state: State, - updated_at_unix_ms: Option, +enum ParsedRecord { + Legacy(State), + Version1 { state: State, written_at_ms: u64 }, + LiteralUnknown, + InvalidState, + Malformed, } -/// Read an agent's effective presence. A timestamped record uses its content timestamp. A legacy -/// bare state uses mtime. Missing, unreadable, and corrupt files read as `offline`. +/// Read an agent's effective presence. Missing or unreadable files produce `offline`. Version 1 +/// uses its embedded timestamp. A valid legacy line uses file mtime during migration. Malformed +/// versioned records fail closed to `unknown` and never fall back to mtime. pub fn read_state(status_path: &Path) -> State { - read_state_at(status_path, SystemTime::now()) -} - -fn read_state_at(status_path: &Path, now: SystemTime) -> State { - let meta = match fs::metadata(status_path) { - Ok(m) => m, - Err(_) => return State::Offline, // missing - }; - let raw = match fs::read_to_string(status_path) { - Ok(r) => r, + let (record, legacy_mtime_ms) = match read_record(status_path) { + Ok(record) => record, Err(_) => return State::Offline, }; - let Some(record) = parse_record(&raw) else { - return State::Offline; - }; - let stale = match record.updated_at_unix_ms { - Some(updated_at) => content_timestamp_is_stale(now, updated_at), - None => meta - .modified() - .ok() - .and_then(|mtime| now.duration_since(mtime).ok()) - .is_some_and(|age| age >= STATUS_STALE), - }; - if stale { State::Unknown } else { record.state } -} - -fn parse_record(raw: &str) -> Option { - let mut lines = raw.lines(); - let state = State::parse_any(lines.next().unwrap_or("").trim())?; - let updated_at_unix_ms = match lines.next() { - None => None, - Some(line) => { - let value = line.trim().strip_prefix(UPDATED_AT_PREFIX)?; - Some(value.parse().ok()?) - } - }; - if lines.next().is_some() { - return None; - } - Some(StatusRecord { - state, - updated_at_unix_ms, - }) -} - -fn content_timestamp_is_stale(now: SystemTime, updated_at_unix_ms: u128) -> bool { - now.duration_since(UNIX_EPOCH) - .ok() - .and_then(|duration| duration.as_millis().checked_sub(updated_at_unix_ms)) - .is_some_and(|age_ms| age_ms >= STATUS_STALE.as_millis()) + read_parsed_at(record, legacy_mtime_ms, crate::message::now_ms()) } -/// Set an agent's presence atomically. The first line remains compatible with old readers. The -/// second line gives content-based synchronizers a changed value and gives new readers freshness. +/// Set an agent's presence to a version 1 record with the current timestamp. The write is atomic +/// (temporary sibling + rename) and creates the agent directory when needed. pub fn set_state(status_path: &Path, state: State) -> anyhow::Result<()> { - set_state_at(status_path, state, SystemTime::now()) + write_record(status_path, state, crate::message::now_ms()) } -fn set_state_at(status_path: &Path, state: State, now: SystemTime) -> anyhow::Result<()> { - write_atomic(status_path, &encode_record(state, now)?) -} - -fn encode_record(state: State, now: SystemTime) -> anyhow::Result { - let updated_at_unix_ms = now - .duration_since(UNIX_EPOCH) - .map_err(|_| anyhow::anyhow!("presence timestamp is before the Unix epoch"))? - .as_millis(); - Ok(format!( - "{}\n{UPDATED_AT_PREFIX}{updated_at_unix_ms}\n", - state.as_str() - )) +/// Timestamp contributed by the status record to `agents --enrich`. Version 1 returns its embedded +/// writer time, clamped to the reader's current time within the allowed future skew. Legacy records +/// return mtime during migration. Invalid records and excessive future skew contribute nothing. +pub fn activity_time_ms(status_path: &Path) -> Option { + let (record, legacy_mtime_ms) = read_record(status_path).ok()?; + activity_time_at(record, legacy_mtime_ms, crate::message::now_ms()) + .map(|timestamp| timestamp as f64) } /// Outcome of a [`refresh`] call. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RefreshOutcome { - /// A valid settable state received a new content timestamp. + /// A valid record received a new heartbeat or completed its one-time legacy upgrade. Refreshed, /// File recorded `dnd` → left untouched so an abandoned hold ages out. LeftDnd, @@ -171,45 +127,171 @@ pub enum RefreshOutcome { Error, } -/// Write a new content timestamp while preserving the state. Missing becomes `available`. `dnd`, -/// `unknown`, and corrupt records stay unchanged. The write is atomic. +/// Refresh the embedded heartbeat for a live agent while preserving its state. Missing writes +/// `available`. A legacy non-DND record upgrades with the current time. A legacy DND record upgrades +/// once with its old mtime. Version 1 DND, `unknown`, and malformed records remain untouched. pub fn refresh(status_path: &Path) -> RefreshOutcome { - refresh_at(status_path, SystemTime::now()) + let (record, legacy_mtime_ms) = match read_record(status_path) { + Ok(record) => record, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return match write_record(status_path, State::Available, crate::message::now_ms()) { + Ok(()) => RefreshOutcome::WroteDefault, + Err(_) => RefreshOutcome::Error, + }; + } + Err(_) => return RefreshOutcome::Error, + }; + match record { + ParsedRecord::Version1 { + state: State::Dnd, .. + } => RefreshOutcome::LeftDnd, + ParsedRecord::Version1 { state, .. } => { + match write_record(status_path, state, crate::message::now_ms()) { + Ok(()) => RefreshOutcome::Refreshed, + Err(_) => RefreshOutcome::Error, + } + } + ParsedRecord::Legacy(State::Dnd) => { + let Some(written_at_ms) = legacy_mtime_ms else { + return RefreshOutcome::LeftDnd; + }; + match write_record(status_path, State::Dnd, written_at_ms) { + Ok(()) => RefreshOutcome::Refreshed, + Err(_) => RefreshOutcome::Error, + } + } + ParsedRecord::Legacy(state) => { + match write_record(status_path, state, crate::message::now_ms()) { + Ok(()) => RefreshOutcome::Refreshed, + Err(_) => RefreshOutcome::Error, + } + } + ParsedRecord::LiteralUnknown => RefreshOutcome::LeftUnknown, + ParsedRecord::InvalidState | ParsedRecord::Malformed => RefreshOutcome::LeftCorrupt, + } } -fn refresh_at(status_path: &Path, now: SystemTime) -> RefreshOutcome { - if !status_path.exists() { - return match set_state_at(status_path, State::Available, now) { - Ok(()) => RefreshOutcome::WroteDefault, - Err(_) => RefreshOutcome::Error, - }; - } - let raw = match fs::read_to_string(status_path) { - Ok(r) => r, - Err(_) => return RefreshOutcome::Error, +fn read_record(path: &Path) -> std::io::Result<(ParsedRecord, Option)> { + let mut file = fs::File::open(path)?; + let mut raw = String::new(); + file.read_to_string(&mut raw)?; + let record = parse_record(&raw); + let legacy_mtime_ms = matches!(record, ParsedRecord::Legacy(_)) + .then(|| file_mtime_ms(&file)) + .flatten(); + Ok((record, legacy_mtime_ms)) +} + +fn parse_record(raw: &str) -> ParsedRecord { + let has_final_newline = raw.ends_with('\n'); + let body = raw.strip_suffix('\n').unwrap_or(raw); + let mut lines = body.split('\n'); + let state = match State::parse_any(lines.next().unwrap_or("")) { + Some(State::Unknown) => return ParsedRecord::LiteralUnknown, + Some(state) => state, + None => return ParsedRecord::InvalidState, }; - let Some(record) = parse_record(&raw) else { - return RefreshOutcome::LeftCorrupt; + let Some(version_line) = lines.next() else { + return ParsedRecord::Legacy(state); + }; + if !has_final_newline || lines.next().is_some() { + return ParsedRecord::Malformed; + } + let Some(timestamp) = version_line.strip_prefix(RECORD_PREFIX) else { + return ParsedRecord::Malformed; }; - if record.state == State::Unknown { - return RefreshOutcome::LeftUnknown; + if timestamp.is_empty() || !timestamp.bytes().all(|byte| byte.is_ascii_digit()) { + return ParsedRecord::Malformed; } - if record.state == State::Dnd { - return RefreshOutcome::LeftDnd; + match timestamp.parse::() { + Ok(written_at_ms) => ParsedRecord::Version1 { + state, + written_at_ms, + }, + Err(_) => ParsedRecord::Malformed, } - match set_state_at(status_path, record.state, now) { - Ok(()) => RefreshOutcome::Refreshed, - Err(_) => RefreshOutcome::Error, +} + +fn read_parsed_at(record: ParsedRecord, legacy_mtime_ms: Option, now_ms: u64) -> State { + match record { + ParsedRecord::Legacy(state) => legacy_mtime_ms.map_or(State::Unknown, |written_at_ms| { + effective_state_at(state, written_at_ms, now_ms) + }), + ParsedRecord::Version1 { + state, + written_at_ms, + } => effective_state_at(state, written_at_ms, now_ms), + ParsedRecord::LiteralUnknown | ParsedRecord::Malformed => State::Unknown, + ParsedRecord::InvalidState => State::Offline, } } +fn effective_state_at(state: State, written_at_ms: u64, now_ms: u64) -> State { + if written_at_ms > now_ms { + return if written_at_ms - now_ms <= duration_ms(STATUS_FUTURE_SKEW) { + state + } else { + State::Unknown + }; + } + if now_ms - written_at_ms >= duration_ms(STATUS_STALE) { + State::Unknown + } else { + state + } +} + +fn activity_time_at( + record: ParsedRecord, + legacy_mtime_ms: Option, + now_ms: u64, +) -> Option { + let written_at_ms = match record { + ParsedRecord::Legacy(_) => legacy_mtime_ms?, + ParsedRecord::Version1 { written_at_ms, .. } => written_at_ms, + ParsedRecord::LiteralUnknown | ParsedRecord::InvalidState | ParsedRecord::Malformed => { + return None; + } + }; + if written_at_ms > now_ms { + (written_at_ms - now_ms <= duration_ms(STATUS_FUTURE_SKEW)).then_some(now_ms) + } else { + Some(written_at_ms) + } +} + +fn file_mtime_ms(file: &fs::File) -> Option { + file.metadata() + .ok()? + .modified() + .ok()? + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|duration| u64::try_from(duration.as_millis()).ok()) +} + +fn duration_ms(duration: Duration) -> u64 { + u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) +} + +fn write_record(path: &Path, state: State, written_at_ms: u64) -> anyhow::Result<()> { + anyhow::ensure!( + state != State::Unknown, + "unknown is derived and cannot be written" + ); + write_atomic( + path, + &format!("{}\n{RECORD_VERSION} {written_at_ms}\n", state.as_str()), + ) +} + /// Atomic write: a temp sibling + rename, so a concurrent reader sees either the old bytes or the new /// bytes, never a partial file. -fn write_atomic(path: &Path, value: &str) -> anyhow::Result<()> { +fn write_atomic(path: &Path, content: &str) -> anyhow::Result<()> { let dir = path.parent().unwrap_or(Path::new(".")); fs::create_dir_all(dir)?; let tmp = dir.join(tmp_name()); - fs::write(&tmp, value)?; + fs::write(&tmp, content)?; // rename over the target — atomic on the same filesystem. if let Err(e) = fs::rename(&tmp, path) { let _ = fs::remove_file(&tmp); // best-effort cleanup @@ -233,11 +315,7 @@ fn tmp_name() -> String { #[cfg(test)] mod tests { use super::*; - use std::time::Duration as Dur; - - fn at(unix_ms: u64) -> SystemTime { - UNIX_EPOCH + Dur::from_millis(unix_ms) - } + use std::time::{Duration as Dur, SystemTime}; #[test] fn missing_is_offline() { @@ -249,7 +327,6 @@ mod tests { fn set_then_read_roundtrips_each_settable_state() { let tmp = tempfile::tempdir().unwrap(); let sp = status_path(tmp.path()); - let written_at = 1_786_741_730_761; for st in [ State::Offline, State::Available, @@ -257,34 +334,32 @@ mod tests { State::Away, State::Dnd, ] { - set_state_at(&sp, st, at(written_at)).unwrap(); - assert_eq!(read_state_at(&sp, at(written_at + 1)), st); - assert_eq!( - fs::read_to_string(&sp).unwrap(), - format!("{}\nupdated-at-unix-ms {written_at}\n", st.as_str()) - ); + let before = crate::message::now_ms(); + set_state(&sp, st).unwrap(); + let after = crate::message::now_ms(); + assert_eq!(read_state(&sp), st); + let raw = fs::read_to_string(&sp).unwrap(); + let ParsedRecord::Version1 { + state, + written_at_ms, + } = parse_record(&raw) + else { + panic!("new writer did not emit version 1: {raw:?}"); + }; + assert_eq!(state, st); + assert!((before..=after).contains(&written_at_ms)); + assert_eq!(raw, format!("{}\nv1 {written_at_ms}\n", st.as_str())); } } - #[test] - fn timestamped_status_keeps_the_old_reader_first_line() { - let tmp = tempfile::tempdir().unwrap(); - let sp = status_path(tmp.path()); - set_state_at(&sp, State::Available, at(1_786_741_730_761)).unwrap(); - - let raw = fs::read_to_string(&sp).unwrap(); - assert_eq!(raw.lines().next(), Some("available")); - assert_eq!( - State::parse_any(raw.lines().next().unwrap()), - Some(State::Available) - ); - } - #[test] fn unknown_is_not_settable() { assert!(State::parse_settable("unknown").is_none()); assert!(State::parse_settable("available").is_some()); assert!(State::parse_settable("bogus").is_none()); + + let tmp = tempfile::tempdir().unwrap(); + assert!(set_state(&status_path(tmp.path()), State::Unknown).is_err()); } #[test] @@ -293,125 +368,219 @@ mod tests { let sp = status_path(tmp.path()); fs::write(&sp, "garbage\n").unwrap(); assert_eq!(read_state(&sp), State::Offline); + } - fs::write(&sp, "available\nupdated-at-unix-ms nope\n").unwrap(); - assert_eq!(read_state(&sp), State::Offline); + #[test] + fn malformed_versioned_record_is_unknown_without_mtime_fallback() { + let tmp = tempfile::tempdir().unwrap(); + let sp = status_path(tmp.path()); + for raw in [ + "available\nv2 100\n", + "available\nv1 nope\n", + "available\nv1 100", + "available\nv1 100\nextra\n", + "available\n\n", + ] { + fs::write(&sp, raw).unwrap(); + fs::File::open(&sp) + .unwrap() + .set_modified(SystemTime::now()) + .unwrap(); + assert_eq!(read_state(&sp), State::Unknown, "raw: {raw:?}"); + } } #[test] - fn legacy_bare_word_uses_mtime_during_a_mixed_fleet_upgrade() { + fn literal_unknown_remains_derived_unknown() { let tmp = tempfile::tempdir().unwrap(); let sp = status_path(tmp.path()); - fs::write(&sp, "busy\n").unwrap(); - let old = SystemTime::now() - STATUS_STALE - Dur::from_secs(60); - let f = fs::File::open(&sp).unwrap(); - f.set_modified(old).unwrap(); + fs::write(&sp, "unknown\n").unwrap(); assert_eq!(read_state(&sp), State::Unknown); } #[test] - fn timestamped_content_controls_freshness_after_replication() { + fn version_1_uses_embedded_time_instead_of_file_mtime() { let tmp = tempfile::tempdir().unwrap(); let sp = status_path(tmp.path()); - let now = SystemTime::now(); - let old = SystemTime::now() - STATUS_STALE - Dur::from_secs(60); - - set_state_at(&sp, State::Available, now).unwrap(); - fs::File::open(&sp).unwrap().set_modified(old).unwrap(); - assert_eq!(read_state(&sp), State::Available); - - set_state_at(&sp, State::Busy, old).unwrap(); + let now_ms = crate::message::now_ms(); + write_record(&sp, State::Busy, now_ms).unwrap(); fs::File::open(&sp) .unwrap() - .set_modified(SystemTime::now()) + .set_modified(SystemTime::now() - STATUS_STALE - Dur::from_secs(60)) .unwrap(); - assert_eq!(read_state(&sp), State::Unknown); + assert_eq!(read_state(&sp), State::Busy); } #[test] - fn refresh_preserves_value_and_changes_the_replicated_bytes() { + fn version_1_staleness_and_future_skew_are_bounded() { + let now_ms = 2_000_000_u64; + let stale_ms = duration_ms(STATUS_STALE); + let skew_ms = duration_ms(STATUS_FUTURE_SKEW); + + for (written_at_ms, expected) in [ + (now_ms - stale_ms + 1, State::Away), + (now_ms - stale_ms, State::Unknown), + (now_ms + skew_ms, State::Away), + (now_ms + skew_ms + 1, State::Unknown), + ] { + let record = ParsedRecord::Version1 { + state: State::Away, + written_at_ms, + }; + assert_eq!(read_parsed_at(record, None, now_ms), expected); + } + } + + #[test] + fn legacy_record_uses_mtime_with_the_same_skew_bound() { let tmp = tempfile::tempdir().unwrap(); let sp = status_path(tmp.path()); - let first = at(1_786_741_000_000); - let second = first + STATUS_REFRESH; - set_state_at(&sp, State::Busy, first).unwrap(); - let before = fs::read_to_string(&sp).unwrap(); + fs::write(&sp, "busy\n").unwrap(); - assert_eq!(refresh_at(&sp, second), RefreshOutcome::Refreshed); + fs::File::open(&sp) + .unwrap() + .set_modified(SystemTime::now() - STATUS_STALE - Dur::from_secs(60)) + .unwrap(); + assert_eq!(read_state(&sp), State::Unknown); - let after = fs::read_to_string(&sp).unwrap(); - assert_ne!(after, before); - assert_eq!( - parse_record(&after).unwrap().updated_at_unix_ms, - Some(1_786_741_300_000) - ); - assert_eq!(read_state_at(&sp, second), State::Busy); + fs::File::open(&sp) + .unwrap() + .set_modified(SystemTime::now() + STATUS_FUTURE_SKEW + Dur::from_secs(60)) + .unwrap(); + assert_eq!(read_state(&sp), State::Unknown); } #[test] - fn writer_and_replica_carry_the_same_fresh_timestamp_while_idle() { + fn refresh_preserves_value_and_changes_heartbeat_bytes() { let tmp = tempfile::tempdir().unwrap(); - let writer = tmp.path().join("writer-status"); - let replica = tmp.path().join("replica-status"); - let first = at(1_786_741_000_000); - let second = first + STATUS_REFRESH; - set_state_at(&writer, State::Available, first).unwrap(); - fs::write(&replica, fs::read(&writer).unwrap()).unwrap(); - - assert_eq!(refresh_at(&writer, second), RefreshOutcome::Refreshed); - fs::write(&replica, fs::read(&writer).unwrap()).unwrap(); - - let writer_raw = fs::read_to_string(&writer).unwrap(); - let replica_raw = fs::read_to_string(&replica).unwrap(); - assert_eq!(replica_raw, writer_raw); - assert_eq!( - parse_record(&writer_raw).unwrap().updated_at_unix_ms, - Some(1_786_741_300_000) - ); - let observed = second + STATUS_REFRESH - Dur::from_millis(1); - assert_eq!(read_state_at(&writer, observed), State::Available); - assert_eq!(read_state_at(&replica, observed), State::Available); + let sp = status_path(tmp.path()); + write_record(&sp, State::Busy, 1).unwrap(); + assert_eq!(refresh(&sp), RefreshOutcome::Refreshed); + assert_eq!(read_state(&sp), State::Busy); + let ParsedRecord::Version1 { + state, + written_at_ms, + } = parse_record(&fs::read_to_string(&sp).unwrap()) + else { + panic!("refreshed record is not version 1"); + }; + assert_eq!(state, State::Busy); + assert!(written_at_ms > 1); } #[test] fn refresh_missing_writes_available_default() { let tmp = tempfile::tempdir().unwrap(); let sp = status_path(tmp.path()); - let now = at(1_786_741_730_761); - assert_eq!(refresh_at(&sp, now), RefreshOutcome::WroteDefault); - assert_eq!(read_state_at(&sp, now), State::Available); - assert_eq!( - fs::read_to_string(&sp).unwrap(), - "available\nupdated-at-unix-ms 1786741730761\n" - ); + assert_eq!(refresh(&sp), RefreshOutcome::WroteDefault); + assert_eq!(read_state(&sp), State::Available); + assert!(matches!( + parse_record(&fs::read_to_string(&sp).unwrap()), + ParsedRecord::Version1 { + state: State::Available, + .. + } + )); } #[test] fn refresh_leaves_dnd_to_age_out() { let tmp = tempfile::tempdir().unwrap(); let sp = status_path(tmp.path()); - let written_at = at(1_786_741_000_000); - set_state_at(&sp, State::Dnd, written_at).unwrap(); - let before = fs::read_to_string(&sp).unwrap(); + set_state(&sp, State::Dnd).unwrap(); + let before = fs::metadata(&sp).unwrap().modified().unwrap(); + std::thread::sleep(Dur::from_millis(5)); + assert_eq!(refresh(&sp), RefreshOutcome::LeftDnd); + assert_eq!(fs::metadata(&sp).unwrap().modified().unwrap(), before); + assert_eq!(read_state(&sp), State::Dnd); + } + + #[test] + fn refresh_upgrades_legacy_non_dnd_with_current_heartbeat() { + let tmp = tempfile::tempdir().unwrap(); + let sp = status_path(tmp.path()); + fs::write(&sp, "away\n").unwrap(); + + let before = crate::message::now_ms(); + assert_eq!(refresh(&sp), RefreshOutcome::Refreshed); + let after = crate::message::now_ms(); + let ParsedRecord::Version1 { + state, + written_at_ms, + } = parse_record(&fs::read_to_string(&sp).unwrap()) + else { + panic!("legacy record was not upgraded"); + }; + assert_eq!(state, State::Away); + assert!((before..=after).contains(&written_at_ms)); + } + + #[test] + fn refresh_upgrades_legacy_dnd_without_renewing_the_hold() { + let tmp = tempfile::tempdir().unwrap(); + let sp = status_path(tmp.path()); + fs::write(&sp, "dnd\n").unwrap(); + fs::File::open(&sp) + .unwrap() + .set_modified(SystemTime::now() - STATUS_STALE - Dur::from_secs(60)) + .unwrap(); + let legacy_timestamp = file_mtime_ms(&fs::File::open(&sp).unwrap()).unwrap(); + + assert_eq!(refresh(&sp), RefreshOutcome::Refreshed); + let raw = fs::read_to_string(&sp).unwrap(); assert_eq!( - refresh_at(&sp, written_at + STATUS_REFRESH), - RefreshOutcome::LeftDnd - ); - assert_eq!(fs::read_to_string(&sp).unwrap(), before); - assert_eq!(read_state_at(&sp, written_at), State::Dnd); - assert_eq!( - read_state_at(&sp, written_at + STATUS_STALE), - State::Unknown + parse_record(&raw), + ParsedRecord::Version1 { + state: State::Dnd, + written_at_ms: legacy_timestamp, + } ); + assert_eq!(read_state(&sp), State::Unknown); + assert_eq!(refresh(&sp), RefreshOutcome::LeftDnd); + assert_eq!(fs::read_to_string(&sp).unwrap(), raw); } #[test] fn refresh_leaves_corrupt_untouched() { let tmp = tempfile::tempdir().unwrap(); let sp = status_path(tmp.path()); - fs::write(&sp, "garbage\n").unwrap(); - assert_eq!(refresh(&sp), RefreshOutcome::LeftCorrupt); - assert_eq!(fs::read_to_string(&sp).unwrap(), "garbage\n"); // untouched + for raw in ["garbage\n", "available\nv1 nope\n"] { + fs::write(&sp, raw).unwrap(); + assert_eq!(refresh(&sp), RefreshOutcome::LeftCorrupt); + assert_eq!(fs::read_to_string(&sp).unwrap(), raw); + } + } + + #[test] + fn activity_uses_origin_time_and_clamps_only_allowed_future_skew() { + let now_ms = 2_000_000_u64; + let skew_ms = duration_ms(STATUS_FUTURE_SKEW); + + let record = ParsedRecord::Version1 { + state: State::Available, + written_at_ms: 1234, + }; + assert_eq!(activity_time_at(record, None, now_ms), Some(1234)); + assert_eq!( + activity_time_at(ParsedRecord::Legacy(State::Busy), Some(1234), now_ms), + Some(1234) + ); + + let bounded_future = ParsedRecord::Version1 { + state: State::Available, + written_at_ms: now_ms + skew_ms, + }; + assert_eq!(activity_time_at(bounded_future, None, now_ms), Some(now_ms)); + + let excessive_future = ParsedRecord::Version1 { + state: State::Available, + written_at_ms: now_ms + skew_ms + 1, + }; + assert_eq!(activity_time_at(excessive_future, None, now_ms), None); + assert_eq!( + activity_time_at(ParsedRecord::Malformed, None, now_ms), + None + ); } } diff --git a/tests/catalog_apply.rs b/tests/catalog_apply.rs index 1cc2e187..ae426c68 100644 --- a/tests/catalog_apply.rs +++ b/tests/catalog_apply.rs @@ -2193,7 +2193,7 @@ fn marker_time_state_routes_existing_orphans_but_never_flat_falls_back_for_new_a .is_some() ); let presence = fs::read_to_string(old.join("status")).unwrap(); - assert!(presence.starts_with("busy\nupdated-at-unix-ms ")); + assert!(presence.starts_with("busy\nv1 ")); assert_eq!(presence.lines().count(), 2); let phantom = send(&catalog, "host.new", "too early"); @@ -2460,7 +2460,7 @@ fn marker_time_status_write_remains_bound_to_its_retained_agent_capability() { String::from_utf8_lossy(&state.stderr) ); let presence = fs::read_to_string(retained_host.join("old/status")).unwrap(); - assert!(presence.starts_with("busy\nupdated-at-unix-ms ")); + assert!(presence.starts_with("busy\nv1 ")); assert_eq!(presence.lines().count(), 2); assert!(!outside.join("old/status").exists()); fs::remove_file(catalog.join("agents/host")).unwrap(); diff --git a/tests/status_agents.rs b/tests/status_agents.rs index 1c5a8e93..c11e6e12 100644 --- a/tests/status_agents.rs +++ b/tests/status_agents.rs @@ -8,7 +8,7 @@ use std::fs; use std::os::unix::fs::PermissionsExt; use std::path::Path; use std::process::Command; -use std::time::{Duration, SystemTime}; +use std::time::Duration; use st2::agents::roster; use st2::message::send_to_inbox; @@ -304,11 +304,7 @@ fn exact_identity_rejects_duplicates_before_status_filtering() { "declarations/two/agent.kdl", &agent_kdl("worker", "h"), ); - set_state( - &status_path(&root.join("declarations/one")), - State::Busy, - ) - .unwrap(); + set_state(&status_path(&root.join("declarations/one")), State::Busy).unwrap(); set_state( &status_path(&root.join("declarations/two")), State::Available, @@ -438,20 +434,82 @@ agent "two" { assert_eq!(selected[0]["name"], "Second Agent Spec"); } -/// A legacy status file older than the stale window projects as `unknown` in the roster. +/// A version 1 heartbeat older than the stale window projects as `unknown` in the roster. #[test] -fn roster_derives_unknown_from_a_stale_status() { +fn roster_derives_unknown_from_a_stale_version_1_heartbeat() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); write(root, "hetz/idle/agent.kdl", &agent_kdl("idle", "hetz")); let sp = status_path(&root.join("hetz/idle")); - fs::write(&sp, "available\n").unwrap(); + set_state(&sp, State::Available).unwrap(); // Fresh → available. assert_eq!(roster(root, "hetz")[0].status, State::Available); - // Backdate the legacy status file past the stale window → unknown. - let old = SystemTime::now() - st2::status::STATUS_STALE - Duration::from_secs(60); - fs::File::open(&sp).unwrap().set_modified(old).unwrap(); + // Backdate the embedded heartbeat past the stale window → unknown. + let stale_ms = st2::message::now_ms() + - u64::try_from((st2::status::STATUS_STALE + Duration::from_secs(60)).as_millis()).unwrap(); + fs::write(&sp, format!("available\nv1 {stale_ms}\n")).unwrap(); assert_eq!(roster(root, "hetz")[0].status, State::Unknown); } + +#[test] +fn roster_uses_version_1_origin_time_for_last_activity() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write(root, "hetz/idle/agent.kdl", &agent_kdl("idle", "hetz")); + let sp = status_path(&root.join("hetz/idle")); + let heartbeat_ms = st2::message::now_ms() - 1_000; + fs::write(&sp, format!("available\nv1 {heartbeat_ms}\n")).unwrap(); + + let row = &roster(root, "hetz")[0]; + assert_eq!(row.status, State::Available); + assert_eq!(row.last_activity_ms, Some(heartbeat_ms as f64)); +} + +#[test] +fn status_cli_writes_and_reads_the_version_1_record() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write(root, "h/worker/agent.kdl", &agent_kdl("worker", "h")); + let before = st2::message::now_ms(); + + let set = Command::new(env!("CARGO_BIN_EXE_st2")) + .args(["status", "h.worker", "--set", "busy", "--root"]) + .arg(root) + .args(["--host", "h"]) + .output() + .unwrap(); + assert!( + set.status.success(), + "{}", + String::from_utf8_lossy(&set.stderr) + ); + assert_eq!(String::from_utf8(set.stdout).unwrap(), "status: busy\n"); + + let after = st2::message::now_ms(); + let raw = fs::read_to_string(status_path(&root.join("h/worker"))).unwrap(); + let lines: Vec<&str> = raw.lines().collect(); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0], "busy"); + let timestamp = lines[1] + .strip_prefix("v1 ") + .unwrap() + .parse::() + .unwrap(); + assert!((before..=after).contains(×tamp)); + assert!(raw.ends_with('\n')); + + let get = Command::new(env!("CARGO_BIN_EXE_st2")) + .args(["status", "h.worker", "--root"]) + .arg(root) + .args(["--host", "h"]) + .output() + .unwrap(); + assert!( + get.status.success(), + "{}", + String::from_utf8_lossy(&get.stderr) + ); + assert_eq!(String::from_utf8(get.stdout).unwrap(), "busy\n"); +} From 0fabb77a20d7308f1232984ef0c37fbbbb7b33c4 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Sun, 16 Aug 2026 11:21:37 +0200 Subject: [PATCH 54/56] Name the session owner in presence spec --- docs/vrs/spec.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/vrs/spec.md b/docs/vrs/spec.md index 0bdb21cd..f92a54fb 100644 --- a/docs/vrs/spec.md +++ b/docs/vrs/spec.md @@ -787,8 +787,8 @@ context record. #### DND behavior -A fresh `dnd` record suppresses DING delivery. The sidecar leaves its timestamp -unchanged, so an abandoned hold ages out. +A fresh `dnd` record suppresses DING delivery. The session owner leaves its +timestamp unchanged, so an abandoned hold ages out. A stale or invalid DND record does not suppress delivery. It reads as `unknown`, which preserves the existing fresh-DND rule. @@ -807,8 +807,8 @@ upgrades its legacy record at its next five-minute refresh. A version 1 session owner upgrades a legacy DND record once. It uses the legacy mtime as the embedded timestamp, so the migration cannot renew the hold. -After that migration, the sidecar does not refresh DND. If the legacy mtime is -unavailable, the sidecar leaves the record unchanged. +After that migration, the session owner does not refresh DND. If the legacy +mtime is unavailable, the session owner leaves the record unchanged. A malformed two-line record is not legacy. Readers must not hide a bad version 1 record behind the legacy mtime fallback. From cbfaa0fee84fab9481ce92329a4a8cbef0f5460f Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Sun, 16 Aug 2026 11:42:31 +0200 Subject: [PATCH 55/56] Rename the Claude MCP driver --- src/driver.rs | 4 ++-- src/hooks.rs | 2 +- src/main.rs | 8 ++++--- tests/driver_expansion.rs | 35 ++++++++++++++++++++++++++-- tests/fixtures/driver/claude.out.kdl | 2 +- 5 files changed, 42 insertions(+), 9 deletions(-) diff --git a/src/driver.rs b/src/driver.rs index b180da46..d4e869f9 100644 --- a/src/driver.rs +++ b/src/driver.rs @@ -82,7 +82,7 @@ fn expand_claude(driver: &ClaudeDriver, bus_id: &str) -> Result { "--catalog", CATALOG, "driver", - "claude", + "claude-mcp", "--identity", bus_id ] @@ -245,7 +245,7 @@ mod tests { "--catalog", "$CATALOG", "driver", - "claude", + "claude-mcp", "--identity", "host.worker" ]) diff --git a/src/hooks.rs b/src/hooks.rs index 954afeea..596099cb 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -490,7 +490,7 @@ mod tests { "--catalog".into(), "/catalog".into(), "driver".into(), - "claude".into(), + "claude-mcp".into(), ], root )); diff --git a/src/main.rs b/src/main.rs index 68b7bf9e..59b8ae84 100644 --- a/src/main.rs +++ b/src/main.rs @@ -324,8 +324,10 @@ enum DriverCmd { #[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)] argv: Vec, }, - /// Run the existing Claude session-owned MCP server over stdio. - Claude { + /// Run the Claude session-owned MCP server over stdio. + // Keep the hidden alias until old Claude sessions can no longer restart their MCP child. + #[command(name = "claude-mcp", alias = "claude")] + ClaudeMcp { #[arg(long)] identity: String, }, @@ -902,7 +904,7 @@ fn main() -> Result<()> { let catalog = catalog.canonicalize().unwrap_or(catalog); st2::codex_app_server::run_controlled(&catalog, identity, runtime_id, argv) } - Command::Driver(DriverCmd::Claude { identity }) => { + Command::Driver(DriverCmd::ClaudeMcp { identity }) => { let catalog = catalog_arg(None)?; let catalog = catalog.canonicalize().unwrap_or(catalog); st2::claude_mcp::run(&catalog, &identity) diff --git a/tests/driver_expansion.rs b/tests/driver_expansion.rs index ae9d3953..c17f3946 100644 --- a/tests/driver_expansion.rs +++ b/tests/driver_expansion.rs @@ -69,7 +69,7 @@ fn cli_prints_each_snapshot_without_changing_its_input() { } #[test] -fn claude_driver_matches_deliver_after_normalizing_only_the_subcommand_alias() { +fn claude_driver_matches_deliver_after_normalizing_the_legacy_command_namespace() { let temp = tempfile::tempdir().unwrap(); let catalog = temp.path().join("catalog"); let legacy_workspace = temp.path().join("legacy-workspace"); @@ -170,11 +170,42 @@ fn claude_driver_matches_deliver_after_normalizing_only_the_subcommand_alias() { let args = driver_mcp["mcpServers"]["st2"]["args"] .as_array_mut() .unwrap(); - assert_eq!(&args[2..4], ["driver", "claude"]); + assert_eq!(&args[2..4], ["driver", "claude-mcp"]); args.splice(2..4, [serde_json::Value::String("claude-mcp".into())]); assert_eq!(driver_mcp, legacy_mcp); } +#[test] +fn claude_mcp_is_canonical_and_claude_is_a_hidden_alias() { + let help = Command::new(env!("CARGO_BIN_EXE_st2")) + .args(["driver", "--help"]) + .output() + .unwrap(); + assert!(help.status.success()); + let help = String::from_utf8(help.stdout).unwrap(); + assert!( + help.lines() + .any(|line| line.trim_start().starts_with("claude-mcp ")) + ); + assert!( + !help + .lines() + .any(|line| line.trim_start().starts_with("claude ")) + ); + + for command in ["claude-mcp", "claude"] { + let output = Command::new(env!("CARGO_BIN_EXE_st2")) + .args(["driver", command, "--help"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{command}: {}", + String::from_utf8_lossy(&output.stderr) + ); + } +} + #[test] fn legacy_claude_shell_launch_keeps_its_source_under_the_session_wrapper() { let temp = tempfile::tempdir().unwrap(); diff --git a/tests/fixtures/driver/claude.out.kdl b/tests/fixtures/driver/claude.out.kdl index d77d7798..7cb3605c 100644 --- a/tests/fixtures/driver/claude.out.kdl +++ b/tests/fixtures/driver/claude.out.kdl @@ -1,4 +1,4 @@ render { - json-upsert .mcp.json "{\n \"mcpServers\": {\n \"st2\": {\n \"args\": [\n \"--catalog\",\n \"$CATALOG\",\n \"driver\",\n \"claude\",\n \"--identity\",\n \"Silber.fabric\"\n ],\n \"command\": \"st2\",\n \"type\": \"stdio\"\n }\n }\n}" + json-upsert .mcp.json "{\n \"mcpServers\": {\n \"st2\": {\n \"args\": [\n \"--catalog\",\n \"$CATALOG\",\n \"driver\",\n \"claude-mcp\",\n \"--identity\",\n \"Silber.fabric\"\n ],\n \"command\": \"st2\",\n \"type\": \"stdio\"\n }\n }\n}" } argv st2 --catalog $CATALOG driver claude-session --identity Silber.fabric --runtime-id Silber.fabric -- claude --model opus --effort xhigh "--dangerously-load-development-channels=server:st2" --permission-mode bypassPermissions --model override "Start the assigned work." From df9c73bf5b41499afaf34d8778db3b3db2f9f24e Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Sun, 16 Aug 2026 11:54:33 +0200 Subject: [PATCH 56/56] Warn on the deprecated Claude driver name --- src/main.rs | 15 +++++++++++++-- tests/driver_expansion.rs | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 59b8ae84..508a7158 100644 --- a/src/main.rs +++ b/src/main.rs @@ -325,12 +325,17 @@ enum DriverCmd { argv: Vec, }, /// Run the Claude session-owned MCP server over stdio. - // Keep the hidden alias until old Claude sessions can no longer restart their MCP child. - #[command(name = "claude-mcp", alias = "claude")] ClaudeMcp { #[arg(long)] identity: String, }, + /// Deprecated name for the Claude MCP server. + // Keep this hidden command until no rendered configuration uses the old name. + #[command(hide = true)] + Claude { + #[arg(long)] + identity: String, + }, /// Run Claude under the session-owned presence wrapper. ClaudeSession { #[arg(long)] @@ -909,6 +914,12 @@ fn main() -> Result<()> { let catalog = catalog.canonicalize().unwrap_or(catalog); st2::claude_mcp::run(&catalog, &identity) } + Command::Driver(DriverCmd::Claude { identity }) => { + eprintln!("warning: `st2 driver claude` is deprecated; use `st2 driver claude-mcp`"); + let catalog = catalog_arg(None)?; + let catalog = catalog.canonicalize().unwrap_or(catalog); + st2::claude_mcp::run(&catalog, &identity) + } Command::Driver(DriverCmd::ClaudeSession { identity, runtime_id, diff --git a/tests/driver_expansion.rs b/tests/driver_expansion.rs index c17f3946..609956f2 100644 --- a/tests/driver_expansion.rs +++ b/tests/driver_expansion.rs @@ -204,6 +204,25 @@ fn claude_mcp_is_canonical_and_claude_is_a_hidden_alias() { String::from_utf8_lossy(&output.stderr) ); } + + let temp = tempfile::tempdir().unwrap(); + let old = Command::new(env!("CARGO_BIN_EXE_st2")) + .arg("--catalog") + .arg(temp.path()) + .args(["driver", "claude", "--identity", "missing"]) + .output() + .unwrap(); + let old_error = String::from_utf8(old.stderr).unwrap(); + assert!(old_error.contains("`st2 driver claude` is deprecated")); + + let current = Command::new(env!("CARGO_BIN_EXE_st2")) + .arg("--catalog") + .arg(temp.path()) + .args(["driver", "claude-mcp", "--identity", "missing"]) + .output() + .unwrap(); + let current_error = String::from_utf8(current.stderr).unwrap(); + assert!(!current_error.contains("deprecated")); } #[test]