Skip to content

perf(renderer): stop hidden orphan ghosts from pinning the live-entry trim - #725

Merged
Juliusolsson05 merged 3 commits into
mainfrom
perf/orphan-ghosts-unpin-trim
Sep 3, 2026
Merged

Juliusolsson05 merged 3 commits into
mainfrom
perf/orphan-ghosts-unpin-trim

Conversation

@Juliusolsson05

Copy link
Copy Markdown
Owner

Problem

The renderer's live entry window (#375) is capped at 2,000 entries / 32 MiB per session, but the two-day performance journal shows renderer.session.memory.entries at 5,016 (1,924 samples over the cap) and one session's ghostMap at 917. Full evidence in #724.

Cause: computeProtectBound lowered the trim bound to the updatedAt of every un-superseded ghost, orphaned or not, while the 1 s sweep only ever removed superseded ghosts. One orphan that JSONL never matches (every sidecar-shaped stream, for instance) pinned the bound for the rest of the session — planLiveEntryTrim returned null on every burst and runtime.entries became append-only. The ghost map itself also grew monotonically.

What changed

The render predicate's rule 4 (ghostPredicate.ts, mirrored in mergedEntries.ts) already defines a ghost that can never paint again: orphaned and at-or-before lastJsonlEntryAt, which only moves forward. This PR makes the two other consumers of the ghost map agree with that definition instead of changing it:

  • liveEntryWindow.ts: new isGhostHiddenBehindJsonlTail; computeProtectBound skips those ghosts; planLiveEntryTrim takes lastJsonlEntryAt as an optional fourth argument (null keeps the previous behaviour, so existing callers and tests are unchanged).
  • ghosts.ts: new gcHiddenOrphanGhosts reducer, reference-stable on no-op, that evicts exactly that set once the ghost has been orphaned for GHOST_SUPERSEDED_GC_MS (the orphan transition already persisted it to the ghost log).
  • useIpcSubscriptions.ts: the sweep chains the new reducer after gcSupersededGhosts; the JSONL-burst trim passes the post-burst tail; the ghost_orphan_sweep feed-debug record gains evictedCount.

Not changed, deliberately (see the Warning section of docs/design/ghost-system.md): rules 1–5, the orphan TTL, orphanStale, gcSupersededGhosts, and therefore which ghosts render. Orphans newer than the JSONL tail — the stuck-JSONL fallback — still pin the bound and still stay in the map.

Linked issues

Fixes #724. Refs #375 (the window this restores), #103, #365.

Verification

  • NODE_ENV=test npx vitest run --project unit src/renderer/src/session-runtime/ src/renderer/src/workspace/ — 33 files, 391 tests passed. New contracts in entries.test.ts (hidden orphan no longer bounds the trim; an orphan newer than the tail still does; a null tail keeps today's behaviour) and a new ghosts.test.ts for gcHiddenOrphanGhosts (evicts only orphaned, un-superseded, at-or-before-tail ghosts after the grace; keeps orphans ahead of the tail, un-orphaned ghosts, everything with a null tail; leaves superseded ghosts to the existing reducer; reference-stable).
  • --project renderer for the same directories — passed.
  • npx tsc -b --pretty false — clean.
  • After: renderer.session.memory.entries should plateau at the cap and ghostMap should stop growing on the next long run.

Known limitations

  • A hidden orphan is evicted only once the JSONL tail passes it; a session whose JSONL is genuinely stuck keeps its orphans, which is the intended fallback.
  • Mixed-clock caveat is unchanged from rule 4: renderer wall-clock updatedAt against a producer JSONL timestamp.

Plan: docs/superpowers/plans/2026-09-03-orphan-ghosts-unpin-trim.md.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Kk16MNVJtWAnCeGxGRuHqa

Juliusolsson05 and others added 3 commits September 3, 2026 12:33
The two-day journal shows the live entry window at 5,016 entries against
its 2,000 cap and one session holding 917 ghosts. Record the design before
touching the ghost subsystem, whose design doc asks for exactly that.

Refs #724

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kk16MNVJtWAnCeGxGRuHqa
… trim

computeProtectBound lowered the trim bound to every un-superseded ghost's
updatedAt, orphaned or not, and the sweep only ever removed superseded
ghosts. One orphan that JSONL never matched therefore pinned the bound for
the rest of the session: planLiveEntryTrim returned null on every burst and
runtime.entries became append-only (5,016 entries against the 2,000 cap on
the two-day journal, one session holding 917 ghosts).

The render predicate's rule 4 already defines a ghost that can never paint
again — orphaned and at-or-before lastJsonlEntryAt, which only advances. The
trim bound now skips exactly those ghosts, and the sweep evicts them after
the same grace superseded ghosts get. Orphans newer than the tail, the
stuck-JSONL fallback the ghost system exists for, still pin and still stay.
Rules 1–5, the orphan TTL, and which ghosts render are unchanged.

Fixes #724
Refs #375, #103

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kk16MNVJtWAnCeGxGRuHqa
…tion

Review follow-ups: ghostsFromSemanticTurn re-mints any block of
semantic.currentTurn whose uuid is missing, so evicting a hidden orphan of a
turn that is still current would re-create it un-orphaned with a fresh
updatedAt — churn on every tick and a reset of rule 4's clock. The sweep now
passes the current turn id and gcHiddenOrphanGhosts leaves those ghosts
alone until the turn is no longer current.

Also: the rule-4 predicate moves into ghosts.ts (the entry window imports
it, not the other way round), the grace boundary matches
gcSupersededGhosts, the sweep's debug record names evictions, the design
doc gains an eviction section and lists all three orphanedAt consumers, and
an end-to-end renderer test drives mint → orphan → history roll-off → trim
→ eviction through useIpcSubscriptions.

Refs #724

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kk16MNVJtWAnCeGxGRuHqa
@Juliusolsson05

Copy link
Copy Markdown
Owner Author

Self-review pass (adversarial review of the first cut) — resolved in HEAD:

  • Re-mint hazard (should-fix): ghostsFromSemanticTurn re-creates any missing block of semantic.currentTurn, so evicting a hidden orphan of the live turn would resurrect it un-orphaned. gcHiddenOrphanGhosts now takes the current turn id and skips those ghosts; unit test added.
  • Layering: isGhostHiddenBehindJsonlTail moved into ghosts.ts; liveEntryWindow.ts imports it (no cycle).
  • Grace boundary now inclusive like gcSupersededGhosts; orphanedAt hoisted; sweep debug summary names evictions; typo fixed.
  • Design doc: new Eviction of hidden orphans section, lifecycle diagram, orphan-TTL/IPC-wiring bullets, and the Warning now lists all three orphanedAt consumers.
  • Integration test: useIpcSubscriptions.renderer.test.tsx drives mint → orphan → history roll-off → over-cap JSONL burst trims → sweep evicts, end to end (fails on main: no trim).

Noted for follow-up issues rather than this PR: the same re-mint hazard exists in gcSupersededGhosts, and session.ts re-appends the whole ghost log on every resume.

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.

perf(renderer): orphaned ghosts pin the live-entry trim bound forever and are never evicted

1 participant