Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion INVARIANTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ 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, origin-timed activity, inbox counts, and the appended `observedState` object. Declared presence, desired lifecycle, and observed harness state are three independent axes: none is derived from another, and a missing observed record is `null`, never a derived `unknown`. | `src/agents.rs::agents_json_has_stable_wire_shape`; `src/agents.rs::agents_json_preserves_opaque_declared_resource_descriptors`; `src/agents.rs::observed_state_joins_declared_presence_without_touching_either`; `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` |
| **Stable roster JSON** | `st2 agents --json [--enrich]` preserves field names, order, null handling, presence, the nullable declared role, typed desired state and rationale, the retirement compatibility projection, opaque declared Resource descriptors, origin-timed activity, inbox counts, and the appended `observedState` object. Declared presence, desired lifecycle, and observed harness state are three independent axes: none is derived from another, and a missing observed record is `null`, never a derived `unknown`. | `src/agents.rs::agents_json_has_stable_wire_shape`; `src/agents.rs::agents_json_preserves_opaque_declared_resource_descriptors`; `src/agents.rs::observed_state_joins_declared_presence_without_touching_either`; `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, Claude, and pi 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/pi_session.rs::idle_pi_provider_refreshes_presence_without_channel_input`; `src/codex_app_server.rs::inbox_fallback_does_not_write_a_fifteen_second_presence_heartbeat` |
| **Scoped delivery-input wakeups** | Native delivery pumps watch only their inputs: the agent's `resources/inbox` subtree and its `status` file. Runtime records written beside them by the pump's own process group — presence temp siblings, the `harness-state` record, stream state — never wake delivery, so a producer that writes on every turn boundary cannot pump its own delivery loop. | `src/watch.rs::delivery_watcher_ignores_runtime_records_but_wakes_on_inbox_and_status` |
| **Observed harness state discipline** | The `harness-state` record is written only by the owning session's driver processes — wrapper, channel, or hooks — serialized by a cross-process lock and coalesced against the on-disk record, atomically and byte-distinct on every write that lands, with freshness from its embedded timestamp and never file mtime; restating an unchanged state touches the record only when the refresh cadence is due. `unknown` is derived, never written: staleness, future skew, malformation, an unsupported schema, and a provably dead pty session each read as `unknown` with a distinct reason; an indeterminate liveness probe downgrades nothing; a missing record is no observation rather than `unknown`; no absence derives a definite state. A writer that loses sight of its harness stops heartbeating instead of refreshing a state it cannot see, and a predecessor session's record is never re-stamped. A reaped provider yields a terminal `ended` record carrying its real exit — written before the stop path's SIGKILL escalation and rewritten from the escalation cover when a grace-window reap observes the real status, proven against the real wrapper binaries of both stop implementations — and never a live state. | `src/harness_state.rs::unknown_state_is_derived_and_cannot_be_written`; `src/harness_state.rs::malformed_record_is_unknown_without_mtime_fallback`; `src/harness_state.rs::staleness_and_future_skew_derive_unknown_with_distinct_reasons`; `src/harness_state.rs::a_dead_session_reads_unknown_even_while_fresh_but_ended_survives`; `src/harness_state.rs::every_landed_write_is_byte_distinct_and_fresh_restatements_do_not_write`; `src/harness_state.rs::a_chatty_producer_restating_its_state_causes_zero_writes`; `src/harness_state.rs::concurrent_writers_defer_to_the_on_disk_record_not_their_cache`; `src/harness_state.rs::a_predecessor_sessions_record_is_never_heartbeat_eligible`; `src/harness_state.rs::missing_record_reads_as_none_not_unknown`; `src/codex_app_server.rs::pump_publishes_observations_and_stops_heartbeating_on_evidence_loss`; `src/claude_session.rs::a_provider_killed_mid_turn_reads_ended_rather_than_active`; `src/claude_session.rs::a_clean_provider_exit_writes_the_terminal_record`; `tests/harness_state_teardown.rs::stop_escalation_writes_the_terminal_record_before_sigkill`; `tests/harness_state_teardown.rs::opencode_stop_escalation_writes_the_cover_record_before_sigkill`; `tests/harness_state_teardown.rs::opencode_graceful_stop_records_the_real_reaped_exit` |
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -459,13 +459,13 @@ st2 context read --full

