Skip to content

fix(storage): prune orphaned ghost logs; protect a ghost log only while its session exists - #1223

Merged
Juliusolsson05 merged 3 commits into
mainfrom
fix/ghost-log-orphan-retention
Sep 25, 2026
Merged

Juliusolsson05 merged 3 commits into
mainfrom
fix/ghost-log-orphan-retention

Conversation

@Juliusolsson05

@Juliusolsson05 Juliusolsson05 commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Problem (#732, class C6)

isProtectedFromDebugPrune protected the whole ghost-logs bucket from every pass. The ghost-logs cap (8 % of the budget) could never act. Startup compaction rewrites every file, so none ever aged out either.

Measured on the owner's machine (2026-09-25): 1,952 ghost logs, 2.1 GB. Of those, 1,935 (2,128 MB) belong to sessions no longer in any window's workspace; only 17 (49 MB) are real recovery state.

Change

  • A ghost log is protected while its session exists: in any window's committed workspace (WorkspaceFileStore.sessionIds()) or running in the session manager (a brand-new pane can run before its first save).
  • index.ts sets that owner provider right after the workspace store opens, and retention reads it once per prune.
  • Orphaned logs go through TTL, cap and budget like any other debug output. They still get the 10-minute active grace, so a pane closed a moment ago can still be restored by undo.
  • With no owner set yet (the first prune runs before the store opens), a provider that throws, or a read-only workspace store (workspace.json unreadable, corrupt or newer; the store then deliberately holds an EMPTY file), everything stays protected. The last case came from the steering review: an empty set there is not evidence of orphans.

Tests (fail-first)

  • Orphans are trimmed oldest-first under the cap, while an older owned log stays.
  • An orphan ages out; an owned log does not.
  • A fresh orphan inside the grace is kept.

Unprotecting all ghost logs (mutation) turns two tests red; the original code fails the first two.

Verification

npx tsc -b clean; src/main/storage passes (8 files, 101 tests).
Not verified here: a real prune over the 2.1 GB directory in the running app. The first scheduled prune after this lands should free about 1.9 GB (the cap is 8 % of the debug budget).

Out of scope

#731 (resume re-appends the whole ghost log) makes owned logs grow per resume. That is its own fix.

Fixes #732

🤖 Generated with Claude Code

Review disposition (round 1 → round 2, reviewers A/B/C)

Finding Disposition
B1 MAJOR: missing/empty/partial workspace counted as "no owners" Fixed in 78934bf2: owners are unknown unless parseWorkspaceFile reports complete (sessionOwnershipKnown())
B2/A/C: wiring + throw guard untested (M6, M7, M12 survived) Fixed: debugRetention.ghostOwners.test.ts runs through pruneDebugStorage on real files
B3: false "undo reads the old log" comment Fixed: comments now match the code and cite #1225
Budget pass not covered for ghost logs (M10/M11) Accepted residual: the age and cap passes are covered, and the budget pass shares the same isProtectedFromDebugPrune gate
Suspicion: HOME override on macOS, v2→v3 buried rows Not proven; real-data count is 0
A: no code reads ghost logs on restore Filed as #1225, fixed in #1227 (reads under the persisted id, which this PR protects)

Round 2: A, B and C all MERGE-READY on 78934bf2.

Juliusolsson05 and others added 2 commits September 24, 2026 23:14
…le its session exists

Every ghost log was protected from every retention pass as a bucket, so
the ghost-logs cap could never act, and startup compaction rewrites each
file, so none ever aged either. Measured on the owner's machine: 1,952
ghost logs, 2.1 GB, of which 1,935 (2,128 MB) belonged to sessions no
longer in any window's workspace; only 17 (49 MB) were real recovery state.

A ghost log is recovery state while its session exists: in any window's
committed workspace, or running in the session manager. Main now provides
that owner set to retention (read once per prune). Owned logs stay fully
protected however old; orphaned logs go through TTL, cap and budget like
any other debug output, still behind the 10-minute active grace (so a pane
closed a moment ago can be restored by undo). With no owner set yet (the
first prune runs before the workspace store opens) or a failing provider,
everything stays protected as before.

Tests: orphans trimmed oldest-first under the cap while an older owned log
stays; an orphan ages out and an owned log does not; a fresh orphan is kept
inside the grace. Unprotecting all ghost logs turns two red.

Fixes #732

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

Steering review (urgent, before review): WorkspaceFileStore loads an EMPTY
file and goes read-only when workspace.json is unreadable, corrupt or from
a newer version, precisely so the user's real data is not overwritten. Its
sessionIds() is then empty although the file on disk may own every
session, and the provider handed retention that empty set as 'no owners',
which would delete every ghost log in recovery mode.

ghostLogOwnersFrom() now returns null (unknown, so protect all) for a
read-only store, and main uses it. Tests: a read-only store yields null and
an old owned log survives even an over-cap prune; a healthy store's set is
its sessions plus the running ones. Removing the read-only guard turns the
first red.

The 1,935-orphan measurement matched every session-id-shaped UUID anywhere
in workspace.json (all committed windows), not one window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e.json was read

Review round 1 on #1223 (2 Claude + 1 Pi):
- Major (B): a MISSING or empty workspace.json is a fresh install to the
  store, not read-only, so sessionIds() was an empty set that retention
  trusted, deleting every ghost log of a workspace the user had only moved
  aside (this machine's state dir shows the habit). The store now records
  whether this run read a COMPLETE file (parser completeness, as
  tmuxRecovery already does), and ghostLogOwnersFrom() returns unknown
  otherwise: missing, empty, partial or unreadable. A later save does not
  upgrade it. Store tests use the real sanitized v3 workspace for the
  trusted case and a damaged copy for the partial one.
- Major (A, test gap): the provider -> policy -> prune wiring and the
  throw-means-unknown guard were removable with everything green. New
  real-file test through pruneDebugStorage: an old orphan is deleted and an
  old owned log kept; unknown owners and a throwing provider delete nothing.
  Each mutation (wiring dropped, throw read as 'no owners', completeness
  ignored, trust by default) now turns a test red.
- Comments (A, B): the recovery-contract wording claimed ghost logs are
  what a restored pane rebuilds from; the review found nothing has read one
  on restore since 0cb71e9 (filed as #1225), and that undo respawns under a
  fresh id. Comments now say what the code does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Juliusolsson05
Juliusolsson05 merged commit e19cdbc into main Sep 25, 2026
2 checks passed
@Juliusolsson05
Juliusolsson05 deleted the fix/ghost-log-orphan-retention branch September 25, 2026 07:02
Juliusolsson05 added a commit that referenced this pull request Sep 25, 2026
…round-1 fixes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

bug(storage): ghost logs are protected from every retention pass, contradicting the cap comment

1 participant