Skip to content
Merged
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 @@ -30,6 +30,7 @@ materialization, messaging, DING, or presence must preserve them.
| **Native-driver diagnostics are bounded and recoverable** | One additive-tolerant `st2.driver-diagnostic.v1` snapshot exposes the earliest failing native-driver boundary through closed stage/reason/source/support vocabulary and origin-timed evidence age. Absent, malformed, foreign, unknown, and wrongly paired records never read healthy. Stage recovery clears only its own failure and removes the record after final recovery without changing prompt submission, retry, read-back, or archive semantics. Roster and Doctor share the typed reader/repair policy; telemetry labels contain only bounded stage/reason/source/support/outcome values, never versions, identities, prompts, messages, or paths. | `src/driver_diagnostic.rs::every_stage_reason_and_source_has_bounded_wire_vocabulary`; `src/driver_diagnostic.rs::additive_fields_decode_but_malformed_foreign_and_unknown_records_are_indeterminate`; `src/driver_diagnostic.rs::recovery_clears_only_its_stage_and_reveals_the_next_failure`; `src/opencode_session.rs::delivery_and_read_back_boundaries_publish_and_clear_diagnostics_without_changing_retry`; `src/metrics.rs::driver_diagnostic_metric_attributes_are_exactly_the_bounded_axes`; `tests/doctor.rs::native_driver_diagnostic_roster_and_doctor_agree_and_recovery_clears` |
| **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` |
| **Archival leaves the live catalog** | `st2 catalog archive` moves a retired identity's whole directory — declaration and `resources/` byte-identical — under the catalog control plane's `.st2/archive`, where discovery structurally cannot reach it, and records one tombstone row in `st2 catalog graph --json`. Eligibility fails closed: a running or suspended declaration, any surviving live or dead declared task record, and any remaining declaration that still names the identity as `supervisor` each refuse before anything moves, and `--dry-run` proves the plan without creating the archive root. An archived directory with no readable tombstone is unexplained control-plane state that makes the graph incomplete. `st2 catalog unarchive` is the exact reverse move. | `tests/catalog_archive.rs::archive_moves_a_retired_seat_out_of_discovery_with_its_resources_byte_identical`; `tests/catalog_archive.rs::archive_refuses_a_running_or_suspended_declaration`; `tests/catalog_archive.rs::archive_refuses_while_any_declared_task_record_survives`; `tests/catalog_archive.rs::archive_refuses_an_identity_another_declaration_still_names_as_supervisor`; `tests/catalog_archive.rs::dry_run_reports_the_plan_and_changes_nothing`; `tests/catalog_archive.rs::an_archived_directory_without_a_tombstone_makes_the_graph_incomplete`; `tests/catalog_archive.rs::unarchive_restores_the_identity_byte_identically_and_clears_its_tombstone` |
| **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` |
| **Parked tasks are visible and individually recoverable** | A parked task is reported alongside an unmodified runtime observation as a complete known fault; only an unbelievable marker fails closed. Park markers and unpark requests share the exact canonical catalog-folder plus host ownership scope, and the projected recovery argv carries both axes, so same-host supervisors cannot see, delete, consume, or advertise recovery into each other's channels even for the same task ID. A projected park whose supervisor generation is gone is positively not parked. An explicit per-task unpark clears that task's park and spent budget so it is launchable again and stays recovered past `interval`, releases no other parked task, restarts no healthy peer, and restores the agent's derived DING. | `src/flapping.rs::unpark_restores_a_launchable_task_not_just_a_cleared_flag`; `src/flapping.rs::unpark_is_per_task_and_reports_whether_it_changed_anything`; `src/park.rs::same_host_supervisors_isolate_markers_and_requests_by_catalog`; `src/park.rs::a_marker_from_a_dead_supervisor_reads_as_not_parked`; `src/park.rs::published_parks_are_readable_and_clear_when_the_task_recovers`; `src/park.rs::an_unbelievable_marker_is_indeterminate_not_absent`; `src/park.rs::a_request_is_consumed_exactly_once`; `src/task_inventory.rs::a_parked_task_reports_its_fault_alongside_a_truthful_runtime_state`; `src/task_inventory.rs::an_unbelievable_park_marker_makes_the_envelope_incomplete`; `tests/task_inventory_cli.rs::projected_recovery_targets_its_exact_catalog_and_host_despite_ambient_defaults`; `tests/run.rs::an_operator_recovers_one_parked_task_without_disturbing_a_healthy_peer`; `tests/run.rs::an_unpark_request_for_a_task_that_is_not_parked_says_so` |
| **Tracked workspaces fail closed** | Materialization simulates content operations before writing and refuses a real change to any Git-tracked target. Byte-identical tracked, untracked, and non-Git targets retain useful behavior. | `tests/materialize.rs::every_content_directive_refuses_to_change_a_tracked_target_before_any_write`; `tests/materialize.rs::byte_identical_tracked_target_is_allowed_without_modification`; `tests/materialize.rs::untracked_and_non_git_targets_remain_materializable` |
Expand Down
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,31 @@ resumes without the original prepared source. Snapshots own the complete
bounded `_templates` library and empty canonical per-agent `.workspace`
directory facts, but never traverse, hash, copy, or delete workspace content.

