Skip to content

fix(runtime-core): compact retired environment lease history at restore and cleanup - #348

Merged
roodboi merged 2 commits into
nextfrom
wf/f5-lease-compaction-b16
Oct 20, 2026
Merged

roodboi merged 2 commits into
nextfrom
wf/f5-lease-compaction-b16

Conversation

@roodboi

@roodboi roodboi commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Linear: HACK-1193 (WU12-ENV-LEASES).

Every environment-bound graph the CLI creates is relay-owned, because serve and serve-restore set relay_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-restore redelivery (restore_with_foreground) and fresh-owner serve-restore (restore_normalized_foreground). Three conditions hold first:

    • every prior slot is re-retired;
    • the acknowledged stopped receipt is superseded on disk (the redelivery restore commits its restoring receipt with the relay marker dormant; a fresh owner's prepare commits its own enrollment);
    • the new generation has not allocated yet.

    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_cleanup recomputes 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 new environment_recovery::Settled outcome (Compacted or Retained). 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::cleanup returns 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 closed environment_recovery error.

The tombstone format, the commit points, the reservation rules and the refusal set are unchanged from the first commit. retire now reads its intent through live_intent, which holds the tombstone guard and can be tested without a VM.

Docs.

  • packages/runtime-core/README.md now 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).
  • It also documents tombstone retention. Tombstones are permanent slot-ID reservations, like standalone exports, and archive and prune do not remove them. Each takes about 160 bytes per current-boot retired slot plus one summary per sealed boot, and is capped at 4 MiB (refused, never truncated).
  • The compaction.rs module 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::tests gives 3 passed (2 new) and 2 ignored VM tests.

  • bun run test:local with --no-fail-fast: the lib has 994 passed, 7 failed and 58 ignored, and every integration binary passes except one test in node_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 base 3b530068, in a separate detached checkout on the same host.
    • node_contract::output_is_drained_beyond_retention_limit_and_failure_is_distinct: timed_out after its 3 s execution budget.

    A clean test:local still 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 on next. 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:
    • 260 generations of a 14-service graph over 9 boots (a new boot every 32 generations).
    • Every batch passes preflight_records, and one record per generation goes through the full record_intent path.
    • Before each compaction graph_slots returns exactly 14 slots. Afterwards the active directory holds only foreign records.
    • Prior boots seal. Peak allocated tombstone size is 73,728 bytes (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, and Settled maps the refusal to Retained.
  • compacted_residue_and_replay_are_never_retired_as_live: live_intent, the guard behind retire, 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 refuses graph_inventory, while a fresh archived record is still counted.
  • residue_with_an_uncertain_pending_record_refuses: covers residue() directly. In the integrated callers, read_mode already 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:
    • a trigger is removed;
    • restore compaction moves before the history capture or the fresh-owner prepare, or after the new generation allocates;
    • relay cleanup starts compacting;
    • archive stops resuming.

Negative controls (mutation)

Each mutation was applied alone to the source, the named filter was rerun, and the file was restored:

Mutation Filter Result
M3: drop compaction::reserves in graph_inventory insert (archived records) environment killed: archived_copy_of_a_compacted_record_is_refused_by_inventory
M4: drop the tombstone guard behind retire (live_intent) environment killed: compacted_residue_and_replay_are_never_retired_as_live
M7: drop the pending-file check in residue() environment killed: residue_with_an_uncertain_pending_record_refuses
M9: drop the MAX_RETIRED_SLOTS cap check environment killed: retired_slot_cap_refuses_rather_than_discards
W1: remove compaction from graph::cleanup graph::environment::tests killed: wiring guard
W2: remove compaction from restore_inputs graph::environment::tests killed: wiring guard
W3: remove resume_graph from archive_slots graph::environment::tests killed: wiring guard
B1: include non-container resources in history bindings graph::environment::tests killed: history_bindings_cover_only_attached_graph_containers
S1: map a refusal to Compacted instead of Retained environment killed: retired_slot_cap_refuses_rather_than_discards

The first commit's mutations M1, M2, M5, M6, M8 and M10 to M16 remain covered by the unchanged tests.

Remaining gaps (host-bound)

  • Call sites. The restore, ordinary-cleanup and archive call sites need an owned VM and engine. Here they are covered by the state-machine tests and the source-order wiring guard. A native run on an owned development VM is still needed to show that repeated owner-restore / serve-restore cycles of a relay-owned 14-service graph stay at one active generation.
  • M3 acceptance gate. The HACK-1193 acceptance proof stays an M3 gate: more than 256 native start/down/restart cycles with no admission refusal, no data loss, and measured allocated disk and cleanup time. The 260-generation test proves only the filesystem state machine. It measures tombstone bytes, not VM disk or native cleanup time.
  • Current-boot growth. Tombstone reads grow with the current boot's per-slot reservations (about 160 bytes per allocation) until a later boot seals them. At the 16384-entry cap, compaction is retained rather than discarding anything, until the next boot.
  • No refusal signal. A Retained outcome is not surfaced in CLI output. It shows up only as records remaining active, and eventually as the existing admission refusal.
  • Tombstone retention. Tombstone files are never removed, because they are permanent slot-ID reservations. This is now documented, but the only bound is one file per environment-attached run.
  • Local gate under load. test:local is not clean locally because of the 8 load-related subprocess timeouts listed above.
  • No TLA+ model. None was added for the compaction commit points or the restore trigger ordering.
  • Live fixture. The 84 live-fixture records from the Event Agent run were not touched.

🤖 Generated with Claude Code

roodboi and others added 2 commits October 19, 2026 11:59
…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 roodboi changed the title feat(runtime-core): compact retired environment leases after confirmed cleanup fix(runtime-core): compact retired environment lease history at restore and cleanup Oct 19, 2026
@roodboi
roodboi marked this pull request as ready for review October 20, 2026 13:58
@roodboi
roodboi merged commit 9697bfb into next Oct 20, 2026
16 checks passed
@roodboi
roodboi deleted the wf/f5-lease-compaction-b16 branch October 20, 2026 13:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant