Skip to content

feat(renderer): live transcript window + memory instrumentation + raw-payload compaction - #511

Merged
Juliusolsson05 merged 3 commits into
mainfrom
feat/renderer-live-window
Jul 9, 2026
Merged

Juliusolsson05 merged 3 commits into
mainfrom
feat/renderer-live-window

Conversation

@Juliusolsson05

Copy link
Copy Markdown
Owner

Closes #375.

The renderer's per-session runtime grew without bound on the live path: initial history loads 120 entries and older pages load 200, but every live jsonl burst appended forever ([...baseEntries, ...appended]), and several side structures (tool indices, seen-uuid sets, raw semantic/debug payload rings) never shrank. This PR bounds the live transcript window by count, makes what's trimmed reloadable, instruments every structure the investigation flagged, and stops retaining raw event payloads by default. Hibernation of background agents is deliberately out of scope (deferred by design).

Part A — memory instrumentation

  • New performance/memoryInstrumentation.ts, driven by a 30s interval in useIpcSubscriptions (separate from the 1 Hz ghost sweep — sweep cadence is a correctness knob, gauge cadence a cost knob).
  • Per-session gauges (renderer.session.memory.*): entries count + byte estimate, semantic history/log byte estimates, feed-debug ring length, ghost map size, tool_use/tool_result index sizes, seen-uuid + trimmed-uuid set sizes.
  • Global gauges: code-block registry size; Monaco model count via a probe registry (lib/code/monacoModelProbe.ts) registered from inside getMonaco() — instrumentation can never force the Monaco lazy chunk, and the workspace hook's module graph never imports monaco-editor (which the renderer vitest project cannot even resolve).
  • Byte estimates sample ≤64 evenly-spaced items and extrapolate. Ingest-time accounting would tax the hot append path and need decrement bookkeeping at every trim/reload site; full stringify per tick is the exact workload being instrumented. A trend gauge only needs order-of-magnitude fidelity.

Part B — the live entries window (the core)

New session-runtime/liveEntryWindow.ts + wiring in the jsonl burst handler:

  • Trim fires when entries.length > MAX_LIVE_ENTRIES (2000, deliberately generous) down to TRIM_TO_LIVE_ENTRIES (1500) — hysteresis so the index rebuild + ledger recompute is paid ~once per 500 appends.

