Repository navigation
fix(runtime-core): compact retired environment lease history at restore and cleanup - #348
Merged
Merged
Conversation
…d cleanup Ordinary graph cleanup retired every bound environment slot but left each immutable intent in run/environment-leases, so admission, cleanup and restore scanned every historical generation of a repeatedly started graph. After `graph cleanup` commits, the same engine mutation lease now compacts the run's records. Each must match a receipt container that is absent and a guest slot that is absent, or nothing is written. Exact intent digests move into one value-free per-run tombstone bound to the VM incarnation, and only then are the active records unlinked. Prior boots fold into one sealed summary each; the current boot keeps per-slot reservations. A retired slot, or any slot of a sealed boot, is never recorded, delivered, retired as live or counted again. Pending, live, changed, misbound or foreign-incarnation records refuse the whole compaction before its first write; other runs' and standalone records are untouched. Exact residue of an interrupted compaction reads as retired and is completed by the next cleanup or by archive's resume-only pass; changed residue refuses. Recovery flows that carry an exact environment inventory keep using the fenced path without compaction. Refs HACK-1193 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every environment-bound graph the CLI creates is relay-owned, so ordinary graph cleanup never ran compaction for it. Foreground redelivery and fresh-owner restore now compact the run's retired history under the engine lease. Compaction runs after every prior slot is re-retired and after the acknowledged stopped receipt is superseded on disk, before the new generation allocates. Enrolled relay cleanup still does not compact, because the acknowledged-publisher check re-verifies its inventory. A compaction refusal after a committed cleanup is now Settled::Retained instead of an error. The records stay active and the next cleanup, restore or archive retries. graph::cleanup no longer reports a cleanup that succeeded on disk as failed. New tests cover the retire guard (live_intent), archived-copy refusal, residue with a pending record, the 16384 retired-slot cap, and 260 generations of a 14-service graph across 9 boots. A source-order wiring guard covers the trigger points. The README now describes the trigger points, refusal semantics, current-boot growth and tombstone retention. Refs: HACK-1193 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
roodboi
marked this pull request as ready for review
October 20, 2026 13:58
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.
Summary
Linear: HACK-1193 (WU12-ENV-LEASES).
Every environment-bound graph the CLI creates is relay-owned, because
serveandserve-restoresetrelay_startup. Each foreground restore re-retired every slot of every earlier generation and then added a new generation, so admission, cleanup and restore scanned the whole history. That is how the Event Agent graph reached 84 records (14 services, 6 generations).The first commit on this branch compacted only in ordinary
graph cleanup, which relay graphs never reach. The rework commit (907726e) moves compaction to the points that relay graphs actually pass through.Final behavior
Compaction runs under the engine mutation lease, and only where no retained effect identity still binds the run's environment inventory:
Every graph restore. This covers foreground
owner-restoreredelivery (restore_with_foreground) and fresh-ownerserve-restore(restore_normalized_foreground). Three conditions hold first:restoringreceipt with the relay marker dormant; a fresh owner'spreparecommits its own enrollment);The container bindings come from the retired receipt, captured before the fresh resources replace them.
Ordinary (non-relay)
graph cleanup, right after its cleaned receipt commits.Archive, which only resumes an interrupted compaction. This is unchanged.
Enrolled relay cleanup (
cleanup_with_relay[_expected]) deliberately does not compact. After the acknowledgement,acknowledged_publisher::verify_cleanuprecomputes the cleanup inventory and compares it with the acknowledged effect, so compacting there would break owner shutdown. The last generation of a relay graph therefore stays active until the next restore or archive. Active records per run are now bounded by one generation, not by total history.A refusal no longer reports a committed cleanup as failed. The
?after the committed effect is replaced by the newenvironment_recovery::Settledoutcome (CompactedorRetained). A refusal can come from an uncertain or changed record, the retired-slot cap, the sealed-boot cap, a malformed tombstone, or an I/O error. In each case nothing is reverted, the records stay active as exact evidence, and the next cleanup, restore or archive retries.graph::cleanupreturns the committed receipt, and a restore continues to allocate. A run that keeps refusing eventually hits the existing active-record budget, which refuses admission with the closedenvironment_recoveryerror.The tombstone format, the commit points, the reservation rules and the refusal set are unchanged from the first commit.
retirenow reads its intent throughlive_intent, which holds the tombstone guard and can be tested without a VM.Docs.
packages/runtime-core/README.mdnow names the trigger points and says that relay cleanup itself does not compact. It describes the refusal semantics and the current-boot reservation growth (at most 16384, about 1170 generations of a 14-service graph in one boot).compaction.rsmodule docs list the same trigger points.Validation
Ran locally on macOS with
RUSTUP_TOOLCHAIN=1.97.1. The shared host's load average was 33 to 88 during these runs.bun run check:local(rustfmt check plus clippy--all-targets -D warnings): pass.mise exec zig -- cargo clippy --all-targets --all-features -- -D warnings: pass.cargo test --all-features --lib environment: 63 passed, 16 ignored.Focused:
cargo test --lib compaction::gives 12 passed, of which 5 are new.cargo test --lib graph::environment::testsgives 3 passed (2 new) and 2 ignored VM tests.bun run test:localwith--no-fail-fast: the lib has 994 passed, 7 failed and 58 ignored, and every integration binary passes except one test innode_contract. All 8 failures are subprocess timeouts under host load, in code this PR does not touch:provider::lifecycle::{host_filesystem, short_home, interrupted}:provider_timeout_uncertain. The same 7 tests also fail on the unmodified PR base3b530068, in a separate detached checkout on the same host.node_contract::output_is_drained_beyond_retention_limit_and_failure_is_distinct:timed_outafter its 3 s execution budget.A clean
test:localstill needs a quieter host or CI.bun test tests/integration-ownership.test.ts tests/cli-spawn-env-isolation.test.ts: 16 passed.bun run --cwd packages/cli typecheck: pass.bun run --cwd packages/cli check: 0 errors, with the 34 warnings already present onnext. No TypeScript, CLI surface or agent guidance changed.bun run privacy:check: ok.The new tests are filesystem-only. Guest and container absence is injected through
confirm, and interruptions are in-process fault returns. Nothing touches a VM, Docker, the real HOME, the keychain or a process kill.repeated_generations_beyond_256_stay_bounded_by_active_allocations:preflight_records, and one record per generation goes through the fullrecord_intentpath.graph_slotsreturns exactly 14 slots. Afterwards the active directory holds only foreign records.st_blocks). Foreign records are byte, inode and mtime identical.retired_slot_cap_refuses_rather_than_discards: exactly at 16384 retired slots compaction is admitted. One slot past the cap it refuses before any write, the records remain live inventory, andSettledmaps the refusal toRetained.compacted_residue_and_replay_are_never_retired_as_live:live_intent, the guard behindretire, refuses exact residue and an exact replay, and the refusal is read-only.archived_copy_of_a_compacted_record_is_refused_by_inventory: a compacted identity copied into the per-run archive directory refusesgraph_inventory, while a fresh archived record is still counted.residue_with_an_uncertain_pending_record_refuses: coversresidue()directly. In the integrated callers,read_modealready refuses committed plus pending first.history_bindings_cover_only_attached_graph_containers.compaction_triggers_stay_wired_at_their_lease_points. This is a source-order wiring guard, not behavior proof. It fails if:Negative controls (mutation)
Each mutation was applied alone to the source, the named filter was rerun, and the file was restored:
compaction::reservesingraph_inventoryinsert (archived records)environmentarchived_copy_of_a_compacted_record_is_refused_by_inventoryretire(live_intent)environmentcompacted_residue_and_replay_are_never_retired_as_liveresidue()environmentresidue_with_an_uncertain_pending_record_refusesMAX_RETIRED_SLOTScap checkenvironmentretired_slot_cap_refuses_rather_than_discardsgraph::cleanupgraph::environment::testsrestore_inputsgraph::environment::testsresume_graphfromarchive_slotsgraph::environment::testsgraph::environment::testshistory_bindings_cover_only_attached_graph_containersCompactedinstead ofRetainedenvironmentretired_slot_cap_refuses_rather_than_discardsThe first commit's mutations M1, M2, M5, M6, M8 and M10 to M16 remain covered by the unchanged tests.
Remaining gaps (host-bound)
owner-restore/serve-restorecycles of a relay-owned 14-service graph stay at one active generation.Retainedoutcome is not surfaced in CLI output. It shows up only as records remaining active, and eventually as the existing admission refusal.test:localis not clean locally because of the 8 load-related subprocess timeouts listed above.🤖 Generated with Claude Code