Skip to content

Commit aa0aa76

Browse files
authored
fix(coding-agents): bound transcript reads so an oversized session still retains (#3345)
Closes #3292. Every live-transcript reader did `readFileSync(path, "utf8").split("\n")`. Past V8's maximum string length (~537M chars) that throws ERR_STRING_TOO_LONG before a single record is parsed; the readers catch it as "unreadable file" and return no turns, so the Stop hook exits successfully having retained nothing. An agent that had been running for weeks just stops updating memory, with no error anywhere. Reproduced on a 575MB Codex rollout: readFileSync throws ERR_STRING_TOO_LONG, readCodexTranscript returns []. With this change the same file yields 8,162 turns ending in the most recent exchange, in 51MB of heap. core/jsonl.ts streams the file a chunk at a time through a StringDecoder (so a multi-byte character split across chunks is reassembled) and reads only the last MAX_TRANSCRIPT_BYTES. Truncating the HEAD is what makes a cap safe here: the recent exchange is the part worth retaining, and the incremental write-back only sends turns after its cursor anyway. Landing mid-record drops that fragment rather than emitting half a line; starting one byte early keeps a record whose boundary the cut happened to land on. A truncated read is logged, never silent — which was the actual complaint. Applied to all six readers that shared the pattern (claude-code, codex, antigravity-cli, copilot-cli, cursor-cli, grok-build), not just the one filed: same bug, same line, and a large Claude Code transcript fails identically.
1 parent 2b0ed82 commit aa0aa76

8 files changed

Lines changed: 240 additions & 53 deletions

File tree

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5+
import { MAX_TRANSCRIPT_BYTES, readJsonlTail } from "./jsonl";
6+
7+
// Spy on the REAL readFileSync (not a stub that throws): the point is to prove nothing in this path
8+
// calls it, which is what #3292 came down to — past V8's maximum string length it throws
9+
// ERR_STRING_TOO_LONG, and the readers turned that into "no turns" with no error anywhere.
10+
vi.mock("node:fs", async (importOriginal) => {
11+
const actual = await importOriginal<typeof import("node:fs")>();
12+
return { ...actual, readFileSync: vi.fn(actual.readFileSync) };
13+
});
14+
15+
let root: string;
16+
let file: string;
17+
18+
beforeEach(() => {
19+
root = mkdtempSync(join(tmpdir(), "hs-jsonl-"));
20+
file = join(root, "transcript.jsonl");
21+
vi.mocked(readFileSync).mockClear();
22+
});
23+
24+
afterEach(() => {
25+
rmSync(root, { recursive: true, force: true });
26+
});
27+
28+
const read = (maxBytes?: number) => {
29+
const tail = readJsonlTail(file, { scope: "test", maxBytes });
30+
return { lines: [...tail.lines], skippedBytes: tail.skippedBytes };
31+
};
32+
33+
describe("readJsonlTail", () => {
34+
it("yields every record of a file under the cap", () => {
35+
writeFileSync(file, ["a", "b", "c"].join("\n") + "\n");
36+
expect(read()).toEqual({ lines: ["a", "b", "c"], skippedBytes: 0 });
37+
});
38+
39+
it("yields a final record that has no trailing newline", () => {
40+
writeFileSync(file, "a\nb");
41+
expect(read().lines).toEqual(["a", "b"]);
42+
});
43+
44+
it("preserves blank and whitespace-only records for the caller to skip", () => {
45+
// The readers decide what to drop (they trim and skip empties); this must not silently change
46+
// the record stream they used to get from split("\n").
47+
writeFileSync(file, "a\n\n b \nc\n");
48+
expect(read().lines).toEqual(["a", "", " b ", "c"]);
49+
});
50+
51+
it("keeps \\r on CRLF records, exactly as split('\\n') did", () => {
52+
writeFileSync(file, "a\r\nb\r\n");
53+
expect(read().lines).toEqual(["a\r", "b\r"]);
54+
});
55+
56+
it("reads the TAIL when the file exceeds the cap, dropping the record it lands inside", () => {
57+
// The cap lands mid-record; that fragment must be discarded whole rather than emitted as a
58+
// half line that fails to parse.
59+
const records = ["1111111111", "2222222222", "3333333333", "4444444444"];
60+
writeFileSync(file, records.join("\n") + "\n"); // 44 bytes
61+
const { lines, skippedBytes } = read(25);
62+
expect(skippedBytes).toBe(19);
63+
expect(lines).toEqual(["3333333333", "4444444444"]);
64+
});
65+
66+
it("keeps whole records when the cap lands exactly on a boundary", () => {
67+
writeFileSync(file, ["aaaa", "bbbb", "cccc"].join("\n") + "\n"); // 15 bytes
68+
// Last 10 bytes start exactly at "bbbb"; the leading newline is the dropped 'partial'.
69+
expect(read(10).lines).toEqual(["bbbb", "cccc"]);
70+
});
71+
72+
it("reassembles a multi-byte character split across the read boundary", () => {
73+
// 'é' is 2 bytes; place it so its first byte ends one 64KB chunk and its second starts the next.
74+
const pad = "a".repeat(65535);
75+
writeFileSync(file, `${pad}é-tail\nsecond\n`);
76+
const lines = read().lines;
77+
expect(lines[0].endsWith("é-tail")).toBe(true);
78+
expect(lines[0]).not.toContain("�"); // no replacement char: the sequence survived
79+
expect(lines[1]).toBe("second");
80+
});
81+
82+
it("yields nothing for a missing file instead of throwing", () => {
83+
expect(read()).toEqual({ lines: [], skippedBytes: 0 });
84+
});
85+
86+
it("yields nothing for an empty file", () => {
87+
writeFileSync(file, "");
88+
expect(read().lines).toEqual([]);
89+
});
90+
91+
it("closes the file even when the consumer stops early", () => {
92+
writeFileSync(file, ["a", "b", "c"].join("\n") + "\n");
93+
const { lines } = readJsonlTail(file, { scope: "test" });
94+
for (const line of lines) {
95+
expect(line).toBe("a");
96+
break; // for..of calls the generator's return(), which must run the finally that closes the fd
97+
}
98+
expect(lines.next().done).toBe(true);
99+
});
100+
101+
it("never reads the whole file into one string", () => {
102+
writeFileSync(file, "a\nb\n");
103+
expect(read().lines).toEqual(["a", "b"]);
104+
expect(vi.mocked(readFileSync)).not.toHaveBeenCalled();
105+
});
106+
107+
it("caps at 32MB by default", () => {
108+
expect(MAX_TRANSCRIPT_BYTES).toBe(32 * 1024 * 1024);
109+
});
110+
});
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**
2+
* Bounded JSONL reading for the harness transcript readers.
3+
*
4+
* Every live-transcript reader used to do `readFileSync(path, "utf8").split("\n")`. Two things go
5+
* wrong with that as a session grows, and the second is silent:
6+
*
7+
* - past V8's maximum string length (~537M chars) `readFileSync` throws ERR_STRING_TOO_LONG
8+
* before a single record is parsed. The readers catch that as "unreadable file" and return no
9+
* turns, so the Stop hook exits successfully having retained nothing — an agent that had been
10+
* running for weeks just stopped updating memory, with no error anywhere (#3292).
11+
* - well before that limit, decoding hundreds of MB into one string (and then a turn per line)
12+
* costs several times the file size in heap, in a hook process that must not fall over.
13+
*
14+
* So: stream the file a chunk at a time, and read only the LAST `maxBytes` of it. Truncating the
15+
* head rather than the tail is what makes the cap safe to apply everywhere — the recent exchange is
16+
* the part worth retaining, and the incremental write-back (core/retain-cursor.ts) only sends turns
17+
* added since its cursor anyway. A transcript over the cap is reported, never dropped in silence.
18+
*/
19+
import { closeSync, openSync, readSync, statSync } from "node:fs";
20+
import { StringDecoder } from "node:string_decoder";
21+
import { log } from "./log";
22+
23+
/** Read granularity. Large enough that a multi-MB transcript is a few hundred syscalls. */
24+
const CHUNK_BYTES = 64 * 1024;
25+
26+
/**
27+
* Most transcript we will decode, per read.
28+
*
29+
* A parsed transcript costs roughly 3x its bytes in heap (one string per line plus the turn
30+
* objects), so this keeps a hook process near ~100MB even in the worst case. It is far above any
31+
* real coding session: the largest transcripts seen in the wild are single-digit MB, and a file
32+
* over this has already stopped being a conversation anyone can extract meaning from.
33+
*/
34+
export const MAX_TRANSCRIPT_BYTES = 32 * 1024 * 1024;
35+
36+
export interface JsonlTail {
37+
/** Complete records, oldest first. Never holds more than one line plus a chunk in memory. */
38+
lines: Generator<string>;
39+
/** Bytes skipped from the head because the file exceeded the cap; 0 when it was read whole. */
40+
skippedBytes: number;
41+
}
42+
43+
/**
44+
* The last `maxBytes` of a JSONL file, one complete record at a time.
45+
*
46+
* Fail-open like the readers it serves: a missing or unreadable file yields no records rather than
47+
* throwing. `scope` only names the harness in the truncation warning.
48+
*/
49+
export function readJsonlTail(path: string, opts: { scope: string; maxBytes?: number }): JsonlTail {
50+
const maxBytes = opts.maxBytes ?? MAX_TRANSCRIPT_BYTES;
51+
let size: number;
52+
try {
53+
size = statSync(path).size;
54+
} catch {
55+
return { lines: emptyLines(), skippedBytes: 0 };
56+
}
57+
const skippedBytes = size > maxBytes ? size - maxBytes : 0;
58+
if (skippedBytes > 0) {
59+
// The whole point of #3292: an oversized transcript must not fail quietly.
60+
log.warn(opts.scope, "transcript too large — retaining the most recent portion only", {
61+
path,
62+
sizeBytes: size,
63+
skippedBytes,
64+
});
65+
}
66+
return { lines: streamLines(path, skippedBytes), skippedBytes };
67+
}
68+
69+
function* emptyLines(): Generator<string> {
70+
/* nothing to yield — see the fail-open contract above */
71+
}
72+
73+
/** Yield complete lines from `start` to EOF. The fd is opened lazily (on first iteration) and
74+
* closed even if the consumer abandons the generator early — for..of calls return() for us. */
75+
function* streamLines(path: string, start: number): Generator<string> {
76+
let fd: number;
77+
try {
78+
fd = openSync(path, "r");
79+
} catch {
80+
return;
81+
}
82+
try {
83+
const buffer = Buffer.allocUnsafe(CHUNK_BYTES);
84+
// StringDecoder holds back a trailing partial UTF-8 sequence, so a multi-byte character split
85+
// across two chunks is reassembled instead of becoming two replacement chars.
86+
const decoder = new StringDecoder("utf8");
87+
let pending = "";
88+
// Begin one byte EARLY so the cut can be classified: if that byte is the newline ending the
89+
// previous record, the cut was clean and the "partial" we drop is an empty string, costing
90+
// nothing. Otherwise we really did land inside a record and drop that fragment.
91+
let position = start > 0 ? start - 1 : 0;
92+
let dropPartial = start > 0;
93+
94+
for (;;) {
95+
const bytesRead = readSync(fd, buffer, 0, buffer.length, position);
96+
if (bytesRead <= 0) break;
97+
position += bytesRead;
98+
pending += decoder.write(buffer.subarray(0, bytesRead));
99+
100+
let newline: number;
101+
while ((newline = pending.indexOf("\n")) !== -1) {
102+
const line = pending.slice(0, newline);
103+
pending = pending.slice(newline + 1);
104+
if (dropPartial) {
105+
dropPartial = false;
106+
continue;
107+
}
108+
yield line;
109+
}
110+
}
111+
112+
pending += decoder.end();
113+
// A final record with no trailing newline still counts (and is not the dropped partial).
114+
if (pending && !dropPartial) yield pending;
115+
} finally {
116+
closeSync(fd);
117+
}
118+
}

hindsight-integrations/coding-agents/src/core/transcript-antigravity.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,13 @@
1-
import { readFileSync } from "node:fs";
21
import type { TransportTurn } from "./chat";
2+
import { readJsonlTail } from "./jsonl";
33
import { stripInjectedMemory } from "./transcript-util";
44

55
/** Read Antigravity's transcript JSONL defensively. The documented hook contract guarantees only
66
* the path, not the internal event schema, so support its user/assistant role and message variants. */
77
export function readAntigravityTranscript(path: string | undefined): TransportTurn[] {
88
if (!path) return [];
9-
let raw: string;
10-
try {
11-
raw = readFileSync(path, "utf8");
12-
} catch {
13-
return [];
14-
}
15-
169
const turns: TransportTurn[] = [];
17-
for (const line of raw.split("\n")) {
10+
for (const line of readJsonlTail(path, { scope: "antigravity-cli" }).lines) {
1811
try {
1912
const event = JSON.parse(line) as {
2013
role?: string;

hindsight-integrations/coding-agents/src/core/transcript-codex.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@
2020
* prevents a retain→recall feedback loop (plus stripInjectedMemory as a defensive second pass on the
2121
* text we do keep). Fail-open: never throws on a missing file or a malformed line.
2222
*/
23-
import { readFileSync } from "node:fs";
2423
import type { TransportTurn } from "./chat";
24+
import { readJsonlTail } from "./jsonl";
2525
import { actionLine, stripInjectedMemory } from "./transcript-util";
2626

2727
interface ContentItem {
@@ -60,15 +60,8 @@ function messageText(payload: Payload): string {
6060
* Drops developer/system + synthetic-startup + reasoning + injected memory + empty turns.
6161
* Never throws on bad lines. */
6262
export function readCodexTranscript(path: string): TransportTurn[] {
63-
let raw: string;
64-
try {
65-
raw = readFileSync(path, "utf8");
66-
} catch {
67-
return [];
68-
}
69-
7063
const turns: TransportTurn[] = [];
71-
for (const rawLine of raw.split("\n")) {
64+
for (const rawLine of readJsonlTail(path, { scope: "codex" }).lines) {
7265
const trimmed = rawLine.trim();
7366
if (!trimmed) continue;
7467

hindsight-integrations/coding-agents/src/core/transcript-copilot.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,11 @@
1-
import { readFileSync } from "node:fs";
21
import type { TransportTurn } from "./chat";
2+
import { readJsonlTail } from "./jsonl";
33
import { stripInjectedMemory } from "./transcript-util";
44

55
/** Normalize Copilot CLI's session-state `events.jsonl` user/assistant message records. */
66
export function readCopilotTranscript(path: string): TransportTurn[] {
7-
let raw: string;
8-
try {
9-
raw = readFileSync(path, "utf8");
10-
} catch {
11-
return [];
12-
}
13-
147
const turns: TransportTurn[] = [];
15-
for (const rawLine of raw.split("\n")) {
8+
for (const rawLine of readJsonlTail(path, { scope: "copilot-cli" }).lines) {
169
try {
1710
const event = JSON.parse(rawLine) as {
1811
type?: string;

hindsight-integrations/coding-agents/src/core/transcript-cursor.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/** Cursor CLI transcript reader for its `stop` write-back hook. */
2-
import { readFileSync } from "node:fs";
32
import type { TransportTurn } from "./chat";
3+
import { readJsonlTail } from "./jsonl";
44
import { actionLine, stripInjectedMemory } from "./transcript-util";
55

66
interface ContentBlock {
@@ -35,15 +35,8 @@ function textFrom(content: string | ContentBlock[] | undefined): string {
3535

3636
/** Parse Cursor's JSONL message and tool-call events into durable text and compact action turns. */
3737
export function readCursorTranscript(path: string): TransportTurn[] {
38-
let raw: string;
39-
try {
40-
raw = readFileSync(path, "utf8");
41-
} catch {
42-
return [];
43-
}
44-
4538
const turns: TransportTurn[] = [];
46-
for (const rawLine of raw.split("\n")) {
39+
for (const rawLine of readJsonlTail(path, { scope: "cursor-cli" }).lines) {
4740
let parsed: unknown;
4841
try {
4942
parsed = JSON.parse(rawLine);

hindsight-integrations/coding-agents/src/core/transcript-grok.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { existsSync, readFileSync, readdirSync } from "node:fs";
55
import { homedir } from "node:os";
66
import { join } from "node:path";
77
import type { TransportTurn } from "./chat";
8+
import { readJsonlTail } from "./jsonl";
89
import { actionLine, stripInjectedMemory } from "./transcript-util";
910

1011
const CHAT_HISTORY = "chat_history.jsonl";
@@ -36,15 +37,8 @@ export function grokTranscriptPath(
3637
/** Normalize Grok's persisted chat-history records into user, assistant, and compact action turns.
3738
* Synthetic user records carry `synthetic_reason`; only prompt-indexed records are actual user work. */
3839
export function readGrokTranscript(path: string): TransportTurn[] {
39-
let raw: string;
40-
try {
41-
raw = readFileSync(path, "utf8");
42-
} catch {
43-
return [];
44-
}
45-
4640
const turns: TransportTurn[] = [];
47-
for (const rawLine of raw.split("\n")) {
41+
for (const rawLine of readJsonlTail(path, { scope: "grok-build" }).lines) {
4842
try {
4943
const event = JSON.parse(rawLine) as {
5044
type?: string;

hindsight-integrations/coding-agents/src/core/transcript.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@
1313
* Fail-open: never throws on a missing file, malformed line, or a line that parses to a
1414
* non-object JSON value (`null`, a number, a boxed primitive, …).
1515
*/
16-
import { readFileSync } from "node:fs";
1716
import type { TransportTurn } from "./chat";
17+
import { readJsonlTail } from "./jsonl";
1818
import { actionLine, stripInjectedMemory } from "./transcript-util";
1919

2020
interface ContentBlock {
@@ -76,15 +76,8 @@ function renderLine(content: string | ContentBlock[] | undefined, type: string):
7676
* Drops thinking blocks, isMeta/isSidechain lines, injected memory, and empty turns.
7777
* Never throws on bad lines. */
7878
export function readClaudeTranscript(path: string): TransportTurn[] {
79-
let raw: string;
80-
try {
81-
raw = readFileSync(path, "utf8");
82-
} catch {
83-
return [];
84-
}
85-
8679
const turns: TransportTurn[] = [];
87-
for (const rawLine of raw.split("\n")) {
80+
for (const rawLine of readJsonlTail(path, { scope: "claude-code" }).lines) {
8881
const trimmed = rawLine.trim();
8982
if (!trimmed) continue;
9083

0 commit comments

Comments
 (0)