perf(agent-status): keep the shared transcript reader's carry linear (#10777)
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.
This commit is contained in:
parent
8db61248ec
commit
a8a2e6cb1f
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue