Repository navigation
perf(sessions): read only the transcript tail when loading history - #753
Conversation
Refs #747 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kk16MNVJtWAnCeGxGRuHqa
The initial history load streamed the whole transcript through readline and JSON.parse to keep the last 120 records, and the older-history page forward-scanned from byte 0 to its anchor on every request. On a workspace restore that ran for every resumed session in the first seconds of boot: 25 loads read 252 MB in 10.7 s of main-thread time, one 89 MB rollout took 4.6 s to return 120 entries, and the parse contended with nine provider spawns. Both readers now walk the file newest-first in 256 KiB blocks and stop as soon as the window is complete (limit + 1 records, so hasMore keeps its exact meaning). Lines are split on newline bytes and decoded only once complete, so records larger than a block and multi-byte characters on a block boundary come back exactly as the forward parse produced them. totalEntries is the tail's parsed records plus a raw newline count of the untouched head, which is exact for provider-written files (their only malformed line is the mid-append one at EOF, inside the parsed tail). The older page anchors on the newest occurrence of a duplicated marker, the one at the edge of a window that grew from the tail. Parse-error tolerance, the per-load intern pool, the missing-marker tail fallback and the empty-chunk read-failure path are unchanged. Fixes #747 Refs #87, #103, #93 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kk16MNVJtWAnCeGxGRuHqa
…d markers cannot livelock paging Review of #753: the backward marker hunt anchored on the NEWEST occurrence of the renderer's cursor marker, and markers repeat in real transcripts — Claude files hold contiguous blocks of 100–260 uuids repeated a few hundred records later, and Codex markers (timestamp + payload id) collide on same-millisecond records routinely. The cursor then jumped forward by the duplicate gap and, once the gap exceeded the page size, cycled forever: the feed re-requests near the top and the prompts modal auto-pages, so that was an unbounded IPC loop reading MBs per call. Every chunk now carries the byte offset of each returned line, the renderer echoes the offset of its cursor line as beforeOffset, and the loader verifies the line there carries the marker and walks back from exactly that byte — exact and O(page). Without an offset (remote client, a cursor the live-window trim re-anchored on an entry with no recorded position) or when the check fails, the loader uses the original forward scan anchored on the OLDEST occurrence, which is lossy across a duplicate gap but provably terminates: the next cursor is a line this page returned, strictly before the anchor. Per-entry offsets rather than one oldestOffset because the renderer's cursor is the first KEPT line, and chunks often lead with records the mapper drops. Also from review: records containing a raw U+2028/U+2029 are one line (readline split them and shredded Codex records), documented and pinned; no phantom empty line at EOF; blockBytes asserted positive; the newline count searches only what a short read filled; only returned entries are interned; the "independent of file size" claim corrected to "parse cost proportional to the window, plus one no-decode newline count over the head". Refs #747 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kk16MNVJtWAnCeGxGRuHqa
03c4efb to
c0f3017
Compare
Review follow-up — c0f3017 (rebased onto main)Blocker (livelock on repeated markers) — fixed with a position cursor.
Should-fix items
Nits — no phantom empty line at EOF (documented), Gates — Left as follow-ups (in the PR body): the remote client's store adopting 🤖 Generated with Claude Code |
Juliusolsson05
left a comment
There was a problem hiding this comment.
Adversarial review (re-run after the pause; PR head reviewed in the worktree).
VERDICT: APPROVE-WITH-NITS
Core engineering is correct. Traced readLinesBackward/readLinesForward byte-by-byte (phantom-EOF-line skip, pending-chunk ordering, unterminated-head carry, first-line-at-0 emission), the offset-cursor check, and both fallbacks: the exact path provably terminates (anchor strictly decreases), the marker path preserves pre-PR semantics (hasMore equivalence holds), and the limit+1 probe gives hasMore the same meaning as before. Multi-byte UTF-8 cannot mis-split (0x0A never inside a sequence). No missing-history regression: window equivalence is oracle-tested, and U+2028/U+2029 handling adds records the old reader shredded. Memory bounds hold.
- SHOULD-FIX — No test exercises
beforeOffsetbeyond the loader. PR body claims schema coverage, but the older-page case sends only{beforeMarker, limit}(scope.test.ts:35);beforeOffsetschema acceptance,RemoteServerpassthrough (RemoteServer.ts:677), andWebSocketSessionFeed.getHistoryforwarding (WebSocketSessionFeed.ts:305) are all uncovered. Add one schema case and one integration case. - SHOULD-FIX — Renderer cursor guard stays armed when the first kept line's marker equals the current cursor marker (
history.ts:126). With pages of consecutive duplicate markers (the PR's own documented 100-260-record Claude dup blocks), the cursor lands on the page's newest kept entry, so the next page advances ~1 record — O(n) pages with duplicate delivery for uuid-less entries. Not a livelock, but contradicts the "every record exactly once" invariant; its test never models this. Fix: disarm the guard after the first kept entry; add a test with a page led by a duplicate of the cursor marker. - NIT —
finishInitialChunkderiveshasMorefrom newline-counted total (historyLoader.ts:753,757), so junk head lines can claimhasMorewhere the old reader said false — one wasted request, then terminates. Documented trade-off; acceptable. - NIT — CRLF transcripts degrade every older page to the forward scan (
historyLoader.ts:358-360requires\nbefore the line). Providers write\n; worth one comment. - NIT —
limit <= 0now returnshasMore: falsewhere old returned true; unreachable via real callers. - NIT — catch-all
emptyreportsanchor: 'tail'even when the offset path threw mid-read (historyLoader.ts:486-496) — telemetry slightly misleading.
Commands: historyLoader 14/14 PASS; unit sessions/ 31/32 (1 pre-existing cappedTextBuffer fuzz timeout, passes at 60s — not this PR); renderer actions 35/35 PASS; RemoteServer integration 13/13 PASS; tsc -b clean.
Integrate current main and keep the first renderable line cursor selected independently of marker equality. Verify duplicate-marker, missing-offset, remote schema and real WebSocket pagination contracts; 57 focused tests and TypeScript pass. Refs #747
Problem
Loading a resumed session's history parsed the WHOLE transcript to return its newest 120 records:
readInitialTranscriptTailstreamed every line through readline +JSON.parse, interned it, and kept a ring buffer oflimit;readOlderTranscriptWindowforward-scanned from byte 0 to the renderer's anchor on every page.Measured in #747 (today's boot): 25
historyLoader.loadInitialChunkspans read 252 MB in 10.7 s of main-thread time. The worst — an 89.3 MB / 18,366-line rollout — took 4,618 ms to return 120 entries (332 ms even hot from page cache). All 25 ran in the first 8 s of boot, where main's event-loop p99 hit 577 ms and nine parallel provider spawns each showed 950–2,130 ms of non-provider time; rendererworkspace.history.loadInitialtotalled 38.7 s behindINITIAL_HISTORY_CONCURRENCY = 2.Behaviour
limit + 1usable records are found. The extra record is whathasMoremeans (at least one more usable record exists), exactly asparsed > limitmeant before. Parse cost is proportional to the window.totalEntries= records parsed in the tail + one no-decode newline count over the untouched head (1 MiB blocks). This is still a pass over the head's bytes (17–32 ms hot on 84 MB; a full-file read cold) — the load's I/O is not independent of file size, its parse cost is.offsets— the absolute byte offset of each returned record's line, parallel toentries. The renderer keeps the offset of its cursor line (historyOldestOffset, next tohistoryOldestMarker) and sends it back asbeforeOffset.beforeOffset: the loader verifies the line at that offset carriesbeforeMarker(one record read, and the byte before it must be a newline), then walks backward from exactly that byte forlimit + 1records — exact, O(page), no marker hunt. Without an offset (remote client; a cursor the live-window trim re-anchored on an entry with no recorded position) or when the check fails (rewritten file, cursor from another transcript), it runs the original forward scan anchored on the oldest occurrence of the marker, which parses from byte 0 to the anchor as before this PR and keeps the "marker not in file → page from the tail" fallback. That page returns offsets, so the very next page is exact again.hasMoresemantics, the empty-chunk read-failure path. The IPC and remote wire shapes gain optional fields only (offsetson chunks,beforeOffseton older requests).Design decisions
ts:typefallbacks). The first revision anchored on the NEWEST occurrence, which moves the renderer's cursor forward by the duplicate gap and, when the gap is at least the page size, cycles forever — an unbounded IPC loop. The oldest-occurrence forward scan provably terminates (the next cursor is a line this page returned, strictly before the anchor, so the anchor position strictly decreases) but skips every record between two occurrences. The offset cursor makes paging exact AND terminating; the forward scan remains only as the terminating fallback.offsetsrather than oneoldestOffset: the renderer's cursor is the first kept line of a chunk, and chunks frequently lead with records the mapper drops (Codexturn_context/session_meta, Claude snapshots). An offset for the first returned line would fail the marker-at-offset check on every such page and silently degrade to the slow scan. Numbers only — ~2 KB per 200-record page.readLinesBackward) shared by both readers plus a forward twin with the same byte-level line contract, so an offset learned from one is verifiable by the other. The consumer's stop callback is what bounds the read.0x0Anever occurs inside a multi-byte sequence, and a line is decoded only once complete.totalEntriesfrom average record size (the renderer's contract is "how many records are on disk", never decremented); a backward marker hunt for the no-offset path (cannot be made to terminate on duplicates without walking the whole file anyway).Plan:
docs/superpowers/plans/2026-09-03-history-tail-read.md.Linked issues
Fixes #747
Refs #87, #103, #93
Verification
NODE_ENV=test npx vitest run --project unit src/main/sessions/historyLoader.test.ts— 14 passed. The oracle in the test IS the removed forward implementation (streamJsonlring buffer / marker scan), so equivalence is against shipped behaviour: small transcript at six limits; a ~3 MB transcript with a 320 KB record spanning many blocks and a block boundary placed inside a 4-byte emoji; bytes read for the window proportional to the window whiletotalEntriesstill counts the whole file; window larger than the file; blank / malformed / literal-null / CRLF / unterminated lines; a raw U+2028 record kept whole (and pinned that readline split it); empty, junk-only and missing files; every returned offset addresses the line that parses to its entry; older pages at six anchor depths by marker AND by offset incl. the first record; Codex marker synthesis; missing-marker tail fallback; a mid-file offset reading only the page (< size/16); offsets that are another line, mid-line, past EOF, negative, fractional or absent all fall back to the forward scan with the same result; the reviewer's duplicated-marker input (0–29 = A0..A29, 30–59 = A0..A29 again, 60–79 unique, limit 10) paged to the head with the renderer's cursor rule — offset cursor: 7 pages, every record exactly once; marker-only: terminates in 4 pages, lossy across the gap exactly like the original scan.NODE_ENV=test npx vitest run --project unit src/main/sessions/— 5 files, 32 passed (on the rebased branch).NODE_ENV=test npx vitest run --project unit src/main/remote/protocol/scope.test.ts— 22 passed (theget-historyschema accepts the optionalbeforeOffset).NODE_ENV=test npx vitest run --project renderer src/renderer/src/workspace/hook/actions/— 8 files, 35 passed.NODE_ENV=test npx vitest run --project system src/main/remote/RemoteServer.integration.test.ts(exercisesget-historyinitial + older page end to end) — 13 passed.npx tsc -b --pretty false— clean.tool_use/tool_resultrecords, hot page cache, M3: initial load (limit 120) 203–217 ms → 13–16 ms, parsing 786 KB of the 84 MB; older page (limit 200) from the tail anchor 201 ms → 6 ms. Results were asserted equal to the old reader's. The real 89 MB rollout in perf(history): initial history load parses the whole transcript to return its last 120 entries #747 costs more per byte than the synthetic one (4.6 s cold / 332 ms hot).Limitations / follow-ups
totalEntriesstill requires one raw pass over the head of the file on every initial load (I/O off the main thread, a memchr-speed loop on it). Caching the count per (path, size, mtime) across loads was left out; the load runs once per session per boot.offsets/beforeOffset(it already receivesoffsets), and carrying byte offsets on live jsonl frames so a trim re-anchor keeps the exact cursor.totalEntriesby the junk lines (documented in the test).🤖 Generated with Claude Code
https://claude.ai/code/session_01Kk16MNVJtWAnCeGxGRuHqa
Review repair (2026-09-04)
The renderer now selects a cursor with an explicit latch, not marker inequality. The first renderable line's byte offset advances even when its marker repeats the old marker; missing offsets clear the previous position. Added three renderer regressions, remote offset validation and real WebSocket duplicate-marker paging. 57 focused tests and TypeScript pass. Full CI passed on implementation 621bff7; the plan-only follow-up is subject to its normal CI rerun.
Final verification: both full host CI gates pass on fcebe0f (run 33938035642). Ready for owner merge confirmation; no live app restart.