Trim-safety constraints (each one load-bearing):

  1. totalEntries is never decremented — it is the on-disk denominator; entries was always documented as a window over it.
  2. Ownership bound: the trim boundary stays strictly older than the oldest semantic.history turn's startedAt, currentTurn.startedAt, and every un-superseded ghost's updatedAt. Trimming a live turn's committed owner would defeat committed-ownership suppression and re-paint duplicates — the fix(feed): keep semantic history visible while JSONL catches up #165/Fix stale semantic web-search rows sticking at bottom after rollout commit #191/Semantic tool and edit rows can disappear without diagnostics explaining the suppression reason #344 regression class. Superseded ghosts don't protect (they're already scheduled for GC). Optimistic rows are never trimmed.
  3. uuid guard: any uuid-less entry anywhere freezes the window. Uuid-less entries can't reload dedupe-safely through pagination, and the ledger's entry:ingest-${index} fallback ids (comment updated in observations/committed.ts) assume unshifted indices — a front-trim would re-key a retained uuid-less entry into an id previously owned by a different entry (the phantom-duplicate React-key class). In practice every mapped entry carries a uuid, so this is a freeze-don't-corrupt failsafe.
  4. Pagination reload: all three ingest sites (live burst, initial history, older history) now stamp the mapper's historyMarker on each entry as a non-enumerable symbol rider — chosen over a parallel uuid→marker map because a rider dies with the entry (no new unbounded structure, no extra lifecycle plumbing) and is invisible to JSON.stringify (debug bundles, byte sampling). On trim, historyOldestMarker advances to the oldest retained entry's marker and hasOlderHistory flips true; if no retained marker exists the trim aborts rather than strand the region.
    Asymmetric dedupe: live ingest treats seen ∪ trimmed as already-seen (a resume bootstrapTail replay must not re-append old rows at the tail); the older-history path lets trimmed uuids back through, releasing them from the set as they reload. Trimmed-uuid bookkeeping follows the seenUuids lifecycle at all six teardown/reset sites (pane close, kill, rename ×2, restart-in-place, tab close).
  5. Index rebuild: toolUseIndex/toolResultIndex are rebuilt in place from retained entries (per-id deletion breaks cross-entry pairings — a retained tool_result resolves its command via the map) with a toolIndexVersion bump. loadOlderHistory now also indexes paged-in entries — previously a latent gap, load-bearing after a rebuild so a reloaded trimmed region restores its pairings.
  6. Scroll safety: no trim while loadingOlderHistory or within a 30s grace after an older-history prepend — Feed has no shrink-anchoring, and a user who just paged history in is reading exactly the rows a trim would remove. Skipping is always safe; the next quiet burst trims.
  7. Debuggability: every trim appends a feed-debug record (kind: 'entries_trimmed', layer STATE) with trimmed/retained counts, the new marker, and the protection bound.
  8. Ledger unchanged: adapter caches are keyed on the entries array reference — a trim mints a new array and costs exactly one clean recompute.

Part C — raw-payload compaction

  • semantic.log entries no longer retain raw: ev by default (SemanticLogEntry.raw is now optional); feed-debug SEM entries store { eventType } instead of the entire semantic event (the summary field already carries the digest). Both capture raw only when semantic/rawCapture.ts is enabled, which rides the existing AGENT_CODE_DEV_DEBUG probe (App.tsx → getDevDebugConfig) — raw payloads are only readable through dev-debug surfaces, so capture and surface share one switch. No consumer reads .raw outside JSON-dumping debug exports, verified by grep.
  • semantic.history turn block maps deliberately NOT compacted: they are paint inputs for the ledger's semantic-history plane, and compacting safely requires committed-suppression knowledge that lives in the decide layer. Decision recorded next to semanticHistoryRow in semantic/helpers.ts.

Verification

  • npx tsc -b tsconfig.node.json — clean.
  • npx tsc --noEmit -p tsconfig.web.json — clean (no TS6305/TS6307 either).
  • NODE_ENV=test npx vitest run — 476 passed (467 on main + 9 new); the full rendering corpus (bundleCorpus, recordingCorpus, replay invariants: 18 files / 151 tests) green. The agent-voice-dictation hotkeyBinding suite failure is pre-existing on main.
  • Tests extended in existing files only: session-runtime/entries.test.ts (trim planner constraint matrix + marker-rider invisibility) and useIpcSubscriptions.renderer.test.tsx (burst-handler trim wiring: window applied, totalEntries untouched, pagination re-anchored, entries_trimmed record, tail-replay dedupe).

Closes #375

🤖 Generated with Claude Code

Juliusolsson05 and others added 3 commits July 9, 2026 11:27
…-payload compaction

Closes #375 — the renderer's per-session runtime grew without bound on
the live path. Three parts, one PR:

Part A — memory instrumentation (performance/memoryInstrumentation.ts):
- 30s per-session gauges (renderer.session.memory.*): entries count +
  sampled byte estimate, semantic history/log byte estimates, feed-debug
  ring length, ghost map size, tool_use/tool_result index sizes,
  seen-uuid + trimmed-uuid set sizes.
- Global gauges: code-block registry size, Monaco model count via a
  probe registry (lib/code/monacoModelProbe.ts) registered from inside
  getMonaco() so instrumentation can never force the Monaco lazy chunk
  (and the workspace hook's import graph never touches monaco-editor).
- Byte estimates sample up to 64 evenly-spaced items and extrapolate —
  ingest-time accounting would tax the hot path and full stringify per
  tick is the workload being instrumented; a trend gauge only needs
  order-of-magnitude fidelity.

Part B — the live entries window (session-runtime/liveEntryWindow.ts):
- The jsonl burst handler trims entries above MAX_LIVE_ENTRIES (2000)
  down to TRIM_TO_LIVE_ENTRIES (1500). Hysteresis so the rebuild cost is
  paid ~once per 500 appends, not per burst.
- totalEntries is NEVER decremented — it is the on-disk denominator and
  entries was always documented as a window over it.
- Trim boundary stays strictly OLDER than: the oldest semantic.history
  turn startedAt, currentTurn startedAt, and every un-superseded ghost
  updatedAt — trimming a live turn's committed owner would defeat
  committed-ownership suppression and re-paint duplicates (the
  #165/#191/#344 regression class). Optimistic rows are never trimmed.
- Any uuid-less entry anywhere freezes the window: uuid-less entries
  cannot reload dedupe-safely AND the ledger's entry:ingest-index
  fallback ids (committed.ts, comment updated) assume unshifted indices.
- Pagination can RELOAD the trimmed region: every ingest site stamps the
  provider historyMarker on the entry as a non-enumerable symbol rider
  (dies with the entry — no new unbounded map); on trim,
  historyOldestMarker advances to the oldest retained marker and
  hasOlderHistory flips true.
- Asymmetric dedupe: live ingest treats seen ∪ trimmed as already-seen
  (a resume bootstrapTail replay must not re-append old rows at the
  tail); the older-history path lets trimmed uuids back through and
  releases them from the set as they reload. Trimmed-uuid bookkeeping
  follows the seenUuids lifecycle at all six teardown/reset sites.
- toolUseIndex/toolResultIndex are rebuilt in place from retained
  entries on trim (per-id deletion would break cross-entry pairings)
  with a toolIndexVersion bump; loadOlderHistory now also indexes
  paged-in entries so a reloaded trimmed region restores its pairings
  (previously a latent gap, load-bearing after a rebuild).
- Scroll safety: no trim while loadingOlderHistory or within 30s of an
  older-history prepend (Feed has no shrink-anchoring).
- Every trim leaves a feed-debug record (kind entries_trimmed, STATE)
  so debug bundles stay explainable.
- The ledger pipeline needs no changes: adapter caches key on the
  entries array reference, so a trim is one clean recompute.

Part C — raw-payload compaction:
- semantic.log entries no longer retain the raw event by default
  (SemanticLogEntry.raw is optional); feed-debug SEM entries store
  { eventType } instead of the whole semantic event. Both capture raw
  only when semantic/rawCapture.ts is enabled, which rides the existing
  AGENT_CODE_DEV_DEBUG probe (App.tsx getDevDebugConfig) — the payloads
  are only readable through dev-debug surfaces, so capture and surface
  share one switch.
- semantic.history block maps deliberately NOT compacted: they are
  paint inputs for the ledger's semantic-history plane; decision
  recorded in semantic/helpers.ts.

Tests: extended session-runtime/entries.test.ts (trim planner constraint
matrix + marker rider invisibility) and
useIpcSubscriptions.renderer.test.tsx (burst-handler trim wiring +
tail-replay dedupe). Full suite 476 passed; the agent-voice-dictation
hotkeyBinding suite failure is pre-existing on main. tsc -b
tsconfig.node.json and tsc --noEmit -p tsconfig.web.json both clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…straint, byte budget, gauge parity

- Constraint 5 in planLiveEntryTrim: the trim boundary may not separate a
  cross-entry tool pair — any retained tool_result whose tool_use would be
  trimmed pulls the boundary older, so the post-trim index rebuild is
  pair-complete by construction (the review's phantom-degraded-rows class).
- The window is now byte-driven as well as count-driven (#375's actual
  ask): MAX_LIVE_ENTRY_BYTES 32MiB -> trim toward 24MiB, estimates via a
  WeakMap-cached JSON.stringify length per entry object (no decrement
  bookkeeping; GC owns eviction). Safety constraints still always win.
- Pagination tool-indexing decision documented as a deliberate fix of the
  latent scroll-back gap; feed-debug gauge now emits bytes alongside count.

Finished from the review-fix agent's in-progress worktree after it was
terminated by the spend limit; gates re-run green (tsc node+web clean,
vitest 481/481 incl. new planner cases, remote-client build OK).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
App.tsx conflict: branch predates #505's thin composition root — took
main's App.tsx and re-applied the rawCapture wiring into
features/debug/devDebugConfig.ts's one-shot hydrate, where the dev-debug
probe lives post-#505. Same behavior, new home.

Co-Authored-By: Claude Fable 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.

Window live renderer transcript/runtime state by bytes, not only initial history count

1 participant