Skip to content

fix(ghosts): keep superseded ghosts of the live turn until it ends - #1228

Merged
Juliusolsson05 merged 3 commits into
mainfrom
fix/ghost-superseded-live-turn
Sep 25, 2026
Merged

Juliusolsson05 merged 3 commits into
mainfrom
fix/ghost-superseded-live-turn

Conversation

@Juliusolsson05

@Juliusolsson05 Juliusolsson05 commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Fixes #730

What was wrong

gcSupersededGhosts evicted a superseded ghost 5 s after supersedure, even while its turn was still semantic.currentTurn (for example, a tool still running). ghostsFromSemanticTurn re-mints any missing block of the current turn on every tick, so the same uuid came back without supersededBy. Its committed entry had already been reconciled, so nothing superseded it again. 30 s later it orphaned with an updatedAt newer than the JSONL tail, which is the state the render predicate still paints: a duplicate of the committed block.

Evidence (counted, not reasoned)

I scanned all 1,955 ghost logs in ~/Library/Application Support/agent-code/ghost-logs for a uuid written superseded and then written again un-superseded:

  • 3,231 re-mints in 419 logs. By provider: 2,128 Codex (resp_…) and 1,103 Claude (msg_…).
  • By block type: 3,010 tool_use, 137 text, 84 thinking.
  • 3,226 were orphaned afterwards.
  • Supersede→re-mint gap: minimum 5,040 ms, which is the 5 s grace plus the 1 s sweep. p50 was 10.2 s.

The fixture testing/fixtures/ghost-remint/ is one real Claude Bash tool_use ghost, unedited: superseded at +2.9 s, re-minted with a fresh createdAt at +8.9 s, orphaned at +38.9 s.

Fix

gcSupersededGhosts takes currentTurnId and spares that turn's ghosts. This is the same exemption gcHiddenOrphanGhosts got in #725, and the sweep in useIpcSubscriptions.ts passes the same id to both. Retention is bounded to one turn's blocks.

Tests

ghosts.remint.test.ts drives the real functions with the fixture: mint, reconcile against the committed entry, sweep at the recorded re-mint time, then the next semantic tick.

  • Fail-first: on main, the re-minted ghost has no supersededBy.
  • Mutation: dropping the exemption turns the test red.
  • The ghost is still evicted once the turn changes, or when no turn is current.
  • Renderer suite: 3,035 of 3,036 pass. The one failure is recentCommandHistory under Node 25 (localStorage.clear); it passes on Node 24, so it is environmental and unrelated.
  • tsc -b is clean.

Not verified

  • The live app (house rule).
  • The sweep wiring in useIpcSubscriptions.ts has no test; the replay harness omits the timer by design (see reconstructSlices.ts).

🤖 Generated with Claude Code

Review disposition (A Codex, B Pi; 2 rounds)

Finding Disposition
A/B: the sweep call site was untested (passing null survived every test) Fixed (3d95d941): sweepGhosts composes the three reducers and reads currentTurn itself; the composed tick is tested
B2: the supersede guard in ghostsFromSemanticTurn was unpinned Fixed: late-delta identity test
B round 2: swapping the TTL and GC timings inside sweepGhosts survived Fixed (89c0e51d): orphan-TTL test through the composed tick
A MAJOR: after a reload, a semantic replay re-mints a ghost whose superseded marker the log reduction dropped A different, pre-existing route; filed as #1233 with the reviewer's repro
B3: >= versus > at the eviction boundary Unobservable at a 1 s sweep; no change

Round 2: A and B both MERGE-READY. Recount of the on-disk evidence by B: 419 logs (exact), minimum gap 5,040 ms (exact).

Juliusolsson05 and others added 2 commits September 25, 2026 00:25
gcSupersededGhosts evicted a superseded ghost 5 s after supersedure even
while its turn was still semantic.currentTurn. ghostsFromSemanticTurn
re-mints any missing block of the current turn on every tick, so the
same uuid came back with no supersededBy; its committed entry was already
reconciled, so it orphaned 30 s later newer than the JSONL tail and
painted as a duplicate of the committed block.

The ghost logs on disk held 3,231 of these re-mints in 419 of 1,955 logs
(2,128 Codex, 1,103 Claude; 93% tool_use), each at least 5,040 ms after
supersedure: the 5 s grace plus the 1 s sweep. The exemption is the one
gcHiddenOrphanGhosts already has.

Fixes #730

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of #1228: the fix was one argument at the call site, and passing
null there type-checked and survived every test. The sweep's three
reducers now compose in ghosts.ts, which derives currentTurnId from the
runtime slice; the timer calls it. Tests drive the composed tick and pin
the supersede guard in ghostsFromSemanticTurn (both mutants now killed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Juliusolsson05 added a commit that referenced this pull request Sep 25, 2026
…question, #1228/#1229 round 2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Round-2 review of #1228: swapping the two timings inside sweepGhosts
passed every test. A ghost 10 s quiet must stay live and orphan after
30 s; the swap now fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Juliusolsson05
Juliusolsson05 merged commit 6bc0ade into main Sep 25, 2026
2 checks passed
@Juliusolsson05
Juliusolsson05 deleted the fix/ghost-superseded-live-turn branch September 25, 2026 08:29
Juliusolsson05 added a commit that referenced this pull request Sep 25, 2026
Keeps #1228's sweepGhosts and drops ghostsToPersist, which this branch
removes along with the on-disk log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Juliusolsson05 added a commit that referenced this pull request Sep 25, 2026
#1234 opened

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(renderer): superseded ghosts of the live turn are evicted and re-minted un-superseded

1 participant