feat(catalog): archive retired agents out of the live catalog - #447
Merged
schickling merged 1 commit intoSep 5, 2026
Merged
Conversation
schickling-assistant
marked this pull request as ready for review
September 4, 2026 14:36
schickling-assistant
changed the base branch from
schickling-assistant/2026-09-04-b2-retired-keeps-resources
to
main
September 4, 2026 14:40
schickling-assistant
force-pushed
the
schickling-assistant/2026-09-04-b3-catalog-archive
branch
from
September 4, 2026 14:41
f2f2ec1 to
aa40ee5
Compare
myobie
approved these changes
Sep 4, 2026
`retired` is runtime teardown only, so a long-lived catalog accumulates retired identities forever (655 on dev3, >99% of their bytes under `resources/`). The cost is catalog size — discovery, validation, reconcile, and doctor pass over every one of them — not disk. Add the pressure valve: `st2 catalog archive` moves a retired identity's whole directory from `agents/<host>/<identity>` to `.st2/archive/<host>/<identity>` under the exclusive catalog-authoring lock, inside one generation commit, as a same-filesystem rename. `.st2` is control space at any depth, so an archived declaration is structurally undiscoverable and is never projected by a whole-catalog transaction — absent from the declaration plane, not filtered out of it. A tombstone beside the moved directory publishes one additive `archived` row in `st2 catalog graph --json`; `st2 catalog unarchive` is the exact reverse move. Eligibility is fail-closed and local-host only, because another host's runtime records are not observable from here: canonical path, retired in either spelling, no live or dead record for any declared task (the rule `st2 doctor` already applies to retirement), and no remaining declaration naming the identity as `supervisor`. `--identity` refuses the whole run if any named identity is ineligible; `--all-retired` reports the ineligible ones and archives the rest. `--dry-run` decides without creating the archive root. agent-identity: dev3.direct.omp.v6c4mkm2 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.1.2 agent-runtime: OMP 18.1.2 tooling-profile: dotfiles@7534055
schickling-assistant
force-pushed
the
schickling-assistant/2026-09-04-b3-catalog-archive
branch
from
September 4, 2026 16:36
aa40ee5 to
b5b0a22
Compare
schickling-assistant
added a commit
that referenced
this pull request
Sep 5, 2026
`st2 catalog archive` (#447) gave retirement a pressure valve, but somebody had to remember to pull it. dev3 mints ~100 identities a day, so a catalog that only shrinks when an operator says so is the model that produced today's 660 retired specs. Decided in Q11 (seat `dev3.direct.omp.v6c4mkm2`, option `st2-auto-archive`): the supervisor closes the `retired -> archived` edge itself, and dotfiles carries no archive code at all (dotfiles#2408, dotfiles#2411). Every `st2 up` reconcile pass now ends by archiving the local seats whose retirement outlived the catalog's `archive-after` grace period — a new `catalog.kdl` setting, default `7d`, where `"0"` is the operator's off switch and an unparseable value fails `st2 validate` rather than falling back to a clock nobody wrote. Eligibility is #447's `catalog_archive` module verbatim: the same fail-closed gate, the same lock, the same move, the same tombstone. At most 25 seats leave per pass, so a catalog holding hundreds of retirements drains over several passes instead of one that holds the authoring lock through all of them. The step runs after the pass releases its shared lock and takes the exclusive lock non-blockingly through the new `CatalogLock::try_exclusive`. A contended lock skips the step: a reconcile pass queued behind `st2 catalog apply` stalls every live agent, and a due seat is still due next pass. In the steady state the step costs one small JSON read and no second discovery, because `pass_has_work` answers from the specs the pass already parsed. st2 records no timestamp for a desired-state edit — the declaration is rewritten in place and the generation counter carries no per-identity data — so the grace period is measured from the supervisor's first observation of the retirement. That observation lives in `.st2/retired-observed.json` (`st2.catalog-retired-observed.v1`) as host -> identity -> epoch millis, in control space and never in the spec, because `retired` keeping every declared byte reversible is what archival is built on. The ledger is reconciled to exactly the currently retired seats on every pass it runs, so a seat that comes back drops its row and a second retirement serves a fresh grace period; an absent or unreadable ledger restarts every clock, which errs toward keeping seats. One eligibility axis is new rather than reused: a declaration re-created under a name the archive still holds is refused as `archive-occupied`. As a `move_out` failure it aborted the whole batch, which for a supervisor loop means wedging on the same seat every pass and never draining the others. agent-identity: dev3.direct.omp.v6c4mkm2 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.1.2 agent-runtime: OMP 18.1.2 tooling-profile: dotfiles@7534055
schickling
pushed a commit
that referenced
this pull request
Sep 5, 2026
`st2 catalog archive` (#447) gave retirement a pressure valve, but somebody had to remember to pull it. dev3 mints ~100 identities a day, so a catalog that only shrinks when an operator says so is the model that produced today's 660 retired specs. Decided in Q11 (seat `dev3.direct.omp.v6c4mkm2`, option `st2-auto-archive`): the supervisor closes the `retired -> archived` edge itself, and dotfiles carries no archive code at all (dotfiles#2408, dotfiles#2411). Every `st2 up` reconcile pass now ends by archiving the local seats whose retirement outlived the catalog's `archive-after` grace period — a new `catalog.kdl` setting, default `7d`, where `"0"` is the operator's off switch and an unparseable value fails `st2 validate` rather than falling back to a clock nobody wrote. Eligibility is #447's `catalog_archive` module verbatim: the same fail-closed gate, the same lock, the same move, the same tombstone. At most 25 seats leave per pass, so a catalog holding hundreds of retirements drains over several passes instead of one that holds the authoring lock through all of them. The step runs after the pass releases its shared lock and takes the exclusive lock non-blockingly through the new `CatalogLock::try_exclusive`. A contended lock skips the step: a reconcile pass queued behind `st2 catalog apply` stalls every live agent, and a due seat is still due next pass. In the steady state the step costs one small JSON read and no second discovery, because `pass_has_work` answers from the specs the pass already parsed. st2 records no timestamp for a desired-state edit — the declaration is rewritten in place and the generation counter carries no per-identity data — so the grace period is measured from the supervisor's first observation of the retirement. That observation lives in `.st2/retired-observed.json` (`st2.catalog-retired-observed.v1`) as host -> identity -> epoch millis, in control space and never in the spec, because `retired` keeping every declared byte reversible is what archival is built on. The ledger is reconciled to exactly the currently retired seats on every pass it runs, so a seat that comes back drops its row and a second retirement serves a fresh grace period; an absent or unreadable ledger restarts every clock, which errs toward keeping seats. One eligibility axis is new rather than reused: a declaration re-created under a name the archive still holds is refused as `archive-occupied`. As a `move_out` failure it aborted the whole batch, which for a supervisor loop means wedging on the same seat every pass and never draining the others. agent-identity: dev3.direct.omp.v6c4mkm2 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.1.2 agent-runtime: OMP 18.1.2 tooling-profile: dotfiles@7534055
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
desired-state "retired"is runtime teardown only — the spec andresources/stay byte-identical and reversible (#439, dotfiles#1535). That is the right semantics, and it means a long-lived catalog accumulates retired identities forever: dev3 holds 655 retired specs today,resources/is >99% of their bytes, andst2 catalog graph --jsonreports 696 declarations. The cost is catalog size — discovery, validation, reconcile and doctor pass over every one of them — not disk. There was no way to take an identity out of the live catalog without deleting it and losing the trace.Decided in Q3 (seat
dev3.direct.omp.v6c4mkm2, optionretired-keeps-all-plus-archived): lifecycle becomesrunning → suspended → retired → archived;archivedmoves the identity out of the live catalog with a tombstone, and un-archive is the reverse move under the same lock.Change
st2 catalog archive [--identity <id>]... [--all-retired] [--host <h>] [--dry-run] [--json]andst2 catalog unarchive <id> [--host <h>] [--json], in a newsrc/catalog_archive.rs(no new crate).<catalog>/.st2/archive/<host>/<identity>/..st2is control space at any depth inagent_spec::discovery::is_catalog_path, andcatalog_transaction's projection only walksagents/,_templates/,catalog.kdland profile modules — so an archived declaration is structurally undiscoverable and never enters a whole-catalog transaction's declaration identity. No new exclusion rule was added anywhere.CatalogLock+ onebegin_generation_commit()around the batch;fs::renameplussync_diron both parents. The archive root is a child of the catalog root, so the move is same-filesystem by construction — there is deliberately no copy+verify+remove engine for a case that cannot arise; anEXDEVrename surfaces as an error naming the constraint. The rename lands before the tombstone write on purpose: a crash in that window leaves an archived directory the graph reports andunarchivestill reverses, whereas tombstone-first would advertise an identity that never moved.agents/<host>/<identity>/agent.kdl; be retired in either spelling; have no live or dead runtime record for any declared task — the sameSystemRunner::list_sessions()rulest2 doctorapplies to retirement; and be named assupervisorby no declaration that stays behind (a retired dependent leaving in the same--all-retiredrun is not a live reference). Local host only: another host's runtime records are not observable from here, matchingdoctor's own host filter. Spec-less directories are out of scope (Q4).--identityrefuses the whole run if any named identity is ineligible (nothing moves);--all-retiredreports the ineligible ones inrefusedand archives the rest.--dry-rundecides without creating the archive root.archived: [{id, host, identity, archivedAt, reason, archiveRoot}]on the existingst2.catalog-graph.v2envelope — no schema bump, no field removed, no shape change for existing readers. An ordinary archived identity leavescomplete: trueuntouched; an archived directory with no readable tombstone (or a tombstone with no directory) is unexplained control-plane state and yields anarchive-unexplainederror issue withcomplete: false.agent_author::desired_state_edittreats a legacyretired #truenode as the lifecycle node and replaces it withdesired-state "…" reason=…(or removes it forrunning). Proven bytests/agent_desired_state.rs::cli_canonicalizes_legacy_retirement_and_refuses_nix_owned_declarationsand::legacy_retirement_reads_and_authoring_preserves_resources; I wrote a third test for it and deleted it as duplication (Q9). Deliberate deviation from the brief:agent publish/catalog applydo not rewrite caller bytes to the canonical spelling, because those are content-addressed — normalizing on the way in would break--input-sha256/--expect-sha256, the exact CAS the transaction is built on. Read compatibility stays; legacy declarations converge through the authoring verb and drain out througharchive, andarchiveaccepts both spellings.Docs: README (workflow + command surface),
docs/vrs/spec.md(R35 catalog-graph area), one new INVARIANTS row Archival leaves the live catalog with seven named proofs.Gates and evidence
Base is
schickling-assistant/2026-09-04-b2-retired-keeps-resources(#439), which is open and unmerged at time of writing — this PR is stacked on it, not onmain. #439's branch itself is based on5b2ee7c, one merge behindmain.cargo test --all-targets --all-features, the 12 new tests intests/catalog_archive.rsall green:archive_moves_a_retired_seat_out_of_discovery_with_its_resources_byte_identicalresources/(incl.resources/inbox/) byte-identical after the move;agents/…gone;st2 lssees nothing; tombstone written; graph has onearchivedrow,complete: truearchive_accepts_the_legacy_retirement_spellingretired #truearchives,reasonis nullarchive_refuses_a_running_or_suspended_declarationnot-retired, nothing moves, archive root never createdarchive_refuses_while_any_declared_task_record_survivesruntime-record-presentfor both an alive and a dead recordarchive_refuses_an_identity_another_declaration_still_names_as_supervisorsupervisor-referenced, names the dependentdry_run_reports_the_plan_and_changes_nothingarchivedAt: 0, no archive rootall_retired_archives_every_eligible_seat_and_reports_the_restall_retired_archives_a_retired_supervisor_together_with_its_retired_dependentunarchive_restores_the_identity_byte_identically_and_clears_its_tombstonean_archived_directory_without_a_tombstone_makes_the_graph_incompletearchive-unexplainederror issue, exit 1archive_refuses_an_unknown_identity_and_a_second_archive_of_the_same_identityunknown-identity; the archived copy is never silently replacedunarchive_refuses_to_overwrite_a_live_declarationLive smoke run of the built binary against a temp catalog (
--help,--dry-run, archive,find, graph JSON, refusal of a running agent, unarchive) — all as documented; output shape:rustfmt --checkis clean on both new files and on every hunk I added tosrc/main.rs,src/catalog_graph.rsandsrc/lib.rs. I did not reformat the pre-existing lines rustfmt also wants to change in those files.Pre-existing red, unchanged by this PR
Proven by stashing this diff and re-running on the base commit:
tests/agent_publish.rs— the same 9 failures before and after (cas_rejects_stale_writers_and_preserves_resources,concurrent_publishers_serialize_and_only_one_wins_the_cas,control_directory_swap_cannot_redirect_publication_staging,intermediate_host_swap_cannot_redirect_publication_outside_the_catalog,publish_post_commit_generation_failure_is_fenced_and_recovered,retirement_cannot_commit_between_reconcile_discovery_and_launch,spec_publish_crash_stages_only_in_the_control_plane,success_receipt_requires_exact_locked_readback,success_receipt_requires_locked_full_catalog_readmission). All fail oncandidate fails full-catalog validation … host 'host' must declare exactly one root agent; found 0— those fixtures publish a soleretired #trueagent, which the Graph v2 counts legacy-retired declarations as roots #402 root-count fold no longer counts as a root.tests/invariants.rs::qualified_proof_references_resolve— panics ontests/resource_profile_supervisor_e2e.rs::demand_observation_settlement_matrix_is_atomic_and_preserves_facts, a proof reference introduced by Add atomic Resource demand observations #404 that names a test that does not exist onmaineither. My seven new proof references were verified to resolve by hand (the assertion aborts on the first bad reference, so it never reaches them).cargo clippy --all-targets --all-features -- -D warnings— fails to compilecrates/st2-resource-protocolonmanual_is_multiple_ofatsrc/lib.rs:410, untouched here. Scoped clippy reports zero findings insrc/catalog_archive.rsandtests/catalog_archive.rs.cargo fmt -- --checkis red tree-wide onmain(agent-spec examples,discovery.rs,resync.rs,run.rs,resource_profile.rs, …).Intentionally left
EXDEVsurfaces as an error naming the constraint.publish/catalog apply. Content-addressed; see above.validatewarning for the legacy spelling. It would add ~655 warnings to dev3's graphissues(already 364) for a form that read compatibility keeps supporting and that archive drains.st2 doctoralready draws.direct.*with noagent.kdl) — out of scope per Q4; operating only on discovered specs excludes them automatically.Refs: schickling/dotfiles#2408, schickling/dotfiles#2411, decision Q3
smkfdt.Posted on behalf of @schickling
agent_identitysessionagent_personaagent_supervisoragent_toolagent_tool_versionagent_runtimetooling_profile