Skip to content

Commit 50eeac4

Browse files
Skip mid-file interleaved garbage in turns.jsonl on resume (#656)
* Skip mid-file interleaved garbage in turns.jsonl on resume JSON.parse failures on a turns line warn and skip so a glued truncated manage_tasks write or mid-file junk no longer aborts reactor resume. Schema-invalid turns still fail closed. Warns name turns.jsonl and the line number; glued {...}{...} fragments are salvaged when possible. Fixes CL-7052 * Simplify glued-line recovery and skip mid-file garbage on reactor load (CL-7052) * Fix prettier formatting in optimized context store
1 parent 57d2fb3 commit 50eeac4

2 files changed

Lines changed: 83 additions & 20 deletions

File tree

src/session/optimized-context-store.test.ts

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -146,17 +146,38 @@ describe("createOptimizedContextStore load", () => {
146146
expect(loaded.connectorState).toBeNull();
147147
});
148148

149-
test("unrecoverable turns.jsonl names the file in the error", async () => {
149+
test("skips mid-file garbage lines and resumes remaining turns", async () => {
150150
const dir = tempDir();
151151
const store = await createOptimizedContextStore(dir);
152152

153-
// Mid-file garbage that is not null padding and not a torn tail — unrecoverable.
153+
// Mid-file garbage that is not null padding and not a torn tail (CL-7052).
154154
fs.writeFileSync(
155155
path.join(dir, TURNS_FILE),
156156
jsonl([turn("a")]) + "THIS IS NOT JSON\n" + jsonl([turn("b")]),
157157
);
158158

159-
await expect(store.load()).rejects.toThrow(/turns\.jsonl/);
159+
const loaded = await store.load();
160+
expect(loaded.turns.map((t) => (t.content[0] as { text: string }).text)).toEqual(["a", "b"]);
161+
});
162+
163+
test("skips a truncated mid-string glued to the next record", async () => {
164+
const dir = tempDir();
165+
const store = await createOptimizedContextStore(dir);
166+
167+
// Crash mid-write left a stub; the next append continued without a newline,
168+
// so a truncated prefix is glued onto the following valid record (CL-7052).
169+
const glued = '{"role":"user","content":[{"type":"te' + JSON.stringify(turn("b"));
170+
fs.writeFileSync(
171+
path.join(dir, TURNS_FILE),
172+
jsonl([turn("a")]) + glued + "\n" + jsonl([turn("c")]),
173+
);
174+
175+
const loaded = await store.load();
176+
expect(loaded.turns.map((t) => (t.content[0] as { text: string }).text)).toEqual([
177+
"a",
178+
"b",
179+
"c",
180+
]);
160181
});
161182

162183
// Compacted head rewrites segment 0 while a prior multi-segment history's
@@ -388,30 +409,36 @@ describe("loadRecentTurns", () => {
388409
expect(loaded.map((t) => (t.content[0] as { text: string }).text)).toEqual(["a", "b", "c"]);
389410
});
390411

391-
test("the reactor's load() stays strict on the same corrupt fixture and names the segment", async () => {
412+
test("reactor load skips a non-tail malformed line the same way display does", async () => {
392413
const dir = tempDir();
393414
const store = await createOptimizedContextStore(dir);
394415
fs.writeFileSync(
395416
path.join(dir, TURNS_FILE),
396417
jsonl([turn("a")]) + '{"role":"user","content":[{"type":"te\n' + jsonl([turn("b")]),
397418
);
398419

399-
await expect(store.load()).rejects.toThrow(TURNS_FILE);
420+
const loaded = await store.load();
421+
expect(loaded.turns.map((t) => (t.content[0] as { text: string }).text)).toEqual(["a", "b"]);
400422
});
401423

402-
test("reactor's load() stays strict and names an unrecoverable extra segment", async () => {
424+
test("reactor load skips mid-file garbage in an extra segment", async () => {
403425
const dir = tempDir();
404426
const store = await createOptimizedContextStore(dir);
405427
const segmentName = segmentFileName(TURNS_FILE, 1);
406428

407429
fs.writeFileSync(path.join(dir, TURNS_FILE), jsonl([turn("a")]));
408-
// Mid-file garbage that is neither null padding nor a torn tail — unrecoverable.
430+
// Mid-file garbage that is neither null padding nor a torn tail (CL-7052).
409431
fs.writeFileSync(
410432
path.join(dir, segmentName),
411433
jsonl([turn("b")]) + "THIS IS NOT JSON\n" + jsonl([turn("c")]),
412434
);
413435

414-
await expect(store.load()).rejects.toThrow(segmentName);
436+
const loaded = await store.load();
437+
expect(loaded.turns.map((t) => (t.content[0] as { text: string }).text)).toEqual([
438+
"a",
439+
"b",
440+
"c",
441+
]);
415442
});
416443
});
417444

src/session/optimized-context-store.ts

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,34 @@ function sanitizeCallId(callId: string): string {
8080
* `fileName` when provided so diagnostics point at the on-disk file, not a bare
8181
* Bun JSON token.
8282
*
83-
* `skipMalformed` is for display-only reads (see loadRecentTurns): a bad line
84-
* anywhere in any segment drops that line and keeps the surrounding history,
85-
* because a blank transcript is a worse answer than a transcript with a hole in
86-
* it. The reactor's own load() must never use it — there, history *is* the live
87-
* conversation state and silently dropping a turn would corrupt it (CL-5935).
83+
* `skipMalformed` drops (or partially recovers) a bad line anywhere in the
84+
* segment and keeps surrounding history. Used by display-only reads
85+
* (`loadRecentTurns`) and by the reactor's own `load()` recovery path so a
86+
* mid-file garbage/interleaved record does not kill resume (CL-7052). Earlier
87+
* CL-5935 kept reactor load strict; killing the session on one bad line was
88+
* worse than a hole in history.
89+
*
90+
* When a crash left a truncated stub glued to the next append (no newline),
91+
* the line fails as a whole; `recoverTurnFromGluedLine` still salvages a
92+
* trailing complete turn from that line when one is present.
8893
*/
94+
function recoverTurnFromGluedLine(line: string): ConversationTurn | null {
95+
// Walk every `{` start: a truncated prefix glued onto a complete record
96+
// parses only from the start of that complete record to end-of-line.
97+
for (let i = 0; i < line.length; i++) {
98+
if (line[i] !== "{") continue;
99+
let raw: unknown;
100+
try {
101+
raw = JSON.parse(line.slice(i));
102+
} catch {
103+
continue;
104+
}
105+
const result = ConversationTurnSchema(raw);
106+
if (!(result instanceof type.errors)) return result;
107+
}
108+
return null;
109+
}
110+
89111
function parseSegmentTurns(
90112
text: string,
91113
tolerateTornTail: boolean,
@@ -109,11 +131,21 @@ function parseSegmentTurns(
109131
try {
110132
raw = JSON.parse(line);
111133
} catch (cause) {
112-
if (tolerateTornTail && isLast) break;
113134
if (skipMalformed) {
135+
const recovered = recoverTurnFromGluedLine(line);
136+
if (recovered !== null) {
137+
log.warn?.(
138+
`recovered trailing turn from glued/malformed JSON at ${fileName} line ${i + 1}`,
139+
);
140+
turns.push(recovered);
141+
continue;
142+
}
143+
// Torn final line: drop it rather than warning as mid-file garbage.
144+
if (tolerateTornTail && isLast) break;
114145
log.warn?.(`skipping malformed JSON at ${fileName} line ${i + 1}`);
115146
continue;
116147
}
148+
if (tolerateTornTail && isLast) break;
117149
throw new Error(`${fileName} has malformed JSON at line ${i + 1}`, { cause });
118150
}
119151
const result = ConversationTurnSchema(raw);
@@ -253,7 +285,8 @@ export async function loadRecentTurns(dir: string, minTurns: number): Promise<Co
253285
// Only the active (last) segment can be mid-write; sealed ones are complete.
254286
// Display-only: skip lines that will not parse rather than losing the whole
255287
// transcript to one bad line, and name the segment in any error that does
256-
// escape (CL-5935).
288+
// escape (CL-5935). Reactor load uses the same skip path for mid-file
289+
// garbage so resume does not die (CL-7052).
257290
const turns = parseSegmentTurns(text, i === segments.length - 1, name, true);
258291
collectedNewestFirst.push(turns);
259292
total += turns.length;
@@ -391,6 +424,7 @@ export async function createOptimizedContextStore(dir: string): Promise<ContextS
391424
text,
392425
index === extraTexts.length - 1,
393426
segmentFileName(TURNS_FILE, index + 1),
427+
true,
394428
),
395429
);
396430
const keepExtras = longestWellFormedExtraCount(baseTurns, parsedExtras);
@@ -414,10 +448,10 @@ export async function createOptimizedContextStore(dir: string): Promise<ContextS
414448
// optional convenience — callers that only need a recent tail (e.g. TUI
415449
// resume hydration) should use `loadRecentTurns` instead.
416450
//
417-
// When the base isogit store hard-fails (e.g. null-padded turns.jsonl from
418-
// a stale truncate), recover usable turns via resilient segment parse and
419-
// re-read metadata via the base schema (soft-empty only if that fails too)
420-
// so resume does not die on a bare Bun JSON token or wipe pending ops.
451+
// When the base isogit store hard-fails (e.g. null-padded or mid-file
452+
// garbage turns.jsonl), recover usable turns via resilient segment parse
453+
// and re-read metadata via the base schema (soft-empty only if that fails
454+
// too) so resume does not die on a bare Bun JSON token or wipe pending ops.
421455
async load(signal) {
422456
try {
423457
const baseResult = await base.load(signal);
@@ -432,10 +466,12 @@ export async function createOptimizedContextStore(dir: string): Promise<ContextS
432466
let baseTurns: ConversationTurn[];
433467
try {
434468
// Prefer resilient parse of segment 0 alone so orphan-tail heal still runs.
469+
// skipMalformed: mid-file garbage/interleaved records must not kill resume
470+
// (CL-7052); null-pad stripping and torn-tail drop still apply.
435471
const basePath = path.join(dir, TURNS_FILE);
436472
if (await pathExists(basePath)) {
437473
const text = await fs.promises.readFile(basePath, "utf-8");
438-
baseTurns = parseSegmentTurns(text, false, TURNS_FILE);
474+
baseTurns = parseSegmentTurns(text, true, TURNS_FILE, true);
439475
} else {
440476
baseTurns = [];
441477
}

0 commit comments

Comments
 (0)