Skip to content

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

Closed

Description

@Juliusolsson05

Symptom

The renderer's live entry window is supposed to hold at most MAX_LIVE_ENTRIES (2,000) / MAX_LIVE_ENTRY_BYTES (32 MiB) entries per session (#375). Over the 2026-09-01 → 09-03 run the renderer.session.memory.* gauges (~/.config/agent-code/performance/runs/2026-09-01T20-33-12-main-75074/metrics.jsonl, 133,564 samples per gauge) show:

gauge max
entries (length) 5,016 — 1,924 samples above the 2,000 cap
ghostMap (size) 917 in one session
seenUuids 5,077
usedJSHeapSize 1.13 GB

So the count cap was not being enforced for long stretches, and one session accumulated ~900 ghosts.

Root cause

Two interacting rules:

  1. computeProtectBound (src/renderer/src/session-runtime/liveEntryWindow.ts ~L345) lowers the trim bound to the updatedAt of every un-superseded ghost, including orphaned ones. planLiveEntryTrim then stops at the first entry at-or-newer than that bound.
  2. The 1 s sweep (useIpcSubscriptions.ts ~L495) calls orphanStale (flags, never removes) and gcSupersededGhosts (removes only superseded ghosts). An orphaned ghost that JSONL never matches — every sidecar-shaped stream (title generation, next-prompt prediction) and any ghost of a turn whose committed entry arrives under a different id — stays in runtime.ghosts for the life of the session.

Once a single orphaned ghost exists, the protect bound is pinned at its updatedAt forever: every entry ingested after that instant is "protected", the planner returns null, and runtime.entries becomes append-only. The ghost map itself also grows monotonically (917 above).

The render predicate (rendering/model/ghostPredicate.ts rule 4, mergedEntries.ts) already treats an orphaned ghost whose updatedAt <= lastJsonlEntryAt as permanently hidden ("stale orphan from earlier in the session, JSONL kept writing past it"). Such a ghost can never paint again, so it neither needs its committed owner protected nor needs to stay in memory.

Expected

  • A ghost pins the trim bound only while it can still render. Orphaned ghosts already hidden by the JSONL-tail rule must not pin it.
  • Those same ghosts are evicted from runtime.ghosts after a short grace (they are already persisted to the ghost log at the orphan transition), keeping the map bounded by "orphans newer than the JSONL tail" — which is exactly the set the fallback render needs.
  • No change to the render predicate, the orphan TTL, or which ghosts render (see docs/design/ghost-system.md Warning section).

Impact

Per-session renderer heap grows without bound with transcript length (28 MB of entries observed in one session, 5k entries), plus selectMergedEntries walks a 900-ghost map on every TileLeaf render. Contributes to the 1.1 GB renderer heap and the multi-second long tasks seen on 2026-09-03.

Environment

Agent Code main @ 6012427, Electron 43, macOS 15, 8-core M3 / 16 GB.

Refs #375, #103, #365.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions