Repository navigation
fix(control): control history prunes old unkeyed calls and their payloads (#1274) - #1330
Conversation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oads on load (#1274) The journal and payloads grew forever (129 MB after 22 days) and lived whole in memory. On load, finished calls with no request key, not reused, and older than 7 days are dropped in one atomic rewrite, then unreferenced payloads are collected. Keyed calls (the dedupe ledger) are kept forever. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…arantine evidence (#1330 review) Retention deleted evidence that is still asked for: unkeyed task origins (operations.read went not_found), outcome_unknown and pending results, and payloads an unaccepted recovery quarantine names. It also failed the whole load when the rewrite failed. These are now retained roots, a failed rewrite skips retention and GC, and the survivors are pinned. Refs #1274 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ines, parses task steps (#1330 round 2) A payload deletion failing after the rewrite made open() serve the old rows and the next append corrupt the journal; a torn-tail quarantine was never scanned, so its only-named payload was deleted; task steps were detected by byte prefix. Rewritten rows are always served, every unaccepted quarantine is evidence, and steps are parsed. Refs #1274 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review disposition (#1330): 3 reviewers, 2 rounds; head
|
| Finding | Verdict | Disposition |
|---|---|---|
A1 / C1: unkeyed task origins pruned, so operations.read returns not_found |
valid, Major | Fixed in 65175615: task origins are retained. |
| A2: payloads an unaccepted quarantine names were GC'd on the second launch | valid, Major | Fixed in 65175615. Widened in round 2 to every unaccepted quarantine. |
A3: an unkeyed outcome_unknown result was pruned |
valid, Major | Fixed: only proven-settled results are pruned; unknown, pending and unreadable are kept. |
| B1: payload GC before the rewrite was untested | valid | Fixed. A forced rewrite failure serves the unpruned rows and deletes nothing; the mutant is killed. |
| B2 / A: reuse-only target untested | valid | Pinned. |
| B3 / A: unknown-age (NaN) untested | valid | Pinned. |
| B4: non-digest GC boundary untested | valid | Pinned (.tmp kept). |
| B5: fixture shared-digest description wrong | valid | Corrected (dispatched rows). |
| C2: 7-day window is an unconfirmed destructive default | valid | Owner decision, open. The PR stays out of batches. |
| C3: plan keyed-receipt count (290) wrong | valid | Corrected to 220. |
| C survivor: 1-day window passed | valid | Pinned at 7 days ± 1 minute. |
Round 2
| Finding | Verdict | Disposition |
|---|---|---|
| a / b / c: a GC failure after the rewrite served the old rows; the next append corrupted the journal | valid, Blocker | Fixed in b3444f04: rewritten rows are always served, and deletions fail individually. Red on 65175615. |
| a / b / c: a torn-tail quarantine was never scanned, so its only-named payload was deleted | valid, Major | Fixed: every unaccepted quarantine file is evidence. Red on 65175615. |
| a / b / c: task detection by byte prefix (key order, corrupted step) | valid, Major | Fixed: the step is parsed through the integrity-checked read; unreadable is kept. Red on 65175615. |
| a / b survivors: task guard, quarantine-only digests | valid test gaps | Killed by the new cases. |
| b / c: first-load classification cost (3.2–6.9 s once) | valid, Minor | Accepted as a residual (in the body). It happens once, on the first control-history use after the upgrade, not at startup. A byte-sniffing shortcut was rejected as a guess. |
|
OWNER-APPROVED: control-history retention window = 90 days (not 7; the owner wants infrequent deletion). Tasks, quarantines and unknown outcomes are always kept (q49). |
…1330) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Disposition, owner decision (2026-09-27): 90 days. Implemented in
|
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Disposition, quick verification of the 90-day window (a: MERGE-READY).
|
…quarantine digests in GC (#1330 verification b) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review disposition, verification pass (head
|
| Finding | Disposition |
|---|---|
| b1 (Blocker): a failed directory sync after the rewrite's rename still served the pre-rewrite rows, so the next append left a gap and the journal was quarantined | Fixed. writeAtomic reports when the rename landed. After that point a failure serves the renumbered rows (with a warning) and skips payload GC for this launch, because a power loss could restore the old journal, which names those payloads. New real-file test with EIO injected at the post-rename sync: the served rows match the file, the payloads remain, the append continues the file, and the next launch has no recovery. It fails on 31d01b39. |
b2 (survivor): the quarantine digests in GC's referenced set were unpinned |
Fixed. New test: a torn-tail quarantine, then another old settled call pruned in the same launch. The quarantine-only payload survives. The test fails with the digests removed. |
Verification:
NODE_ENV=test npx vitest run src/main/control/history: 38/38.rm -rf .tsc-out; npx tsc -b: clean.- b gets a focused recheck.
🤖 Generated with Claude Code
|
Recheck (b) at 🤖 Generated with Claude Code |
Refs #1274. This PR fixes the bulk of the growth; the residuals below keep the issue open.
Problem
FileControlHistoryappended every control call toevents.jsonl, plus a payload file per prompt and result, and never removed anything. It also held the whole journal in memory. The owner's store is 129 MB after 22 days; about 110 MB of that is read-onlytranscripts.pageresults.Change
On a clean load (a launch that recovered the journal does not prune),
open()prunes whole calls that are unkeyed, finished, older than the window, and not needed as evidence. A call is kept, whatever its age, when any of these holds:requestKey: the executor's dedupe ledger (steering q12);resultrow, or its result is not proven settled. Settled means acompleted/ui_openedsuccess, anot_startedrefusal, or an MCP transport echo.outcome_unknown,pending, and a missing or unreadable payload are kept;task.*step, or cannot be read.operations.read/finishlook tasks up by it;reusedCallId;The kept rows are rewritten atomically, and only then are payload files no kept row or unaccepted quarantine names deleted; non-digest files such as
.tmpare never touched.history.list/history.readdescribe exactly what is kept.Plan:
docs/plans/2026-09-26-control-history-retention.md, including the review round and the owner question.Tests
FileControlHistory.test.ts, retention block, 13 cases. The fixture holds real journal rows from the owner (ids, times, callers, keys, digests only). Each recorded digest is remapped, in the test, to a real hashed body of the right shape; the other cases write the executor's and task writer's own row shapes..tmpstays. A second launch is clean.operations.readstill returns the completed result after the window.outcome_unknownandpendingare kept (the unknown payload stays readable);not_started,completed,ui_openedand a transport echo are pruned.Red on the round-1 head
436dc4ac: task origin, unsettled results, quarantine, and rewrite failure. Red on the round-2 head65175615: the three round-2 cases. Red on31d01b39: the directory-sync case. Mutations killed:src/main/control/: 87/87 pass.npx tsc -bis clean.Residuals (why Refs, not Fixes)
history.read/history.listdescriptions is not asserted by a test; both are correct at this head (verification a).🤖 Generated with Claude Code