The roster includes suspended and retired declarations instead of silently conflating desired
lifecycle with runtime presence. Both JSON shapes keep stable `identity` separate from optional
`name` and `description`, and contain the compatibility `retired` projection, `desiredState`,
`name`, `description`, and `role`. They contain the compatibility `retired` projection, `desiredState`,
`desiredStateReason`, plus the declaration's ordered `resources` descriptors. `--enrich`
additionally supplies `lastActivity` and `inbox`. Human output prints the same presentation fields
as separate columns and appends the non-running state and rationale.
`--identity <host>.<identity>` selects exactly one qualified Agent Spec or fails,
so external harness hooks can read its current name and description without parsing KDL or relying
on a duplicate state file.
so external consumers can read its current name, description, and role without parsing KDL or
relying on a duplicate state file.

For a catalog-backed agent, every native bus operation resolves the same agent directory used by
the roster: presence is `<agent-dir>/status`, while unread messages, archive receipts, context, and
Expand Down
2 changes: 1 addition & 1 deletion crates/agent-spec/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
//!
//! st2 consumes this crate, which is what keeps it a reference implementation rather than a copy:
//! a second reader (a TUI, a linter) sees exactly the fields the runner sees, including the ones
//! the runner's roster JSON does not carry (`supervisor`, `role`, `workspace`, `host`).
//! the runner's roster JSON does not carry (`supervisor`, `workspace`, `host`).
//!
//! The runner-normalized model deliberately drops render-only fields. [`DeclaredDocument`] retains
//! them without assigning policy, so st2 stays render-agnostic while policy consumers do not need a
Expand Down
6 changes: 4 additions & 2 deletions docs/vrs/02-agent-spec/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,11 +230,13 @@ Evidence: [validation](../../../src/validate.rs).
<h3 id="f05">F05 <code>role</code></h3>

Update observable declaration metadata only. Do not change the fingerprint,
workspace files, notification state, or a healthy task.
workspace files, notification state, or a healthy task. Both JSON roster shapes
expose the exact free-form value as nullable `role`.

Authoring: [pinned complete declaration][evals-fields]. st2 source:
[`AgentSpec::role`](../../../crates/agent-spec/src/spec.rs). Evidence:
[KDL lowering](../../../crates/agent-spec/src/kdl_format.rs).
[KDL lowering](../../../crates/agent-spec/src/kdl_format.rs) and
[roster projection](../../../src/agents.rs).

<h3 id="f06">F06 <code>workspace</code></h3>

