From a8a2e6cb1fd20a890ee62e890773306da05ca8e3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:31:46 -0700 Subject: [PATCH] perf(agent-status): keep the shared transcript reader's carry linear (#10777) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readLastTextFromTranscriptOnce re-joined its carry buffer on every block that held no newline, so a transcript whose tail is one oversized line copied O(line^2). It backs three readers — the Claude/Codex user prompt, the Command Code assistant message, and the shared assistant-text reader — so every agent that resolves turn text from a transcript paid it. Same chunk-list carry the Command Code prompt reader already uses. Measured on a transcript whose tail is one big line: 15.24 ms -> 8.87 ms at 3.9 MB, and the gap widens with the line, which is the quadratic signature. --- src/shared/agent-hook-listener.test.ts | 50 ++++++++++++++++++++++++++ src/shared/agent-hook-listener.ts | 43 ++++++++++++---------- 2 files changed, 75 insertions(+), 18 deletions(-) diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index f5a3e470c..f6bcc0c38 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -971,6 +971,56 @@ describe('shared agent-hook-listener', () => { } }) + it('reads the last assistant message behind an oversized line without quadratic copying', () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'orca-assistant-huge-line-')) + const transcriptPath = join(tmpDir, 'transcript.jsonl') + const originalConcat = Buffer.concat + let concatenatedBytes = 0 + try { + // The shared backward reader (readLastTextFromTranscriptOnce) stitches a + // line spanning many read blocks. Re-joining the carry per block copies + // O(line^2); the chunk list defers to one join. + const lineBytes = 2 * 1024 * 1024 + writeFileSync( + transcriptPath, + `${JSON.stringify({ + role: 'assistant', + content: [{ type: 'text', text: 'answer behind a huge line' }] + })}\n${JSON.stringify({ + role: 'user', + content: [{ type: 'text', text: 'x'.repeat(lineBytes) }] + })}\n` + ) + + Buffer.concat = ((list: readonly Uint8Array[], totalLength?: number) => { + const joined = originalConcat(list as Uint8Array[], totalLength) + concatenatedBytes += joined.length + return joined + }) as typeof Buffer.concat + + const done = normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt', + env: 'production', + version: '1', + payload: { hook_event_name: 'Stop', transcript_path: transcriptPath } + }, + 'production' + ) + + expect(done?.payload.lastAssistantMessage).toBe('answer behind a huge line') + // Linear copies once (~lineBytes); the quadratic form copied many times that. + expect(concatenatedBytes).toBeLessThan(lineBytes * 4) + } finally { + Buffer.concat = originalConcat + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + // Why these three: the prompt read scans backward from EOF and stops at the // first user line, so the cases that can break are a prompt spanning a chunk // boundary, a later prompt that must win over an earlier one, and the byte diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index e2413f8fb..7701ea6b1 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -1337,11 +1337,14 @@ function readLastTextFromTranscriptOnce( } const fd = openSync(transcriptPath, 'r') try { - let carryBytes: Buffer = Buffer.alloc(0) + // Why a chunk list: carry holds a partial line, and re-joining it per block + // made one oversized line (a big tool result or pasted prompt) cost O(line^2). + let carryChunks: Buffer[] = [] let bytesRead = 0 - while (bytesRead < size && bytesRead < TRANSCRIPT_MAX_SCAN_BYTES) { - const chunkSize = Math.min(size - bytesRead, TRANSCRIPT_CHUNK_BYTES) - const position = size - bytesRead - chunkSize + let scanEnd = size + while (scanEnd > 0 && bytesRead < TRANSCRIPT_MAX_SCAN_BYTES) { + const chunkSize = Math.min(scanEnd, TRANSCRIPT_CHUNK_BYTES) + const position = scanEnd - chunkSize const buffer = Buffer.alloc(chunkSize) let filled = 0 while (filled < chunkSize) { @@ -1351,25 +1354,30 @@ function readLastTextFromTranscriptOnce( } filled += n } - const n = filled - bytesRead += n - if (n === 0) { + // Why bail on a short read: the file shrank under us, so the bytes above + // this block no longer line up with what the earlier ones assumed. + if (filled < chunkSize) { break } - const combined = Buffer.concat([buffer.subarray(0, n), carryBytes]) - const atStart = bytesRead >= size - const firstNewline = combined.indexOf(0x0a) + bytesRead += filled + scanEnd = position + // Why search only the new block: carry is always the run before a newline, + // so it holds none of its own. + const firstNewline = buffer.indexOf(0x0a) + const atStart = position === 0 let completeRegion: Buffer - let nextCarry: Buffer if (atStart) { - completeRegion = combined - nextCarry = Buffer.alloc(0) + completeRegion = + carryChunks.length === 0 ? buffer : Buffer.concat([buffer, ...carryChunks]) + carryChunks = [] } else if (firstNewline === -1) { - completeRegion = Buffer.alloc(0) - nextCarry = combined + completeRegion = EMPTY_TRANSCRIPT_REGION + carryChunks.unshift(buffer) } else { - nextCarry = combined.subarray(0, firstNewline) - completeRegion = combined.subarray(firstNewline + 1) + const afterNewline = buffer.subarray(firstNewline + 1) + completeRegion = + carryChunks.length === 0 ? afterNewline : Buffer.concat([afterNewline, ...carryChunks]) + carryChunks = [buffer.subarray(0, firstNewline)] } if (completeRegion.length > 0) { const extracted = findLastExtractedTranscriptLineText( @@ -1380,7 +1388,6 @@ function readLastTextFromTranscriptOnce( return extracted } } - carryBytes = nextCarry } return undefined } finally {