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
1 change: 1 addition & 0 deletions INVARIANTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ materialization, messaging, DING, or presence must preserve them.
| Invariant | Guarantee | Proof |
|---|---|---|
| **R11 control-plane replacement safety** | Normal stop or SIGKILL of `st2 up`, atomic binary replacement, and control-plane restart leave live PTY and exec agents usable with the same PID and creation identity. The replacement adopts each survivor exactly once, launches only genuinely missing work, and never duplicates a survivor; only explicit teardown kills an agent. | `tests/nomad_survival.rs::normal_stop_and_binary_replacement_adopt_exec_unchanged_without_duplicate`; `tests/nomad_survival.rs::forced_kill_and_binary_replacement_adopt_exec_unchanged_without_duplicate`; `tests/nomad_survival.rs::normal_stop_and_binary_replacement_adopt_pty_unchanged_without_duplicate`; `tests/nomad_survival.rs::forced_kill_and_binary_replacement_adopt_pty_unchanged_without_duplicate`; `tests/nomad_survival.rs::explicit_teardown_kills_exec_but_plain_stop_does_not`; `tests/nomad_survival.rs::explicit_teardown_kills_pty_but_plain_stop_does_not` |
| **Stable identity with live presentation** | The positional Agent Spec identity remains the only routing, authorization, task, and lifecycle key. Optional name and description are bounded declaration metadata. st2 projects them through one exact-ID atomic PTY metadata patch; changes and clears preserve PID and creation identity, unchanged projections emit nothing, and presentation failure never authorizes restart or teardown. | `crates/agent-spec/tests/discovery.rs::presentation_bounds_count_unicode_scalars_and_reject_noncanonical_values`; `tests/agent_presentation.rs::cli_sets_replaces_and_clears_fields_without_changing_identity_or_other_bytes`; `tests/reconcile.rs::live_pty_presentation_is_exact_id_metadata_and_not_lifecycle_drift`; `src/run.rs::metadata_patch_uses_exact_id_and_one_json_stdin_payload`; `tests/nomad_survival.rs::presentation_changes_patch_the_exact_live_pty_without_restarting_it` |
| **R23 fail-closed adoption inventory** | `st2 tasks --json` joins the shared-locked desired catalog to exact PTY and exec generations without reconciliation. Missing roots are positively absent without creation; malformed, unreadable, duplicate, PID-reused, or incomplete evidence is indeterminate, makes the typed envelope incomplete, and exits non-zero. Strict exec generations are durably published while legacy PID evidence is observed read-only. | `src/task_inventory.rs::stable_wire_shape_maps_pty_exec_explicit_default_and_ignores_foreign_host`; `src/task_inventory.rs::observer_cannot_claim_complete_running_without_generation_evidence`; `src/task_inventory.rs::retired_task_desires_absence_without_erasing_observed_generation`; `src/exec_backend.rs::spawn_atomically_publishes_strict_generation_json`; `src/exec_backend.rs::start_token_mismatch_cannot_report_running_or_signal_reused_pid`; `tests/task_inventory_cli.rs` |
| **Transport-decoupled lifecycle** | Each task is isolated from a supervisor/transport process-group or cgroup cascade. | `tests/transport_isolation.rs`; `tests/transport_isolation_macos.rs` |
| **Clean exec teardown** | Killing an exec task reaps its whole process group. | `tests/exec_backend.rs::exec_kill_reaps_the_whole_process_group_not_just_the_leader` |
Expand Down
36 changes: 28 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ agent "<identity>" {
// Optional metadata:
// role "worker"
// supervisor "<supervisor-bus-id>"
// name "Release worker"
// description "Owns release preparation and verification."
env { ST_AGENT "<host>.<identity>" }
argv "codex" "--dangerously-bypass-approvals-and-sandbox" "--dangerously-bypass-hook-trust" "<boot prompt>"
ding
Expand Down Expand Up @@ -186,6 +188,21 @@ access, readiness, or lifecycle policy, and URI possession conveys no authority.
declaration edits do not stop, replace, or relaunch a live task. Resource types and resolvers remain
opaque to st2; catalog readers use the public `agent-spec` crate to inspect the typed bindings.

The positional agent value is the stable automation identity. Optional `name` and `description`
fields are presentation only; they never route messages, select tasks, or rename durable
state. Mutate a catalog-owned KDL declaration through the constrained commands:

```sh
st2 rename <stable-id> "Release worker"
st2 describe <stable-id> "Owns release preparation and verification."
st2 rename <stable-id> --clear
```

These commands preserve unrelated KDL bytes and refuse TOML, JSON, and
`meta { managed-by "nix" }` targets. `ST_AGENT` callers may edit themselves or declared
descendants; an operator without `ST_AGENT` may use the same bounded authoring path. The sibling
`<agent-dir>/name` convention is retired and ignored.

`argv` launches its first value directly with the remaining values as arguments. It resolves a bare
program such as `codex` through the task environment's `PATH`, preserves argument boundaries, and
does not introduce a shell. Use `command #"..."#` instead when the task intentionally needs shell
Expand Down Expand Up @@ -340,16 +357,19 @@ st2 context read --full
```

The roster includes retired declarations instead of silently conflating them with runtime
presence. Both JSON shapes contain `retired` and the declaration's ordered `resources` descriptors;
`--enrich` additionally supplies `lastActivity` and `inbox`. Human output leaves active rows
unchanged and appends `[retired]` to a retired row.
presence. Both JSON shapes keep stable `identity` separate from optional `name` and `description`,
and contain `retired` 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 `[retired]` to a retired row.

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
links live under `<agent-dir>/resources/`. The flat `<root>/<identity>` layout remains only as the
intentional catalog-less fallback used by isolated folder evals. In a catalog-backed root,
`st2 message ls` rejects an absent identity; recovery inspection of a deliberately orphaned flat
box must be explicit with `st2 message ls <identity> --orphan` (and optionally `--archive`).
links live under `<agent-dir>/resources/`. Catalog-derived routing, including global `--catalog`,
rejects an absent identity. Explicit `--root` is the low-level transport boundary used by isolated
folder evals: declared identities still resolve to their native resource boxes, while an absent
identity names an exact flat `<root>/<identity>` mailbox. It never resolves a display name.
Recovery inspection of a deliberately orphaned flat box can also be requested with
`st2 message ls <identity> --orphan` (and optionally `--archive`).

Adopters should cut directly to the native layout. Before launching a migrated identity, install and
verify hooks, validate and materialize its hand-authored declaration, stop any predecessor transport,
Expand Down Expand Up @@ -407,7 +427,7 @@ st2 service uninstall

```text
ls, up, down, validate, doctor
message, ding, agents, status, context, resource
message, ding, agents, status, context, resource, rename, describe
env, pty, shell, pretrust
hooks, service, eval
agent digest, agent publish
Expand Down
25 changes: 25 additions & 0 deletions crates/agent-spec/src/kdl_format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ fn agent_node_to_raw(node: &KdlNode) -> anyhow::Result<RawSpec> {
for child in children.nodes() {
match child.name().value() {
"identity" => raw.identity = arg_string(child).or(raw.identity),
"name" => parse_presentation(child, "name", &mut raw.name)?,
"description" => parse_presentation(child, "description", &mut raw.description)?,
"host" => raw.host = arg_string(child),
"role" => raw.role = arg_string(child),
"type" => raw.job_type = arg_string(child),
Expand Down Expand Up @@ -116,6 +118,29 @@ fn agent_node_to_raw(node: &KdlNode) -> anyhow::Result<RawSpec> {
Ok(raw)
}

fn parse_presentation(
node: &KdlNode,
field: &str,
destination: &mut Option<String>,
) -> anyhow::Result<()> {
anyhow::ensure!(
destination.is_none(),
"agent declares `{field}` more than once"
);
anyhow::ensure!(
node.children().is_none()
&& node.entries().len() == 1
&& node.entries()[0].name().is_none(),
"agent `{field}` must contain exactly one positional string"
);
let value = node
.get(0)
.and_then(|value| value.as_string())
.ok_or_else(|| anyhow::anyhow!("agent `{field}` must contain a string"))?;
*destination = Some(value.to_owned());
Ok(())
}

fn resource_node_to_raw(node: &KdlNode) -> anyhow::Result<(String, RawResource)> {
if node.children().is_some() {
anyhow::bail!("resource binding cannot have children");
Expand Down
53 changes: 50 additions & 3 deletions crates/agent-spec/src/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
//! A job reads like a Nomad job: the *agent* is the job, its **tasks** are `pty{}` (interactive —
//! allocates a terminal, an agent harness) and `exec{}` (a plain process — the ding, daemons, a
//! stage's script; must NOT allocate a terminal, R09). st2 reads only the runner-normative subset:
//! `identity`, `host`, `role` (metadata only), `type`, `workspace`, `retired`, `keep`, `supervisor`,
//! `identity`, presentation (`name`, `description`), `host`, `role` (metadata only),
//! `type`, `workspace`, `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.
Expand All @@ -19,11 +20,20 @@ use std::time::Duration;
use serde::de::{self, MapAccess, Visitor};
use serde::{Deserialize, Serialize};

/// Maximum Unicode scalar count for an agent's human-facing label.
pub const AGENT_NAME_MAX_CHARS: usize = 160;
/// Maximum Unicode scalar count for an agent's enduring responsibility description.
pub const AGENT_DESCRIPTION_MAX_CHARS: usize = 1_000;

/// A rendered agent job, lowered to the shared declaration fields st2 and other readers inspect.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentSpec {
/// Unique id; the bus id is `<host>.<identity>`.
pub identity: String,
/// Optional mutable human-facing label. Never used as an automation selector.
pub name: Option<String>,
/// Optional enduring responsibility boundary. Never used for lifecycle decisions.
pub description: Option<String>,
/// Which machine runs this agent. `None` → resolved to the path's host / this machine.
pub host: Option<String>,
/// Optional declared persona role. Preserved as metadata and ignored for execution.
Expand Down Expand Up @@ -264,6 +274,8 @@ pub fn parse_duration(s: &str) -> Result<Duration, String> {
#[derive(Debug, Default, Deserialize)]
pub(crate) struct RawSpec {
pub identity: Option<String>,
pub name: Option<String>,
pub description: Option<String>,
pub host: Option<String>,
pub role: Option<String>,
#[serde(rename = "type")]
Expand Down Expand Up @@ -553,8 +565,7 @@ fn validate_uri_component(value: &str, extra: &[u8]) -> Result<(), &'static str>
} else if byte.is_ascii_alphanumeric()
|| matches!(
byte,
b'-'
| b'.'
b'-' | b'.'
| b'_'
| b'~'
| b'!'
Expand Down Expand Up @@ -600,6 +611,12 @@ impl RawSpec {
host: Option<String>,
path: PathBuf,
) -> anyhow::Result<AgentSpec> {
validate_presentation("name", self.name.as_deref(), AGENT_NAME_MAX_CHARS)?;
validate_presentation(
"description",
self.description.as_deref(),
AGENT_DESCRIPTION_MAX_CHARS,
)?;
validate_launch(
&identity,
self.command.as_ref(),
Expand Down Expand Up @@ -664,6 +681,8 @@ impl RawSpec {

Ok(AgentSpec {
identity,
name: self.name,
description: self.description,
host,
role: self.role,
job_type,
Expand All @@ -679,6 +698,34 @@ impl RawSpec {
}
}

/// Validate one optional presentation field at the shared parse/authoring boundary.
pub fn validate_presentation(
field: &str,
value: Option<&str>,
max_chars: usize,
) -> anyhow::Result<()> {
let Some(value) = value else {
return Ok(());
};
anyhow::ensure!(
!value.is_empty(),
"agent presentation `{field}` cannot be empty; omit it to clear it"
);
anyhow::ensure!(
value.trim() == value,
"agent presentation `{field}` cannot begin or end with whitespace"
);
anyhow::ensure!(
!value.chars().any(char::is_control),
"agent presentation `{field}` must be one printable line without control characters"
);
anyhow::ensure!(
value.chars().count() <= max_chars,
"agent presentation `{field}` exceeds the {max_chars}-character limit"
);
Ok(())
}

impl RawTask {
pub(crate) fn lower(
self,
Expand Down
107 changes: 107 additions & 0 deletions crates/agent-spec/tests/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,113 @@ argv = ["claude", "--resume", "session id"]
);
}

#[test]
fn presentation_metadata_lowers_from_kdl_toml_and_json_without_changing_identity() {
let tmp = tempfile::tempdir().unwrap();
write(
tmp.path(),
"agents/h/kdl/agent.kdl",
r#"agent "kdl" {
host "h"
name "Display label"
description "Enduring responsibility"
command "true"
}"#,
);
write(
tmp.path(),
"agents/h/toml/agent.toml",
r#"identity = "toml"
host = "h"
name = "Display label"
description = "Enduring responsibility"
command = "true"
"#,
);
write(
tmp.path(),
"agents/h/json/agent.json",
r#"{"identity":"json","host":"h","name":"Display label","description":"Enduring responsibility","command":"true"}"#,
);

let found = agent_spec::discover(tmp.path());
assert!(found.errors.is_empty(), "{:?}", found.errors);
for identity in ["kdl", "toml", "json"] {
let spec = find(&found.specs, identity);
assert_eq!(spec.identity, identity);
assert_eq!(spec.name.as_deref(), Some("Display label"));
assert_eq!(spec.description.as_deref(), Some("Enduring responsibility"));
}
}

#[test]
fn malformed_or_duplicate_kdl_presentation_is_rejected() {
for (case, body) in [
("duplicate", "name \"one\"; name \"two\""),
("wrong-type", "description 42"),
("children", "description { nested \"no\" }"),
] {
let tmp = tempfile::tempdir().unwrap();
write(
tmp.path(),
&format!("agents/h/{case}/agent.kdl"),
&format!("agent {case:?} {{ host \"h\"; {body}; command \"true\" }}"),
);
let found = agent_spec::discover(tmp.path());
assert!(found.specs.is_empty(), "{case}: {:?}", found.specs);
assert_eq!(found.errors.len(), 1, "{case}: {:?}", found.errors);
assert!(
found.errors[0].message.contains("must contain")
|| found.errors[0].message.contains("more than once"),
"{case}: {}",
found.errors[0].message
);
}
}

#[test]
fn presentation_bounds_count_unicode_scalars_and_reject_noncanonical_values() {
use agent_spec::spec::{
AGENT_DESCRIPTION_MAX_CHARS, AGENT_NAME_MAX_CHARS, validate_presentation,
};

let name_at_limit = "é".repeat(AGENT_NAME_MAX_CHARS);
let description_at_limit = "界".repeat(AGENT_DESCRIPTION_MAX_CHARS);
assert!(
validate_presentation("name", Some(&name_at_limit), AGENT_NAME_MAX_CHARS).is_ok()
);
assert!(
validate_presentation(
"description",
Some(&description_at_limit),
AGENT_DESCRIPTION_MAX_CHARS,
)
.is_ok()
);
assert!(
validate_presentation(
"name",
Some(&format!("{name_at_limit}x")),
AGENT_NAME_MAX_CHARS,
)
.is_err()
);
assert!(
validate_presentation(
"description",
Some(&format!("{description_at_limit}x")),
AGENT_DESCRIPTION_MAX_CHARS,
)
.is_err()
);
for invalid in ["", " leading", "trailing ", "two\nlines", "control\u{7f}"] {
assert!(
validate_presentation("name", Some(invalid), AGENT_NAME_MAX_CHARS).is_err(),
"accepted {invalid:?}"
);
}
}

#[test]
fn named_resource_bindings_are_typed_uri_identities_and_order_independent() {
let tmp = tempfile::tempdir().unwrap();
Expand Down
Loading