feat(activity): add fail-closed workspace snapshot - #257
feat(activity): add fail-closed workspace snapshot#257schickling-assistant wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c8cac5263f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| ); | ||
| } | ||
| }; | ||
| let found = discover(&catalog); |
There was a problem hiding this comment.
Use strict discovery for fail-closed snapshots
When any catalog subtree is unreadable or an entry cannot be inspected, ordinary discover silently skips it; crates/agent-spec/src/discovery.rs explicitly reserves discover_strict for recording such traversal uncertainty. Consequently, both discovery passes can omit the same workspace declaration and its live runtime, leaving this cleanup-facing envelope complete: true with a false inactivity claim. Use strict discovery for both the initial and drift-check passes so concealed declarations make the snapshot incomplete.
Useful? React with 👍 / 👎.
| epoch: Epoch { | ||
| catalog, | ||
| host: host.to_owned(), | ||
| catalog_generation: before.generation(), |
There was a problem hiding this comment.
Reject unversioned epochs before marking complete
For a legacy or manually created catalog without .st2/catalog-generation, read_fence returns None, but this path serializes catalogGeneration: null and can still mark the snapshot complete. After any later catalog edit, the epoch remains the same (catalog, host, null), so a cleanup transaction comparing its admitted epoch cannot detect that it is now reasoning about a different declaration generation. Such catalogs should produce incomplete evidence or use another declaration digest as the epoch.
Useful? React with 👍 / 👎.
Problem
A disk janitor finds a large worktree that has not been touched recently. Its Git-tracked files are clean, while ignored generated directories such as
node_modules,.devenv, or build outputs occupy substantial space.Filesystem age and Git status do not answer the safety-critical question: does an agent still own this workspace and have a running st2-managed runtime there?
st2 already owns the relevant declaration and runtime evidence: Agent Specs declare explicit workspace paths, and st2 observes their managed PTY/exec runtimes. Before this PR, a cleanup planner had to reconstruct that relationship from separate reads, with no single fail-closed snapshot or catalog-generation boundary.
Goal
Let an external cleanup planner obtain one short-lived, machine-readable snapshot that answers:
Incomplete evidence must never look like inactivity.
Decisions
Why st2 produces this evidence
st2 is authoritative for the relationship between Agent Spec declarations and st2-managed runtime observations. The janitor is authoritative for filesystem eligibility, Git state, cleanup policy, and mutation. Keeping those responsibilities separate avoids duplicating st2's catalog/runtime model inside a disk tool.
Snapshot contract
st2 workspace-activity --host <host> --ttl <seconds> --jsonemits ast2.workspace-activity.v1envelope containing:capturedAtandexpiresAt, with TTL constrained to 1-300 seconds;activestate;completeplus structured error text.The command fails closed: malformed or changing catalog data, unresolved workspace paths, duplicate runtime IDs, runtime-observer failures, incomplete observation batches, unexpected observations, or catalog-generation drift make
completefalse and the command exits non-zero. Invalid TTLs emit an already-expired incomplete envelope and exit non-zero.Suspended and retired declarations remain represented, so a retained live runtime cannot disappear merely because its desired state changed.
CLI and output contract
The v1 command requires JSON output and accepts an optional host plus a bounded TTL:
st2 --catalog /catalog workspace-activity --host dev3 --ttl 60 --jsonOn complete evidence it exits zero and emits one compact JSON object. This representative value has the exact implemented schema and field types:
{"schemaVersion":"st2.workspace-activity.v1","producer":"st2","epoch":{"catalog":"/catalog","host":"dev3","catalogGeneration":42},"capturedAt":"2026-08-14T08:00:00.000Z","expiresAt":"2026-08-14T08:01:00.000Z","complete":true,"errors":[],"claims":[{"workspace":"/worktrees/example","agents":["dev3.worker"],"activeRuntimeIds":["dev3.worker"],"active":true}]}Consumers rely on:
schemaVersionandproducerto select the contract;epoch.catalog,epoch.host, andepoch.catalogGenerationto bind evidence to the admitted declaration state (catalogGenerationisnullfor legacy catalogs without generation receipts);capturedAtandexpiresAtto reject stale evidence;completeanderrorsto fail closed before reading inactivity from claims;workspace, declaringagents, positively observedactiveRuntimeIds, and derivedactiveboolean.For example, an invalid TTL exits non-zero after printing an incomplete, already-expired envelope. The timestamps are equal by contract:
The paths, generation, and timestamps above are representative fixture values; the JSON keys, types, nesting, error text, exit behavior, and timestamp relationship match the implementation and tests.
Producer-to-consumer flow
An inactive claim means only that this complete snapshot observed no running st2-managed generation for the workspace's declared tasks. It is one required input to cleanup safety, not proof that deletion is safe by itself.
Non-goals
This PR does not:
Generation PID and creation-time evidence remains available through
st2 tasks --jsonfor the stronger deletion transaction.Verification
Passed locally with an already-present Rust toolchain:
The CLI suite covers:
The unit test deterministically proves
expiresAt = capturedAt + TTL.rustfmt --checkpassed for the two new Rust files, andgit diff --checkpassed.The full Nix/devenv suite was not materialized locally because the host root filesystem was below its operating free-space floor. CI is the full-repository proof.
Complexity
One focused module and one CLI command. The implementation reuses existing catalog discovery, catalog read fencing, and runtime observation; it introduces no dependency or parallel liveness subsystem.
Concerns
The snapshot expires by time but does not prevent state from changing after capture. A deletion consumer must fail closed on any invalid snapshot and must revalidate stronger evidence immediately before mutation.
Friction & bottlenecks
Root filesystem pressure prevented safe local Nix/devenv materialization. Focused Rust tests used an already-present toolchain without adding Nix store pressure.
Follow-ups
References