Skip to content

fix: bound the small unbounded-growth items from the C6 hunt (#1278) - #1417

Merged
Juliusolsson05 merged 25 commits into
integration/batch-2026-09-27-tfrom
fix/c6-small-unbounded
Sep 27, 2026
Merged

Juliusolsson05 merged 25 commits into
integration/batch-2026-09-27-tfrom
fix/c6-small-unbounded

Conversation

@Juliusolsson05

@Juliusolsson05 Juliusolsson05 commented Sep 27, 2026 •

Copy link
Copy Markdown
Owner

Closes #1278 (the C6 hunt's small unbounded-growth items). Also carries #1251 row 13 (a malformed legacy-ledger row stopped all pruning), moved from #1411 because it and steering q109 change the same ledger loader. Per-item evidence and decisions: docs/plans/2026-09-27-c6-small-unbounded.md.

Every item was verified on origin/main 5e22c7b0 first. Each fix has a test that fails without it: fail-first, or with the fix removed as a mutation. Final head 8b3928d7: the code and both B6 pins were manager-verified at 1c0702f6, and the later commit only corrects a comment.

Owner rule "do not delete stuff often": these fixes bound memory and repair a cleanup that was already intended. None adds a new deletion policy for user-visible data.

# Item Change
2 Empty proxy parent dirs (2,978 on the author's machine, walked on every prune) A real bug. The intended sweep called rm(dir, { recursive: false }), which always throws EISDIR (checked on Node 24.14.1), so no parent was ever removed. Now rmdir, which is atomic against a concurrent new run (ENOTEMPTY) and bounded by root + sep. A real-filesystem test is red on main.
3a 18.4 MB legacy ledger re-parsed every 5 min Cached by file identity (inode + ctime + mtime + size), re-checked by a second stat after the read: a ledger replaced or edited mid-read is 'unknown'. Absent and unreadable are both 'unknown' (steering q109 and review round 2): never cached, and every legacy bundle is protected for that prune. This also closes main's windows where a transient read failure, or a ledger moved aside during a prune, made hand-saved bundles deletable. Pinned: the absent, unreadable-then-readable, two rename-race and same-size-edit-with-mtime-set-back cases, and the inode in the key (B6 pin).
1 Paste-debug journal writers, one per paste forever Writers: capped at 64 with oldest-first eviction. flushAll awaits evicted flushes (the dictation #1276 shape), and a re-created writer is ordered after the evicted one. Failed appends: a failed batch is re-queued at the front, in order (B6 pin). The queue is bounded at 1000 lines, and overflow writes a journal:dropped-lines record. The timer's drain is caught, so a failed timed append is never an unhandled rejection, and a flush that joins a failing drain rejects instead of reporting success.
8 retiredWebContentsIds tombstones ("cleared with the registry" only happened in tests) Tombstones expire 10 minutes after the window closes, on performance.now() (monotonic, so a wall-clock step back cannot strand stale entries), and are swept whenever another window closes. Test red on main.
6 Conversation caches Codex heads: swept against the scope-independent walk (exact); mutant red. Codex rolloutPaths: LruMap(4096) (the recorded corpus has 2,023 indexed threads). Claude summaries: swept per listed project directory, and against the root listing for vanished project directories. Search promptCache: LRU 1024 through a new shared LruMap, which now also backs promptFolder; its private LRU functions are removed, and its 13 tests are unchanged and green.

Residual or intentional, not changed (reasons in the plan):

  • 3a cost (accepted, round 2 a): with no ledger at all, pre-split legacy bundles are never aged out. The owner's "do not delete stuff often" is applied to evidence we cannot classify.
  • Emptied ledger: a readable but emptied ledger parses as "no manual bundles". Only a manual edit produces one; an empty file is not unknown.
  • EACCES summary survivor: the Claude summary cache keeps a directory whose listing fails with EACCES (an EACCES-as-gone mutant survives). Keeping on an unknown error is the intended fail-closed direction, and the cost is memory only.
  • Declined survivors (test gaps, not production gaps):
    • the rolloutPaths set and its 4096 bound (the Codex search falls back to SQLite, so no test sees the cache);
    • the promptFolder test hook;
    • the search-cache cap (shared LruMap semantics are pinned in lruMap.test.ts).
  • 3b: the autosave ledger's append-only design is a stated choice, and trimming it is a deletion policy.
  • 4: the WorkflowBridge maps follow the durable store. Workflow runs and workflow codex-home sessions are never pruned (1.3 GB) #1275's retention bounds that store.
  • 5: lastActivityAt is kept on purpose (WHY at sessionManager.ts:1095).
  • 7: cli-update-logs (13 files, 52 KB here) is kept on purpose per its WHY.
  • 9: tracked by perf(diagnostics): default-off and off-thread the always-on diagnostic subsystems #767 item 3.

Verification:

  • npx tsc -b: 0 lines.
  • Touched suites green on the merged tree (after fix: one bad record no longer fails the whole set (C5 rows 8–12, #1251) #1411): codex 9, storage 19 + 1, monitor 16, paste 6, window 17, lruMap 2, promptFolder 13, claude 7, service 5. Manager check: 50/50.
  • A combined run under a load average of about 45 once hit 5 s timeouts; the same files passed one at a time. Reported, not widened.

Overlap:

🤖 Generated with Claude Code

Juliusolsson05 and others added 6 commits September 27, 2026 05:08
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d the legacy ledger parse is cached (#1278)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… search prompt cache via a shared LruMap (#1278)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e and is never cached (steering q109)

A transient read failure (EACCES, EMFILE) returned an empty manual set, which
put every hand-saved legacy bundle in the deletable bucket; the identity cache
then kept that empty set after access recovered. Now a failed stat/read is
'unknown': not cached, and every legacy bundle is protected for that prune.
Only ENOENT means no ledger. The identity key adds inode and ctime, so a
rename-replace or a same-size edit with the mtime set back re-parses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Juliusolsson05 Juliusolsson05 added type:bug Something works wrong class:C6-unbounded Unbounded growth sev:P3 Minor labels Sep 27, 2026
Juliusolsson05 and others added 2 commits September 27, 2026 05:16
…runing (#1251 row 13)

Moved here from #1411 (manager q109: coordinate the overlap). Row 13 and
steering q109 both change loadManualLegacyBundlePaths, so they ship together.
A JSON-valid non-entry line (null, a number, a non-string bundlePath) threw,
which rejected collectArtifacts and stopped every prune pass. Rows are now
shape-checked; a non-string reason counts as manual, so the bundle is kept.
Adds a test through the real loader and cache: #1411 review (b) found that
replacing the loader's parse with an empty set survived the parser-only test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…codex

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Juliusolsson05 added a commit that referenced this pull request Sep 27, 2026
Row 13 of #1251 and steering q109 on #1417 both change
loadManualLegacyBundlePaths, so the row ships with #1417 (commit 02df3652
there), with a test through the real loader. This PR no longer touches
debugRetention.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Juliusolsson05 and others added 5 commits September 27, 2026 05:48
…ss the read (#1417 review a)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r appends after the evicted one (#1417 review b, c)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lose keeps queued saves (#1417 review b)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…bound Codex rolloutPaths (#1417 review c)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Juliusolsson05

Juliusolsson05 commented Sep 27, 2026 •

Copy link
Copy Markdown
Owner Author

Review disposition (round 1: a, b, c codex at 6150238a, all FIX-BEFORE-MERGE)

Fixed at 62091493. Per-finding detail is in docs/plans/2026-09-27-c6-small-unbounded.md (review table).

Finding Verdict Disposition
a, major: a ledger renamed between stat and read gets cached as empty, so a manual bundle becomes deletable valid Fixed: re-stat after the load; any identity change → 'unknown' (protective, not cached). Fail-first for "still away" and "renamed back".
a survivor: root + sep valid Pinned with a sibling-root fixture.
a survivor: ino in the identity declined Not isolatable portably (a rename-replace also moves ctime). Kept as defence in depth.
b + c, major: an evicted paste writer with an in-flight append escaped the shutdown drain, and a re-created writer could reorder the file valid Fixed: flush() joins the in-flight append, and a re-created writer waits for the evicted flush. Gated-append test; both mutants red.
b, major: the tombstone count cap could drop a queued final save during a mass close valid Fixed: the bound is now age (10 min), not count. Red under the count cap.
c, major: scoped Claude discovery kept summaries of gone files in listed directories valid Fixed: summaries are swept per enumerated directory. Own-corpus test.
c, minor: Codex rolloutPaths unbounded valid Fixed: LruMap(4096).
c: plan 3b stale valid Corrected.
b survivors: the promptFolder hook peek vs get; a search-cache cap of 1 declined A test-only inspection hook and an untimed performance contract. The LruMap semantics are pinned in lruMap.test.ts.

Round 2 (a, b, c codex at 62091493): all FIX-BEFORE-MERGE, the final round

Every round-1 reproduction verified fixed; the control mutants are red. Fixed at 02c7c918:

Finding Verdict Disposition
a, major: a ledger absent for the whole prune made manual bundles deletable valid Fixed: an absent ledger is 'unknown', protecting every legacy bundle, never cached. Cost stated: without a ledger, pre-split legacy bundles never age out. Fail-first.
a, major: a removed Claude project directory kept its summaries valid Fixed: swept against the scope-independent root listing, plus ENOENT while listing; a failed listing keeps them. Fail-first.
b, major; a and c, minor: wall-clock steps broke tombstone aging valid Fixed: monotonic performance.now(). Wall-clock-step test; the Date.now mutant is red.
c, major; a and b, minor: a failed paste append lost its batch, rejected unhandled, and a joining flush resolved valid Fixed: the batch is re-queued in order, bounded at 1000 lines with a journal:dropped-lines report; the timer catches; both joining flushes reject on a dead disk. Red at the previous head; the bound is pinned.
survivors: the rolloutPaths bound; the promptFolder hook / cache cap declined Shared LruMap semantics, pinned in lruMap.test.ts. Pinning the field would need over 4096 indexed rows or reaching into the private map; a miss costs only the SQLite lookup.

Both review rounds are spent. 02c7c918 needs a manager-verify (a, b, c) before the member gate.

Loader ownership (manager q118)

This PR owns the manual-ledger loader, and #1388 (W2, owner-held) rebases onto it after this merges. The contract that must survive that integration:

  • cachedManualLegacyBundlePaths() returns Set<string> | 'unknown'. Absent (ENOENT) and unreadable are both 'unknown', never an empty set.
  • A parse is trusted only when the ledger's identity (inode, ctime, mtime, size) is unchanged across the read.
  • 'unknown' is never cached.
  • legacyDebugBundleBucketForPath(path, 'unknown') returns debug-bundles-manual, so every legacy bundle is protected for that prune.

#1388's current loadManualLegacyBundlePaths still maps ENOENT to an empty set, and it answers unreadable with null (skipping legacy collection). After the rebase it should call this PR's loader and classifier rather than keep its own, so the stricter rule wins. Skipping collection instead of protecting is also fail-closed; only one of the two should remain.

Pinned by debugRetention.test.ts: "protects every legacy bundle while the ledger is absent…", the unreadable-then-readable sequence, the two rename-race cases, and the same-size edit with the mtime set back.

Main merged (0de17781) for a real conflict: #1376's .1-only retention test and this PR's tests were added at the same anchor in debugRetention.test.ts, and the resolution keeps both. debugRetention.ts auto-merged: #1376's .1 run markers sit beside this PR's parent sweep. On the merged tree npx tsc -b prints 0 lines, and every touched suite passes (storage 19, paste 5, window 17, lruMap 2, promptFolder 13, claude 7, codex 7, service 3). No source change beyond the merge; the manager-verify target is otherwise 02c7c918.

Manager verification: two survivors pinned (test-only)

Survivor Test Mutant
The inode in the ledger cache key debugRetention.ledgerIdentity.test.ts: stat is controlled for the ledger path only, with equal size, mtime and ctime and a different inode; the content is real. A real rename always moves ctime, so the inode can't be isolated on a real filesystem. Removing the inode: red
The paste retry order pasteDebugJournal.test.ts: a held write fails while a line is appended during it; the file must read first, second. Re-queueing at the back: red

npx tsc -b prints 0 lines; storage and paste suites green.

Residuals (stated, not blocking):

  • A readable but emptied ledger parses as "no manual bundles". Only a manual edit produces one; an empty file is not unknown.
  • The summary cache keeps a directory whose listing fails with EACCES (an EACCES-as-gone mutant survives). Keeping on an unknown error is the intended fail-closed direction, and the cost is memory only.
  • The declined survivors: the rolloutPaths bound, the promptFolder test hook, and the search-cache cap (shared LruMap semantics pinned in lruMap.test.ts).

Main merged again (1c0702f6) for a real conflict after #1411 merged: both PRs added tests at the same anchor in codex.system.test.ts, and the resolution keeps both. codex.ts auto-merged (#1411's per-row guard sits beside this PR's heads sweep, and rolloutPaths is an LruMap with the same set/get). On the merged tree npx tsc -b prints 0 lines and every touched suite passes (codex 9, storage 19 + 1, monitor 16, paste 6, window 17, lruMap 2, promptFolder 13, claude 7, service 5). There is no source change beyond the merge; the manager-verify target is otherwise 00a6ccbb.

Manager verification and READY

B6 verified the code and both pins at 1c0702f6 (manager-verify a/b/c, MERGE-READY once the body and one comment were corrected): a1–a4 are fixed and killed; 50/50 pass.

  • Body corrected:
    • 3a: absent = 'unknown', protected, never aged;
    • 8: a 10-minute monotonic TTL;
    • the Claude summaries sweep;
    • the paste retry and the 1000-line cap;
    • the full residual list (the a1 cost, the emptied ledger, the EACCES summary survivor, the declined survivors).
  • Comment corrected at cachedManualLegacyBundlePaths in 8b3928d7: comment-only; the second stat now guards a ledger replaced or edited mid-read, and the loader already answers ENOENT with 'unknown'.

Main merged (d3745374) after batch Q, which carried #1434 and conflicts with this PR in codex.ts:

Main merged again (6cd622f4) after batch R, which carried #1388 and conflicts in debugRetention.ts. Both sides are kept:

merge-gate.sh agent-code 1417 --member --dry: GATE PASS (6cd622f4, 0 behind main, checks green). READY.

Juliusolsson05 and others added 5 commits September 27, 2026 07:19
…egacy bundle (#1417 review round 2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t are gone (#1417 review round 2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…jects unhandled, never flushes as success (#1417 review round 2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Juliusolsson05 added a commit that referenced this pull request Sep 27, 2026
W4's #1417 owns loadManualLegacyBundlePaths and treats an absent ledger as
unknown, stricter than this branch's ENOENT-means-empty. Keep only the
dirStats fix; use #1417's loader as-is after it merges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	src/main/storage/debugRetention.test.ts
Juliusolsson05 added a commit that referenced this pull request Sep 27, 2026
Juliusolsson05 and others added 2 commits September 27, 2026 09:35
…e-parsed (#1417 manager verification)

Removing the inode from the cache key passed every test: a real rename moves
ctime, so no portable filesystem sequence isolates it. stat() is controlled
for the ledger path only (size, mtime, ctime equal; the inode differs); the
content is real. The no-inode mutant is red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…while it wrote (#1417 manager verification)

Re-queueing a failed batch at the back passed every test. A held write that
fails, with a line appended during it, must land first/second in order; the
back-requeue mutant is red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Juliusolsson05 and others added 2 commits September 27, 2026 12:16
# Conflicts:
#	src/main/conversations/sources/codex.system.test.ts
…loader (#1417 manager verify)

Comment-only. The comment still said the loader "rightly" answers ENOENT
with "no ledger"; since round 2 an absent ledger is 'unknown', and the
second stat now guards a ledger replaced or edited mid-read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Sep 27, 2026
Juliusolsson05 and others added 2 commits September 27, 2026 14:33
Real conflict: batch Q's #1434 and this PR both changed codex.ts's imports
(#1434 adds ConversationPromptsUnreadable and friends from ./types.js, this
PR adds LruMap). Both kept.

Semantic conflict: #1434's promptsUnreadable.system.test.ts reads the
private rolloutPaths as a Map (.has/.get), which this PR bounded as an
LruMap. The two Codex tests now read it with peek(), which does not touch
recency, so the lookup cannot change what the source evicts next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Real conflict with #1388 in debugRetention.ts, both sides kept:
- imports: this PR's rmdir/sep (parent sweep) + #1388's writeFile/relative
  (key-log baseline);
- collectArtifacts: this PR's cachedManualLegacyBundlePaths() stays the one
  ledger read (q118: absent or unreadable ledger = 'unknown', every legacy
  bundle protected, never cached), and #1388's keyLogOnlyBaseline capture
  is added after it. #1388 already calls this PR's loader; it keeps no loader
  of its own.

tsc -b 0 lines; storage, conversations, paste, window, lruMap and
performance suites 413/413.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Juliusolsson05
Juliusolsson05 changed the base branch from main to integration/batch-2026-09-27-t September 27, 2026 22:23
@Juliusolsson05
Juliusolsson05 merged commit 408eca9 into integration/batch-2026-09-27-t Sep 27, 2026
2 checks passed
@Juliusolsson05
Juliusolsson05 deleted the fix/c6-small-unbounded branch September 27, 2026 22:23
Juliusolsson05 added a commit that referenced this pull request Sep 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

class:C6-unbounded Unbounded growth sev:P3 Minor type:bug Something works wrong

Projects

None yet

1 participant