Expand Down
20 changes: 14 additions & 6 deletions src/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ pub struct AgentRow {
pub name: Option<String>,
/// Optional enduring responsibility boundary from the Agent Spec declaration.
pub description: Option<String>,
/// Optional free-form role from the Agent Spec declaration.
pub role: Option<String>,
/// Whether the declaration is explicitly retired. Presence remains a separate runtime signal.
pub retired: bool,
/// Declarative whole-agent lifecycle intent.
Expand Down Expand Up @@ -67,6 +69,7 @@ pub fn roster_from_discovered(
status: status::read_state(&status::status_path(agent_dir)),
name: s.name.clone(),
description: s.description.clone(),
role: s.role.clone(),
retired: s.desired_state.is_retired(),
desired_state: s.desired_state.as_str().to_owned(),
desired_state_reason: s.desired_state.reason().map(str::to_owned),
Expand Down Expand Up @@ -144,6 +147,7 @@ struct SummaryJson<'a> {
status: &'a str,
name: Option<&'a str>,
description: Option<&'a str>,
role: Option<&'a str>,
retired: bool,
resources: &'a [Resource],
#[serde(rename = "desiredState")]
Expand All @@ -161,6 +165,7 @@ struct EnrichedJson<'a> {
status: &'a str,
name: Option<&'a str>,
description: Option<&'a str>,
role: Option<&'a str>,
retired: bool,
resources: &'a [Resource],
#[serde(rename = "lastActivity")]
Expand All @@ -184,6 +189,7 @@ pub fn to_json(rows: &[AgentRow], enrich: bool) -> String {
status: r.status.as_str(),
name: r.name.as_deref(),
description: r.description.as_deref(),
role: r.role.as_deref(),
retired: r.retired,
resources: &r.resources,
desired_state: &r.desired_state,
Expand All @@ -202,6 +208,7 @@ pub fn to_json(rows: &[AgentRow], enrich: bool) -> String {
status: r.status.as_str(),
name: r.name.as_deref(),
description: r.description.as_deref(),
role: r.role.as_deref(),
retired: r.retired,
resources: &r.resources,
desired_state: &r.desired_state,
Expand Down Expand Up @@ -265,6 +272,7 @@ mod tests {
status,
name: name.map(str::to_string),
description: None,
role: None,
retired,
desired_state: if retired { "retired" } else { "running" }.to_owned(),
desired_state_reason: None,
Expand Down Expand Up @@ -292,11 +300,11 @@ mod tests {

assert_eq!(
to_json(&rows, false),
r#"[{"identity":"hetz.cos-claude","status":"available","name":null,"description":null,"retired":false,"resources":[],"desiredState":"running","desiredStateReason":null,"observedState":null},{"identity":"hetz.st2-claude","status":"busy","name":"owner","description":null,"retired":true,"resources":[],"desiredState":"retired","desiredStateReason":null,"observedState":null}]"#
r#"[{"identity":"hetz.cos-claude","status":"available","name":null,"description":null,"role":null,"retired":false,"resources":[],"desiredState":"running","desiredStateReason":null,"observedState":null},{"identity":"hetz.st2-claude","status":"busy","name":"owner","description":null,"role":null,"retired":true,"resources":[],"desiredState":"retired","desiredStateReason":null,"observedState":null}]"#
);
assert_eq!(
to_json(&rows, true),
r#"[{"identity":"hetz.cos-claude","status":"available","name":null,"description":null,"retired":false,"resources":[],"lastActivity":1784653027733.6138,"inbox":1,"desiredState":"running","desiredStateReason":null,"observedState":null},{"identity":"hetz.st2-claude","status":"busy","name":"owner","description":null,"retired":true,"resources":[],"lastActivity":null,"inbox":0,"desiredState":"retired","desiredStateReason":null,"observedState":null}]"#
r#"[{"identity":"hetz.cos-claude","status":"available","name":null,"description":null,"role":null,"retired":false,"resources":[],"lastActivity":1784653027733.6138,"inbox":1,"desiredState":"running","desiredStateReason":null,"observedState":null},{"identity":"hetz.st2-claude","status":"busy","name":"owner","description":null,"role":null,"retired":true,"resources":[],"lastActivity":null,"inbox":0,"desiredState":"retired","desiredStateReason":null,"observedState":null}]"#
);
// Empty roster is `[]`, not `null`.
assert_eq!(to_json(&[], true), "[]");
Expand All @@ -316,7 +324,7 @@ mod tests {

assert_eq!(
to_json(&[resource_row], false),
r#"[{"identity":"hetz.worker","status":"available","name":null,"description":null,"retired":false,"resources":[{"name":"work","uri":"vendor+thing://authority/exact%20identity","reason":"Current implementation task."}],"desiredState":"running","desiredStateReason":null,"observedState":null}]"#
r#"[{"identity":"hetz.worker","status":"available","name":null,"description":null,"role":null,"retired":false,"resources":[{"name":"work","uri":"vendor+thing://authority/exact%20identity","reason":"Current implementation task."}],"desiredState":"running","desiredStateReason":null,"observedState":null}]"#
);
}

Expand Down Expand Up @@ -346,11 +354,11 @@ mod tests {

assert_eq!(
to_json(&[wedged.clone()], false),
r#"[{"identity":"hetz.worker","status":"busy","name":null,"description":null,"retired":false,"resources":[],"desiredState":"running","desiredStateReason":null,"observedState":{"state":"idle","blockedOn":"none","inputBuffer":"empty","ask":"none","harness":"codex","since":1784653000000,"reason":null,"exit":null}}]"#
r#"[{"identity":"hetz.worker","status":"busy","name":null,"description":null,"role":null,"retired":false,"resources":[],"desiredState":"running","desiredStateReason":null,"observedState":{"state":"idle","blockedOn":"none","inputBuffer":"empty","ask":"none","harness":"codex","since":1784653000000,"reason":null,"exit":null}}]"#
);
assert_eq!(
to_json(&[wedged], true),
r#"[{"identity":"hetz.worker","status":"busy","name":null,"description":null,"retired":false,"resources":[],"lastActivity":1784653027733.6138,"inbox":0,"desiredState":"running","desiredStateReason":null,"observedState":{"state":"idle","blockedOn":"none","inputBuffer":"empty","ask":"none","harness":"codex","since":1784653000000,"reason":null,"exit":null}}]"#
r#"[{"identity":"hetz.worker","status":"busy","name":null,"description":null,"role":null,"retired":false,"resources":[],"lastActivity":1784653027733.6138,"inbox":0,"desiredState":"running","desiredStateReason":null,"observedState":{"state":"idle","blockedOn":"none","inputBuffer":"empty","ask":"none","harness":"codex","since":1784653000000,"reason":null,"exit":null}}]"#
);

let mut derived = row("hetz.worker", State::Available, None, false, None, 0);
Expand All @@ -366,7 +374,7 @@ mod tests {
});
assert_eq!(
to_json(&[derived], false),
r#"[{"identity":"hetz.worker","status":"available","name":null,"description":null,"retired":false,"resources":[],"desiredState":"running","desiredStateReason":null,"observedState":{"state":"unknown","blockedOn":"unknown","inputBuffer":"unknown","ask":"unknown","harness":"codex","since":null,"reason":"session-dead","exit":null}}]"#
r#"[{"identity":"hetz.worker","status":"available","name":null,"description":null,"role":null,"retired":false,"resources":[],"desiredState":"running","desiredStateReason":null,"observedState":{"state":"unknown","blockedOn":"unknown","inputBuffer":"unknown","ask":"unknown","harness":"codex","since":null,"reason":"session-dead","exit":null}}]"#
);
}
}
5 changes: 4 additions & 1 deletion tests/status_agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ fn write(root: &Path, rel: &str, contents: &str) {
fn agent_kdl(identity: &str, host: &str) -> String {
format!(
"agent \"{identity}\" {{\n identity \"{identity}\"\n host \"{host}\"\n \
type \"service\"\n resource \"work\" uri=\"issue://example/{identity}\" reason=\"example work item\"\n \
type \"service\"\n role \"worker\"\n resource \"work\" uri=\"issue://example/{identity}\" reason=\"example work item\"\n \
pty \"agent\" {{ command \"exec claude boot\" }}\n}}\n"
)
}
Expand Down Expand Up @@ -142,6 +142,7 @@ fn roster_projects_presence_name_and_enrich_across_the_catalog() {
assert_eq!(st2c.status, State::Busy);
assert_eq!(st2c.name.as_deref(), Some("st2 owner"));
assert_eq!(st2c.description.as_deref(), Some("Own st2 delivery"));
assert_eq!(st2c.role.as_deref(), Some("worker"));
assert!(!st2c.retired);
assert_eq!(
st2c.inbox, 1,
Expand Down Expand Up @@ -213,6 +214,7 @@ fn roster_json_and_human_output_distinguish_retirement_from_presence() {
);
let rows: serde_json::Value = serde_json::from_slice(&json.stdout).unwrap();
assert_eq!(rows[0]["identity"], "h.live");
assert_eq!(rows[0]["role"], "worker");
assert_eq!(rows[0]["retired"], false);
assert_eq!(
rows[0]["resources"],
Expand Down Expand Up @@ -240,6 +242,7 @@ fn roster_json_and_human_output_distinguish_retirement_from_presence() {
let selected: serde_json::Value = serde_json::from_slice(&selected.stdout).unwrap();
assert_eq!(selected.as_array().unwrap().len(), 1);
assert_eq!(selected[0]["identity"], "h.live");
assert_eq!(selected[0]["role"], "worker");

let absent = Command::new(env!("CARGO_BIN_EXE_st2"))
.arg("agents")
Expand Down
Loading