A retired declaration is runtime teardown only: it keeps its spec and `resources/` byte-identical
and stays reversible, so a long-lived catalog accumulates retired identities. Archival is the
pressure valve that keeps the live plane bounded:

```sh
st2 catalog archive --catalog "$CATALOG" --all-retired --dry-run --json
st2 catalog archive --catalog "$CATALOG" --identity <identity> --json
st2 catalog unarchive --catalog "$CATALOG" <identity> --json
```

Archival moves the whole identity directory from `agents/<host>/<identity>` to
`.st2/archive/<host>/<identity>` under the exclusive catalog-authoring lock, in one generation
commit, as a same-filesystem rename — the archive root is a child of the catalog root, so no bytes
are copied and no partial bundle can exist. `.st2` is control space at any depth, so an archived
declaration is undiscoverable rather than filtered, and the whole-catalog transaction never projects
it. A tombstone beside the moved directory keeps the identity traceable as one `archived` row in
`st2 catalog graph --json`.

Eligibility fails closed against the local host only, because another host's runtime records are not
observable from here: the declaration must sit at its canonical path, be retired in either spelling,
have no live or dead record for any declared task (the rule `st2 doctor` already applies to
retirement), and be named as `supervisor` by no declaration that stays behind. `--identity` refuses
the whole run if any named identity is ineligible; `--all-retired` reports the ineligible ones and
archives the rest. `st2 catalog unarchive` is the exact reverse move.

The compact declaration shape is:

```kdl
Expand Down Expand Up @@ -622,7 +647,7 @@ message, ding, agents, status, context, resource, rename, describe
env, pty, shell, pretrust
hooks, service, claude-channel, eval
agent digest, agent publish
catalog bootstrap, catalog snapshot, catalog apply
catalog bootstrap, catalog snapshot, catalog apply, catalog archive, catalog unarchive
completions
```

Expand Down
19 changes: 19 additions & 0 deletions docs/vrs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -846,6 +846,25 @@ removed from the inbox. An existing archive file wins byte-for-byte, so replay
and a sync-restored duplicate converge without overwriting the receipt.
Suspended reconciliation never performs this settlement.

Retirement is where the lifecycle stops being about runtime and starts being
about catalog size, so `st2 catalog archive` adds one step past it:
`running → suspended → retired → archived`. Archival moves the whole identity
directory from `agents/<host>/<identity>` into the catalog control plane at
`.st2/archive/<host>/<identity>`, under the exclusive authoring lock and inside
one generation commit, as a same-filesystem rename. `.st2` is excluded from
catalog space at any depth, so an archived declaration is structurally
undiscoverable and is never projected by a whole-catalog transaction — it is
absent from the declaration plane, not filtered out of it. A tombstone file
beside the moved directory carries `{id, host, identity, archivedAt, reason,
archiveRoot}` and is published as one additive `archived` row in the
`st2.catalog-graph.v2` envelope; an ordinary archived identity therefore leaves
`complete: true` untouched, while an archived directory with no readable
tombstone is unexplained control-plane state and makes the envelope incomplete.
Eligibility is fail-closed and local-host only, because another host's runtime
records are not observable: canonical path, retired in either spelling, no live
or dead record for any declared task, and no remaining declaration naming the
identity as `supervisor`. `st2 catalog unarchive` is the exact reverse move.

Before starting a Codex provider, st2 asks that binary to generate its
app-server JSON schemas and fingerprints only the delivery-critical projection:
every client request and server notification arm st2 uses, the exact
Expand Down
Loading
Loading