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/INVARIANTS.md b/INVARIANTS.md index feeef1bb..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 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` | +| **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 812033f9..577a0bc5 100644 --- a/README.md +++ b/README.md @@ -475,9 +475,15 @@ 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. 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 or restart, the sidecar first adopts an exact staged recovery/backlog notice when present, then sends 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/kdl_format.rs b/crates/agent-spec/src/kdl_format.rs index 6540668e..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> { @@ -124,6 +124,37 @@ 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"))?, + )); + } + "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) { @@ -148,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 e9824715..5b7697b5 100644 --- a/crates/agent-spec/src/lib.rs +++ b/crates/agent-spec/src/lib.rs @@ -39,10 +39,11 @@ 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, 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 a0cf9a29..faa9fe75 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`, 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 @@ -36,6 +37,74 @@ 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`)" + ), + } + } +} + +/// One typed harness driver declaration. +/// +/// st2 expands this field into inspectable Agent Spec KDL before task and render compilation. +#[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 { @@ -91,6 +160,10 @@ 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, + /// 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. pub resources: Vec, @@ -319,14 +392,25 @@ 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 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.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. + pub fn has_delivery_transport(&self) -> bool { + self.driver.is_some() + || 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 +483,12 @@ 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>, + /// 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. @@ -409,6 +499,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); @@ -750,6 +860,9 @@ impl RawSpec { || self.command.is_some() || 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() @@ -778,13 +891,26 @@ 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()?; + 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" + ); validate_launch( &identity, self.command.as_ref(), 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" ); @@ -799,7 +925,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 { @@ -850,6 +976,8 @@ impl RawSpec { desired_state, 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 af08b9a3..1ab097e2 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::{TaskKind, TaskLifecycle}; +use agent_spec::spec::{ + ClaudeDriver, CodexDriver, DeliveryTransport, Driver, TaskKind, TaskLifecycle, +}; use agent_spec::{ AgentDesiredState, AgentSpec, JobType, Resource, Task, discover, discover_strict, }; @@ -139,10 +141,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 +331,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] @@ -462,6 +547,138 @@ 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"] { + 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()), + 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"] { + let spec = find(&found.specs, identity); + assert_eq!(spec.driver.as_ref(), Some(&codex)); + assert!(!spec.is_runnable()); + } +} + +#[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/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). diff --git a/docs/vrs/spec.md b/docs/vrs/spec.md index 3a687e0b..f92a54fb 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 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. + +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 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. + +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/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, 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 (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; + loop { + 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/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 new file mode 100644 index 00000000..c741248b --- /dev/null +++ b/src/codex_app_server.rs @@ -0,0 +1,4357 @@ +//! 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 notification or successful resume response to the exact +//! 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. + +use std::collections::BTreeMap; +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::os::unix::process::CommandExt as _; +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, SystemTime, UNIX_EPOCH}; + +use anyhow::{Context as _, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; +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"; +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 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); +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 { + 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 + } +} + +/// 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, +} + +#[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, 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, + observed: CodexObservedState, +} + +struct CodexInboxDelivery { + config: CodexDeliveryConfig, + state_path: PathBuf, + runtime: CodexRuntime, + wake: Receiver<()>, + _watcher: Option, + next_inbox_refresh: Instant, + next_presence_refresh: Instant, + head: Option, + suppressed: bool, + state: Option, + pending: Option, + rejected: Option, + next_request_id: u64, +} + +impl CodexInboxDelivery { + fn new( + config: CodexDeliveryConfig, + state_path: PathBuf, + runtime: CodexRuntime, + ) -> 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); + let state = load_delivery_state(&state_path, &config.identity, runtime.runtime_id())?; + Ok(Self { + config, + state_path, + runtime, + wake, + _watcher: watcher, + next_inbox_refresh: Instant::now(), + next_presence_refresh: Instant::now(), + head: None, + suppressed: false, + 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 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(()); + } + let unread = message::list_inbox(&self.config.inbox)?; + 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() + .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_inbox_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); + } + 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.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 filename = head.filename.clone(); + 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.write_state(CodexDeliveryState::attempted( + &self.runtime, + state.thread_id().to_string(), + filename.clone(), + client_id, + ))?; + self.pending = Some(PendingCodexDelivery { + request_id, + filename, + 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() { + 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(), + }); + 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; + 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 { + 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, +} + +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: reason @ (CodexHoldReason::Review | CodexHoldReason::Compaction), + .. + } => CodexObservedState::Held { + reason: *reason, + turn_id: Some(turn_id), + }, + CodexObservedState::Active { .. } + | CodexObservedState::Held { + reason: 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, + .. + } => self.observed.clone(), + 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, + .. + } if current_reason == &reason + && matches!( + reason, + CodexHoldReason::Review | CodexHoldReason::Compaction + ) => + { + 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, + }, + }; + } +} + +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, + 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 delivery = CodexDeliveryConfig::resolve(catalog_root, &identity)?; + + 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)?; + + 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)?; + diagnostics.record( + "runtimePublished", + json!({ + "runtimeIncarnation": runtime.incarnation(), + "resumeSelected": resume_thread.is_some(), + }), + )?; + + 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_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 = Command::new(&codex_argv[0]); + server_command + .args(server_args) + .stdin(Stdio::null()) + .stdout(log.try_clone()?) + .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() })) + .and_then(|_| { + run_connected( + &mut server, + &socket_path, + &runtime, + &codex_argv, + resume_thread.as_deref(), + delivery, + diagnostics, + ) + }); + terminate_process_group(&mut server); + let _ = fs::remove_file(&socket_path); + result +} + +fn run_connected( + server: &mut Child, + socket_path: &Path, + 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 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"); + 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 || { + 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, + resume, + Some(delivery), + events_tx, + ) + }); + + // A fresh initialized observer reads before this child can issue thread/start. A resumed + // 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]); + tui_command.args(tui_args); + let mut tui = match tui_command + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + { + 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, diagnostics).and_then(|_| { + diagnostics.record("threadBound", json!({ "pid": tui.id() }))?; + monitor_bound_tui(&mut tui, &events_rx) + }) + })(); + if result.is_err() { + terminate_child(&mut tui); + } + drop(resume_ready_tx); + let _ = shutdown.shutdown(Shutdown::Both); + let _ = event_thread.join(); + 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 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")) +} + +/// 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 = Command::new(codex); + server_command + .args(server_args) + .stdin(Stdio::null()) + .stdout(log.try_clone()?) + .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() })) + .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_process_group(&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], + 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) +} + +/// 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 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 +/// 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(index); + } + if !argument.starts_with('-') || argument == "-" { + return Ok(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(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 '{}'", + 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, + 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) +} + +/// 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 { + eprintln!("codex control: requesting TUI-loaded thread list"); + 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 >= Duration::from_millis(1), + "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) + .context("polling Codex TUI-loaded response")? + { + 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 { + TuiThreadLoaded(Sender<()>), + Bound, + Observed, + Closed, + 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>, + delivery: Option, + events: Sender, +) { + let result = (|| -> Result<()> { + 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; + let mut peer_closed = false; + 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() + .context("initializing Codex inbox delivery")?; + 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) + .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")?; + diagnostic_rx + .recv() + .context("waiting for the Codex TUI-loaded diagnostic before control resume")?; + write_json_message( + &mut websocket, + &json!({ + "method": "thread/resume", + "id": CONTROL_SUBSCRIBE_REQUEST_ID, + "params": { "threadId": thread_id } + }), + ) + .context("sending Codex thread resume request")?; + 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, + 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) + .context("sending Codex delivery request")?; + } + continue; + }; + if control_state.is_none() { + 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).context("accepting Codex resume subscription")? { + SubscriptionAcceptance::Accepted { .. } => { + if let Some(delivery) = delivery.as_mut() { + delivery.reconcile_resume(&message, &bound).context("reconciling Codex resume delivery")?; + } + } + 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()), + ) + .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).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).context("persisting Codex fresh control state")?; + control_state = Some(bound); + let _ = events.send(ControlEvent::Bound); + } + + let state = control_state + .as_mut() + .context("Codex control state is unbound")?; + let delivery_response = match delivery.as_mut() { + Some(delivery) => { + delivery.accept_response(&message, &state.observed).context("accepting Codex delivery response")? + || delivery.accept_typed_receipt(&message, state).context("accepting Codex typed receipt")? + } + 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!( + subscription_pending, + "Codex control received an unexpected thread/resume response" + ); + subscription_pending = false; + match state.accept_subscription(&message).context("accepting Codex subscription")? { + SubscriptionAcceptance::Accepted { changed } => { + if let Some(delivery) = delivery.as_mut() { + delivery.reconcile_resume(&message, state).context("reconciling Codex subscription delivery")?; + } + changed + } + SubscriptionAcceptance::Deferred => false, + } + } else { + state.observe(&message).context("observing Codex control event")? + }; + if changed { + atomic_json(control_state_path, state).context("persisting Codex observed control state")?; + let _ = events.send(ControlEvent::Observed); + } + if !state.subscribed + && !subscription_pending + && subscription_candidate(&message, state.thread_id()) + { + write_json_message( + &mut websocket, + &json!({ + "method": "thread/resume", + "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) + .context("sending Codex delivery request")?; + } + } + })(); + if let Err(error) = result { + let _ = events.send(ControlEvent::Failed(format!("{error:#}"))); + } +} + +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(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(Some(thread_id)) + } + _ => Ok(None), + } +} + +fn wait_for_binding( + tui: &mut Child, + events: &Receiver, + timeout: Duration, + diagnostics: &mut WrapperDiagnostics, +) -> 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::TuiThreadLoaded(acknowledge)) => { + diagnostics.record("tuiThreadLoaded", json!({ "pid": tui.id() }))?; + let _ = acknowledge.send(()); + } + Ok(ControlEvent::Bound) => return Ok(()), + Ok(ControlEvent::Observed) => {} + 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::TuiThreadLoaded(acknowledge)) => { + let _ = acknowledge.send(()); + } + Ok(ControlEvent::Bound) => {} + Ok(ControlEvent::Observed) => {} + 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!( + SUPPORTED_CODEX_CLI_VERSIONS.contains(&actual.as_str()), + "unsupported Codex app-server protocol version '{actual}' (expected one of: {})", + SUPPORTED_CODEX_CLI_VERSIONS.join(", ") + ); + 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)) +} + +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, + 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(WebSocketMessage::Text(value.to_string().into()))?; + Ok(()) +} + +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 { + WebSocketMessage::Text(text) => { + let value = serde_json::from_str(&text) + .context("decoding Codex app-server WebSocket JSON")?; + return Ok(Some(value)); + } + 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") + } + } + } +} + +/// Spawn a non-interactive provider launcher as the leader of an isolated process group. +/// Its native descendants inherit this group, so cleanup cannot strand a listening process. +fn spawn_process_group(command: &mut Command) -> 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(_)) => {} + _ => { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + 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(); + 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") + ); + } + + #[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 { + 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 + } + + 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() + } + + fn acknowledge_tui_thread_loaded(events: &Receiver) { + let ControlEvent::TuiThreadLoaded(acknowledge) = + events.recv_timeout(Duration::from_secs(10)).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( + 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 = inbox_delivery(tmp.path(), config.clone()); + 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_inbox_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 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); + 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)) + .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!( + std::fs::read_to_string(&presence) + .unwrap() + .contains("\nv1 ") + ); + 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(); + let config = delivery_config(tmp.path()); + let filename = + message::send_to_inbox(&config.inbox, "h.sender", Some("retry"), None, &[], "body") + .unwrap(); + let mut delivery = inbox_delivery(tmp.path(), config.clone()); + 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_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( + &config.inbox, + "h.sender", + Some("submitted"), + None, + &[], + "body", + ) + .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( + &json!({ "id": request["id"], "result": { "turn": { "id": "turn-new" } } }), + idle.observed(), + ) + .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_inbox_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(); + 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 + // 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!( + 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(); + 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"); + let head_id = server_filename + .trim_end_matches(".md") + .rsplit_once('-') + .unwrap() + .1; + assert!( + delivery["params"]["input"][0]["text"] + .as_str() + .unwrap() + .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) + ); + let client_id = delivery["params"]["clientUserMessageId"] + .as_str() + .unwrap() + .to_string(); + write_json_message( + &mut websocket, + &json!({ + "id": FIRST_DELIVERY_REQUEST_ID, + "result": { "turn": { "id": "turn-delivery" } } + }), + ) + .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(); + 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(10)).unwrap(), + ControlEvent::Bound + )); + server.join().unwrap(); + 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" + ); + 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( + &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 (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(); + let pump = thread::spawn(move || { + pump_control( + websocket, + &binding_for_pump, + &control_state_for_pump, + &runtime_for_pump, + Some(ControlResume { + thread_id: "thread-main", + ready: resume_ready_rx, + tui_loaded_timeout: TUI_LOADED_TIMEOUT, + }), + Some(config), + tx, + ) + }); + acknowledge_tui_thread_loaded(&rx); + assert!(matches!( + rx.recv_timeout(Duration::from_secs(10)).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] + 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", "status": { "type": "idle" } } } + }), + ) + .unwrap(); + write_json_message( + &mut websocket, + &json!({ + "method": "thread/status/changed", + "params": { "threadId": "thread-main", "status": { "type": "idle" } } + }), + ) + .unwrap(); + // JSON-RPC request IDs are per direction. A server request may reuse the client's + // subscription ID and must not be consumed as a client response. + write_json_message( + &mut websocket, + &json!({ + "id": CONTROL_SUBSCRIBE_REQUEST_ID, + "method": "item/commandExecution/requestApproval", + "params": {} + }), + ) + .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(); + }); + + 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 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, + &control_state_for_pump, + &runtime_for_pump, + None, + None, + tx, + ) + }); + 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(); + + let binding = load_current_binding(&binding_path, &runtime) + .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] + 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(); + 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"); + 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"); + assert!(matches!( + poll_json_message(&mut websocket).unwrap(), + 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!({ + "method": "thread/started", + "params": { + "thread": { "id": "thread-unrelated", "status": { "type": "idle" } } + } + }), + ) + .unwrap(); + write_json_message( + &mut websocket, + &json!({ + "method": "thread/status/changed", + "params": { + "threadId": "thread-unrelated", + "status": { "type": "active", "activeFlags": [] } + } + }), + ) + .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"); + 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 (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(ControlResume { + thread_id: "thread-prior", + ready: resume_ready_rx, + tui_loaded_timeout: TUI_LOADED_TIMEOUT, + }), + None, + tx, + ) + }); + pre_gate_checked_rx + .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 + )); + 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"); + let state = load_current_control_state(&control_state_path, &runtime, &binding) + .unwrap() + .unwrap(); + assert!(state.subscribed()); + 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(); + 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 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"); + 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(ControlResume { + thread_id: "thread-prior", + ready: resume_ready_rx, + tui_loaded_timeout: TUI_LOADED_TIMEOUT, + }), + None, + tx, + ) + }); + 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"); + }; + 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(); + 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 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::Review, + turn_id: Some("turn-2".into()), + } + ); + + // 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!({ + "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", + "params": { "threadId": "thread-main", "status": { "type": "idle" } } + })) + .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", + "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()), + } + ); + 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] + 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 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 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() + ]) + .unwrap() + ); + assert!( + !authored_bypasses_hook_trust(&["--".into(), "--dangerously-bypass-hook-trust".into()]) + .unwrap() + ); + } + + #[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 = + 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")); + + 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", + &[ + "--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()]; + 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" + ] + ); + assert_eq!( + expected_resume_thread( + &["resume".into(), "thread-explicit".into()], + Some("thread-prior") + ) + .unwrap(), + None + ); + + 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" + ] + ); + 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] + 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 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(); + 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/ding/mod.rs b/src/ding/mod.rs index d578e111..05d60ef3 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\nv1 {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 new file mode 100644 index 00000000..d4e869f9 --- /dev/null +++ b/src/driver.rs @@ -0,0 +1,278 @@ +//! 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. +//! Print, reconcile, and materialization use this same expansion. + +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"; + +/// 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 { + ensure_single_source(spec)?; + 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-mcp", + "--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 provider = vec!["claude".to_string()]; + if let Some(model) = &driver.model { + provider.extend(["--model".to_string(), model.clone()]); + } + if let Some(effort) = &driver.effort { + provider.extend(["--effort".to_string(), effort.clone()]); + } + if driver.dev_channels { + provider.push("--dangerously-load-development-channels=server:st2".to_string()); + } + 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)])) +} + +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_a_channel_render_and_session_owned_launch() { + 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-mcp", + "--identity", + "host.worker" + ]) + ); + assert_eq!( + strings(output.get("argv").unwrap()), + [ + "st2", + "--catalog", + "$CATALOG", + "driver", + "claude-session", + "--identity", + "host.worker", + "--runtime-id", + "host.worker", + "--", + "claude", + "--model", + "opus", + "--effort", + "xhigh", + "--dangerously-load-development-channels=server:st2", + "--model", + "override", + "Start work." + ] + ); + } +} diff --git a/src/eval_run.rs b/src/eval_run.rs index 9d1a3923..83d5e407 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}; @@ -117,6 +119,8 @@ pub fn spec_to_agent_specs(agents: &[SpecAgent], host: &str, root: &Path) -> Vec desired_state: AgentDesiredState::Running, keep: false, restart: None, + delivery: None, + driver: None, resources: Vec::new(), tasks, path: path.clone(), @@ -128,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, } @@ -164,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. @@ -224,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); @@ -237,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_ding_tasks(&mut specs, host, task_context)?; + 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()) @@ -1554,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::>(), @@ -1564,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(); @@ -1596,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/hooks.rs b/src/hooks.rs index 33db5f52..596099cb 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -136,21 +136,24 @@ 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, 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-mcp".into(), + ], + root + )); } #[test] diff --git a/src/lib.rs b/src/lib.rs index 4eab71f6..dc13a6a5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,8 +10,12 @@ pub mod agents; 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; +pub mod driver; pub mod eval_run; pub mod eval_spec; pub mod exec_backend; @@ -40,10 +44,12 @@ 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, 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/main.rs b/src/main.rs index de118588..508a7158 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 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 /// 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 @@ -112,6 +115,25 @@ 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, + }, + /// 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 { @@ -280,6 +302,51 @@ 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)] + identity: String, + #[arg(long)] + runtime_id: String, + #[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)] + argv: Vec, + }, + /// Run the Claude session-owned MCP server over stdio. + 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)] + identity: String, + #[arg(long)] + runtime_id: String, + #[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)] + argv: Vec, + }, +} + #[derive(Subcommand)] enum AgentCmd { /// Author reversible whole-agent lifecycle intent in one canonical KDL declaration. @@ -818,6 +885,54 @@ 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::ClaudeMcp { identity } => { + let catalog = catalog_arg(None)?; + 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::ClaudeMcp { identity }) => { + let catalog = catalog_arg(None)?; + 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, + 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()) + } Command::Status { identity, set, ctx } => status_cmd(identity, set, ctx), Command::Rename(args) => presentation_cmd(st2::agent_author::PresentationField::Name, args), Command::Describe(args) => { @@ -1049,6 +1164,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 { @@ -1476,6 +1628,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`, `deliver`, or a driver block; agent receives no DING", + ); + } for task in &spec.tasks { let id = task .id @@ -1495,7 +1653,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); @@ -1503,7 +1661,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?", ); } } @@ -1620,6 +1778,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, @@ -2662,7 +2824,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. @@ -2911,11 +3073,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/materialize.rs b/src/materialize.rs index aaca96ec..0aadbe84 100644 --- a/src/materialize.rs +++ b/src/materialize.rs @@ -235,6 +235,86 @@ 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)?; + let mut generated = RenderPlan::default(); + for render in expansion + .nodes() + .iter() + .filter(|node| node.name().value() == "render") + { + generated + .ops + .extend(parse_render_node(render, &spec.identity)?.ops); + } + resolve_driver_render_executable(&mut generated, &spec.identity)?; + plan.ops.extend(generated.ops); + Ok(plan) +} + +fn resolve_driver_render_executable(plan: &mut RenderPlan, agent: &str) -> 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)?; + 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,7 +325,7 @@ pub(crate) fn catalog_owned_render_inputs( spec: &AgentSpec, this_host: &str, ) -> Result> { - let plan = parse_plan(spec)?; + 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(); @@ -506,7 +586,7 @@ fn claims_for_agent( spec: &AgentSpec, this_host: &str, ) -> Result>> { - let plan = parse_plan(spec)?; + let plan = effective_plan(root, spec, this_host)?; if plan.ops.is_empty() { return Ok(BTreeMap::new()); } @@ -618,7 +698,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 plan = parse_plan(spec)?; + let plan = effective_plan(root, spec, this_host)?; if plan.ops.is_empty() { return Ok(Vec::new()); } @@ -822,7 +902,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/src/reconcile.rs b/src/reconcile.rs index 4688778a..5c95f125 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, 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)] @@ -73,6 +74,194 @@ 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<()> { + for spec in specs.iter() { + 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 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(()) +} + +/// 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" + ); + + 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 + .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(()) +} + +/// 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 @@ -122,6 +311,90 @@ 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.driver.is_some() { + continue; + } + 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 7e1324b8..064e60f8 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,11 +1514,11 @@ 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 - .push(format!("compile generated DING tasks (pass skipped): {error:#}")); + .push(format!("compile generated tasks (pass skipped): {error:#}")); return report; } @@ -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}"))?; @@ -2280,6 +2280,8 @@ mod tests { desired_state: crate::AgentDesiredState::Running, keep: false, restart: None, + delivery: None, + driver: None, resources: vec![], tasks: vec![Task { kind: TaskKind::Pty, @@ -2329,6 +2331,8 @@ mod tests { desired_state: crate::AgentDesiredState::Running, keep: false, restart: None, + delivery: None, + driver: None, resources: vec![], tasks: vec![Task { kind: TaskKind::Pty, @@ -2585,6 +2589,8 @@ mod tests { desired_state: crate::AgentDesiredState::Running, keep: false, restart: None, + delivery: None, + driver: None, resources: vec![], tasks: vec![], path: std::path::PathBuf::from("/x"), diff --git a/src/status.rs b/src/status.rs index e0650359..39250a7f 100644 --- a/src/status.rs +++ b/src/status.rs @@ -1,25 +1,29 @@ //! 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 (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}; +use std::time::{Duration, UNIX_EPOCH}; -/// A status file older than this reads as `unknown` no matter its contents. +/// A valid status heartbeat at least this old reads as `unknown`. 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`). 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 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 { @@ -72,38 +75,45 @@ 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)] +enum ParsedRecord { + Legacy(State), + Version1 { state: State, written_at_ms: u64 }, + LiteralUnknown, + InvalidState, + Malformed, +} + +/// 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 { - 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, + let (record, legacy_mtime_ms) = match read_record(status_path) { + Ok(record) => record, Err(_) => return State::Offline, }; - let first = raw.lines().next().unwrap_or("").trim(); - State::parse_any(first).unwrap_or(State::Offline) + read_parsed_at(record, legacy_mtime_ms, crate::message::now_ms()) } -/// 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 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<()> { - write_atomic(status_path, state.as_str()) + write_record(status_path, state, crate::message::now_ms()) +} + +/// 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 { - /// File present + a valid settable state → re-wrote the same value, mtime bumped. + /// 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, @@ -117,43 +127,171 @@ 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. +/// 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 { - if !status_path.exists() { - return match write_atomic(status_path, State::Available.as_str()) { - Ok(()) => RefreshOutcome::WroteDefault, - Err(_) => RefreshOutcome::Error, - }; - } - 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(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, }; - let first = raw.lines().next().unwrap_or("").trim(); - if first == "unknown" { - return RefreshOutcome::LeftUnknown; + 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 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(version_line) = lines.next() else { + return ParsedRecord::Legacy(state); + }; + if !has_final_newline || lines.next().is_some() { + return ParsedRecord::Malformed; } - if first == "dnd" { - return RefreshOutcome::LeftDnd; + let Some(timestamp) = version_line.strip_prefix(RECORD_PREFIX) else { + return ParsedRecord::Malformed; + }; + if timestamp.is_empty() || !timestamp.bytes().all(|byte| byte.is_ascii_digit()) { + return ParsedRecord::Malformed; } - match State::parse_settable(first) { - Some(s) => match write_atomic(status_path, s.as_str()) { - Ok(()) => RefreshOutcome::Refreshed, - Err(_) => RefreshOutcome::Error, + match timestamp.parse::() { + Ok(written_at_ms) => ParsedRecord::Version1 { + state, + written_at_ms, }, - None => RefreshOutcome::LeftCorrupt, + Err(_) => ParsedRecord::Malformed, + } +} + +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, format!("{value}\n"))?; + 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 @@ -177,7 +315,7 @@ fn tmp_name() -> String { #[cfg(test)] mod tests { use super::*; - use std::time::Duration as Dur; + use std::time::{Duration as Dur, SystemTime}; #[test] fn missing_is_offline() { @@ -196,13 +334,21 @@ mod tests { State::Away, State::Dnd, ] { + let before = crate::message::now_ms(); set_state(&sp, st).unwrap(); + let after = crate::message::now_ms(); assert_eq!(read_state(&sp), st); - // file content is exactly `\n` (wire format). - assert_eq!( - fs::read_to_string(&sp).unwrap(), - format!("{}\n", st.as_str()) - ); + 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())); } } @@ -211,6 +357,9 @@ mod tests { 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] @@ -222,29 +371,101 @@ mod tests { } #[test] - fn stale_mtime_reads_as_unknown_regardless_of_contents() { + fn malformed_versioned_record_is_unknown_without_mtime_fallback() { let tmp = tempfile::tempdir().unwrap(); let sp = status_path(tmp.path()); - set_state(&sp, State::Busy).unwrap(); - // Backdate the mtime past the stale window. - let old = SystemTime::now() - STATUS_STALE - Dur::from_secs(60); - let f = fs::File::open(&sp).unwrap(); - f.set_modified(old).unwrap(); + 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 literal_unknown_remains_derived_unknown() { + let tmp = tempfile::tempdir().unwrap(); + let sp = status_path(tmp.path()); + fs::write(&sp, "unknown\n").unwrap(); assert_eq!(read_state(&sp), State::Unknown); } #[test] - fn refresh_preserves_value_and_bumps_mtime() { + fn version_1_uses_embedded_time_instead_of_file_mtime() { + let tmp = tempfile::tempdir().unwrap(); + let sp = status_path(tmp.path()); + let now_ms = crate::message::now_ms(); + write_record(&sp, State::Busy, now_ms).unwrap(); + fs::File::open(&sp) + .unwrap() + .set_modified(SystemTime::now() - STATUS_STALE - Dur::from_secs(60)) + .unwrap(); + assert_eq!(read_state(&sp), State::Busy); + } + + #[test] + 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()); - set_state(&sp, State::Busy).unwrap(); - // Backdate → would read unknown… - let old = SystemTime::now() - STATUS_STALE - Dur::from_secs(60); - fs::File::open(&sp).unwrap().set_modified(old).unwrap(); + fs::write(&sp, "busy\n").unwrap(); + + fs::File::open(&sp) + .unwrap() + .set_modified(SystemTime::now() - STATUS_STALE - Dur::from_secs(60)) + .unwrap(); + assert_eq!(read_state(&sp), State::Unknown); + + fs::File::open(&sp) + .unwrap() + .set_modified(SystemTime::now() + STATUS_FUTURE_SKEW + Dur::from_secs(60)) + .unwrap(); assert_eq!(read_state(&sp), State::Unknown); - // …refresh keeps `busy` but bumps mtime, so it reads busy again. + } + + #[test] + fn refresh_preserves_value_and_changes_heartbeat_bytes() { + let tmp = tempfile::tempdir().unwrap(); + 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] @@ -253,6 +474,13 @@ mod tests { let sp = status_path(tmp.path()); 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] @@ -268,12 +496,91 @@ mod tests { 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!( + 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/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/src/validate.rs b/src/validate.rs index 634ecf4c..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,6 +243,15 @@ fn validate_scoped(root: &Path, this_host: Option<&str>) -> Report { )); } + 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 // field is omitted, path defaults remain part of placement and mismatches stay advisory. let explicit_placement = s.host.as_ref().is_some_and(|host| { @@ -254,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/catalog_apply.rs b/tests/catalog_apply.rs index c63254fe..ae426c68 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\nv1 ")); + 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\nv1 ")); + 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/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/codex_app_server.rs b/tests/codex_app_server.rs new file mode 100644 index 00000000..a4757802 --- /dev/null +++ b/tests/codex_app_server.rs @@ -0,0 +1,198 @@ +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 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" + lifecycle "adopt-only" + env { CODEX_HOME "$CATALOG/codex" } + deliver "app-server" + argv "codex" "--model" "gpt-test" "-c" "model_reasoning_effort=xhigh" "--model" "override" "boot" +} +"#, + ); + write( + &driver_path, + r#"agent "worker" { + host "h" + lifecycle "adopt-only" + env { CODEX_HOME "$CATALOG/codex" } + 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()); + assert!(st2::hooks::required_by_codex_agent( + &driver, + "h", + 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 [ + ( + "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_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()); + compile_generated_tasks(&mut found.specs, "h", &context(tmp.path())).unwrap(); + + 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/doctor.rs b/tests/doctor.rs index 6ef50b28..45187f24 100644 --- a/tests/doctor.rs +++ b/tests/doctor.rs @@ -361,3 +361,67 @@ 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`, `deliver`, or a driver block; 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"; 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" }"#, + ) + .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/driver_expansion.rs b/tests/driver_expansion.rs new file mode 100644 index 00000000..609956f2 --- /dev/null +++ b/tests/driver_expansion.rs @@ -0,0 +1,309 @@ +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) { + 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"), + ); +} + +#[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); + } +} + +#[test] +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"); + 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(); + 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(); + 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(); + let legacy_task = legacy + .tasks + .iter() + .find(|task| task.name == "agent") + .unwrap(); + let driver_task = driver + .tasks + .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(); + 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"], + legacy_mcp["mcpServers"]["st2"]["command"] + ); + let args = driver_mcp["mcpServers"]["st2"]["args"] + .as_array_mut() + .unwrap(); + 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) + ); + } + + 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] +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(); + 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/fixtures/driver/claude.in.kdl b/tests/fixtures/driver/claude.in.kdl new file mode 100644 index 00000000..5bab6aa9 --- /dev/null +++ b/tests/fixtures/driver/claude.in.kdl @@ -0,0 +1,11 @@ +agent "fabric" { + host "Silber" + 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..7cb3605c --- /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-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." 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." diff --git a/tests/reconcile.rs b/tests/reconcile.rs index c0850f4c..55678410 100644 --- a/tests/reconcile.rs +++ b/tests/reconcile.rs @@ -398,6 +398,8 @@ 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 ce92b5fb..c1da5842 100644 --- a/tests/run.rs +++ b/tests/run.rs @@ -236,6 +236,8 @@ fn task_spec(identity: &str, host: Option<&str>, id: &str) -> AgentSpec { desired_state: AgentDesiredState::Running, keep: false, restart: None, + delivery: None, + driver: None, resources: vec![], tasks: vec![Task { kind: TaskKind::Exec, diff --git a/tests/status_agents.rs b/tests/status_agents.rs index 553fa770..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,9 +434,9 @@ 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 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")); @@ -450,8 +446,70 @@ fn roster_derives_unknown_from_a_stale_status() { // Fresh → available. assert_eq!(roster(root, "hetz")[0].status, State::Available); - // Backdate the 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"); +} 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("[]"); diff --git a/tests/validate.rs b/tests/validate.rs index 646a65e7..23927050 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 'worker' 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 @@ -391,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 }"#)]); @@ -414,6 +477,32 @@ 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#""driver", "claude-session""#) + && stdout.contains(r#""--", "claude", "boot"]"#), + "{stdout}" + ); +} + #[test] fn a_missing_render_source_is_an_error() { let workspace = tempfile::tempdir().unwrap();