Skip to content

feat(activity): add fail-closed workspace snapshot - #257

Open
schickling-assistant wants to merge 1 commit into
mainfrom
schickling/2026-08-13-workspace-activity-snapshot
Open

feat(activity): add fail-closed workspace snapshot#257
schickling-assistant wants to merge 1 commit into
mainfrom
schickling/2026-08-13-workspace-activity-snapshot

Conversation

@schickling-assistant

@schickling-assistant schickling-assistant commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

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?

  • Deleting from an apparently stale workspace can destroy an active agent's environment or in-progress generated state.
  • Refusing every deletion because ownership is unknown prevents safe disk reclamation even when hundreds of gigabytes are recoverable.

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:

For each explicit Agent Spec workspace on this host, which agents declare it, and which of their st2-managed runtimes are positively observed running?

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> --json emits a st2.workspace-activity.v1 envelope containing:

  • the canonical catalog, host, and observed catalog generation as its epoch;
  • capturedAt and expiresAt, with TTL constrained to 1-300 seconds;
  • lexically sorted workspace claims with declaring agent IDs, positively running runtime IDs, and derived active state;
  • complete plus 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 complete false 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 --json

On 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:

  • schemaVersion and producer to select the contract;
  • epoch.catalog, epoch.host, and epoch.catalogGeneration to bind evidence to the admitted declaration state (catalogGeneration is null for legacy catalogs without generation receipts);
  • capturedAt and expiresAt to reject stale evidence;
  • complete and errors to fail closed before reading inactivity from claims;
  • each claim's canonical workspace, declaring agents, positively observed activeRuntimeIds, and derived active boolean.

For example, an invalid TTL exits non-zero after printing an incomplete, already-expired envelope. The timestamps are equal by contract:

$ st2 --catalog /catalog workspace-activity --host dev3 --ttl 0 --json
{"schemaVersion":"st2.workspace-activity.v1","producer":"st2","epoch":{"catalog":"/catalog","host":"dev3","catalogGeneration":null},"capturedAt":"2026-08-14T08:00:00.000Z","expiresAt":"2026-08-14T08:00:00.000Z","complete":false,"errors":["snapshot TTL must be between 1 and 300 seconds"],"claims":[]}
Error: workspace activity snapshot incomplete

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

Agent Specs + st2 runtime observers
                |
                v
  st2.workspace-activity.v1 snapshot
  (epoch + expiry + complete + claims)
                |
                v
janitor validates complete, expiry, and epoch
                |
                v
janitor combines it with Git/filesystem/process checks
                |
                v
janitor revalidates immediately before any mutation

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:

  • delete, quarantine, archive, or garbage-collect anything;
  • decide cleanup policy or whether a worktree is stale;
  • scan arbitrary OS processes or infer activity from filesystem timestamps;
  • provide a generation-bound lease or lock against a runtime starting after capture;
  • replace the janitor's immediate pre-mutation filesystem/process/runtime revalidation.

Generation PID and creation-time evidence remains available through st2 tasks --json for the stronger deletion transaction.

Verification

Passed locally with an already-present Rust toolchain:

cargo test --test workspace_activity_cli
7 passed; 0 failed

cargo test workspace_activity --lib
1 passed; 0 failed

The CLI suite covers:

  • a positively active workspace claim and no state mutation;
  • accepted TTL boundaries and fail-closed rejection outside them;
  • malformed catalog input;
  • duplicate runtime ownership;
  • runtime-observer failure;
  • relative workspace resolution.

The unit test deterministically proves expiresAt = capturedAt + TTL. rustfmt --check passed for the two new Rust files, and git diff --check passed.

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

  • Make the disk janitor consume the native snapshot alongside its Git/filesystem eligibility checks.
  • Complete the quarantine and immediate pre-mutation revalidation transaction outside st2.

References

  • Refs schickling/dotfiles#1554

@schickling
schickling marked this pull request as ready for review August 14, 2026 09:05

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/workspace_activity.rs
);
}
};
let found = discover(&catalog);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread src/workspace_activity.rs
Comment on lines +284 to +287
epoch: Epoch {
catalog,
host: host.to_owned(),
catalog_generation: before.generation(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

@schickling schickling added area:catalog Catalog structure, declarations, transactions, and admission · Set: manual area:doctor doctor, validate, and task inventory diagnostics · Set: manual area:reconcile Supervisor run loop, lifecycle, restart, park, and teardown · Set: manual origin:agent Filed or primarily produced by an AI agent · Set: manual type:feature New user-visible or system capability · Set: manual labels Aug 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:catalog Catalog structure, declarations, transactions, and admission · Set: manual area:doctor doctor, validate, and task inventory diagnostics · Set: manual area:reconcile Supervisor run loop, lifecycle, restart, park, and teardown · Set: manual origin:agent Filed or primarily produced by an AI agent · Set: manual type:feature New user-visible or system capability · Set: manual

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants