Skip to content

Commit 07c54ae

Browse files
Stop null-padding turns.jsonl and recover poisoned resume loads (#480)
* Stop null-padding turns.jsonl and recover poisoned resume loads When keepBytes exceeds on-disk size, rebuild the segment instead of truncate-past-EOF (which pads null bytes). On load, if the base store fails, recover usable turns with null-strip parse and soft-default metadata; unrecoverable errors name the file. * Preserve valid metadata when recovering poisoned turns Turn recovery after a hard base.load failure was always soft-emptying metadata even when metadata.json parsed cleanly under the real schema. That dropped pendingOperations, tokenUsage, and connectorState for sessions whose turns.jsonl had null-byte holes but whose gates were still parked — rehydrateGates then found nothing to re-arm and left suspended agents wedged. Prefer base.loadMetadata() on the recovery path so a good metadata file survives; soft-empty only when that load itself fails. Regression covers null-hole turns plus non-empty pendingOperations.
1 parent 1555577 commit 07c54ae

4 files changed

Lines changed: 260 additions & 16 deletions

File tree

src/session/incremental-jsonl.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,34 @@ describe("createSegmentedJSONLWriter", () => {
237237
});
238238
});
239239

240+
describe("createSegmentedJSONLWriter stale keepBytes", () => {
241+
test("does not pad null bytes when on-disk file shrank below keepBytes", async () => {
242+
const dir = tempDir();
243+
const write = createSegmentedJSONLWriter(dir, BASE);
244+
245+
const a = { id: 1, text: "first-record" };
246+
const b = { id: 2, text: "second-record" };
247+
const c = { id: 3, text: "third-record" };
248+
await write([a, b, c]);
249+
250+
const full = path.join(dir, BASE);
251+
// Simulate external shrink/compaction that left the in-memory offsets stale:
252+
// file is shorter than the writer's remembered keepBytes for a shared prefix.
253+
const keptOnDisk = fullSnapshot([a]);
254+
fs.writeFileSync(full, keptOnDisk);
255+
expect(fs.statSync(full).size).toBeLessThan(Buffer.byteLength(fullSnapshot([a, b, c])));
256+
257+
// Shared prefix [a, b] would compute keepBytes past the shrunken file size.
258+
// Writer must rebuild rather than truncate-extend with null padding.
259+
const d = { id: 4, text: "after-shrink" };
260+
await write([a, b, d]);
261+
262+
const onDisk = fs.readFileSync(full);
263+
expect(onDisk.includes(0)).toBe(false);
264+
expect(await combined(dir)).toBe(fullSnapshot([a, b, d]));
265+
});
266+
});
267+
240268
describe("segment readers", () => {
241269
test("readExtraSegmentTexts returns tail segments in order", async () => {
242270
const dir = tempDir();

src/session/incremental-jsonl.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -211,12 +211,31 @@ export function createSegmentedJSONLWriter(
211211
const full = path.join(dir, name);
212212
const truncateInPlace = isFirst && state !== null && entry.keepBytes > 0;
213213
if (truncateInPlace) {
214-
const handle = await fs.promises.open(full, "r+");
214+
// Stale keepBytes (e.g. after external shrink/compaction) can exceed the
215+
// on-disk size. POSIX truncate-past-EOF pads with null bytes, which
216+
// poisons the JSONL and breaks resume with `\u0000` parse errors.
217+
// Never extend via truncate — rewrite the full segment instead.
218+
let existingSize = 0;
215219
try {
216-
await handle.truncate(entry.keepBytes);
217-
if (entry.text.length > 0) await handle.write(entry.text, entry.keepBytes);
218-
} finally {
219-
await handle.close();
220+
existingSize = (await fs.promises.stat(full)).size;
221+
} catch {
222+
existingSize = 0;
223+
}
224+
if (entry.keepBytes > existingSize) {
225+
// Offsets are wrong relative to disk. Rebuild the kept prefix from
226+
// the in-memory records that belong in this segment, then append
227+
// the planned text (the post-prefix lines for this segment).
228+
const keptRecords = records.slice(firstSegStartRecord, prefix);
229+
const fullText = keptRecords.map((r) => lineFor(r)).join("") + entry.text;
230+
await fs.promises.writeFile(full, fullText);
231+
} else {
232+
const handle = await fs.promises.open(full, "r+");
233+
try {
234+
await handle.truncate(entry.keepBytes);
235+
if (entry.text.length > 0) await handle.write(entry.text, entry.keepBytes);
236+
} finally {
237+
await handle.close();
238+
}
220239
}
221240
} else {
222241
await fs.promises.writeFile(full, entry.text);

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

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,104 @@ describe("createOptimizedContextStore load", () => {
6161
expect(loaded.turns.map((t) => (t.content[0] as { text: string }).text)).toEqual(["a", "b"]);
6262
});
6363

64+
test("recovers usable turns when turns.jsonl has a mid-file null-byte hole", async () => {
65+
const dir = tempDir();
66+
const store = await createOptimizedContextStore(dir);
67+
68+
const head = jsonl([turn("a"), turn("b")]);
69+
const tail = jsonl([turn("c")]);
70+
// Simulate truncate-past-EOF null padding between valid JSONL records.
71+
const poisoned = Buffer.concat([
72+
Buffer.from(head, "utf8"),
73+
Buffer.alloc(64, 0),
74+
Buffer.from(tail, "utf8"),
75+
]);
76+
fs.writeFileSync(path.join(dir, TURNS_FILE), poisoned);
77+
78+
const loaded = await store.load();
79+
expect(loaded.turns.map((t) => (t.content[0] as { text: string }).text)).toEqual([
80+
"a",
81+
"b",
82+
"c",
83+
]);
84+
});
85+
86+
test("preserves pendingOperations when turns are poisoned but metadata is valid", async () => {
87+
const dir = tempDir();
88+
const store = await createOptimizedContextStore(dir);
89+
90+
const head = jsonl([turn("a"), turn("b")]);
91+
const tail = jsonl([turn("c")]);
92+
const poisoned = Buffer.concat([
93+
Buffer.from(head, "utf8"),
94+
Buffer.alloc(64, 0),
95+
Buffer.from(tail, "utf8"),
96+
]);
97+
fs.writeFileSync(path.join(dir, TURNS_FILE), poisoned);
98+
99+
// Valid non-empty metadata must survive recovery so rehydrateGates can re-arm.
100+
const pendingOp = {
101+
correlationId: "corr-1",
102+
kind: "approval" as const,
103+
registeredAt: 1_700_000_000_000,
104+
gateId: "gate-1",
105+
};
106+
fs.writeFileSync(
107+
path.join(dir, "metadata.json"),
108+
JSON.stringify({
109+
pendingOperations: [pendingOp],
110+
tokenUsage: { input: 10, output: 20, cacheRead: 1, cacheWrite: 2, thinking: 3 },
111+
connectorState: null,
112+
}),
113+
);
114+
115+
const loaded = await store.load();
116+
expect(loaded.turns.map((t) => (t.content[0] as { text: string }).text)).toEqual([
117+
"a",
118+
"b",
119+
"c",
120+
]);
121+
expect(loaded.pendingOperations).toEqual([pendingOp]);
122+
expect(loaded.tokenUsage).toEqual({
123+
input: 10,
124+
output: 20,
125+
cacheRead: 1,
126+
cacheWrite: 2,
127+
thinking: 3,
128+
});
129+
expect(loaded.connectorState).toBeNull();
130+
});
131+
132+
test("soft-defaults metadata when metadata.json is corrupt but turns load", async () => {
133+
const dir = tempDir();
134+
const store = await createOptimizedContextStore(dir);
135+
136+
fs.writeFileSync(path.join(dir, TURNS_FILE), jsonl([turn("kept")]));
137+
// Corrupt metadata alone must not abort resume when turns are fine.
138+
// Base load parses turns first then metadata — if metadata throws, recovery
139+
// path soft-defaults and still returns turns.
140+
fs.writeFileSync(path.join(dir, "metadata.json"), "{not-json\x00");
141+
142+
const loaded = await store.load();
143+
expect(loaded.turns).toHaveLength(1);
144+
expect((loaded.turns[0]!.content[0] as { text: string }).text).toBe("kept");
145+
expect(loaded.pendingOperations).toEqual([]);
146+
expect(loaded.connectorState).toBeNull();
147+
});
148+
149+
test("unrecoverable turns.jsonl names the file in the error", async () => {
150+
const dir = tempDir();
151+
const store = await createOptimizedContextStore(dir);
152+
153+
// Mid-file garbage that is not null padding and not a torn tail — unrecoverable.
154+
fs.writeFileSync(
155+
path.join(dir, TURNS_FILE),
156+
jsonl([turn("a")]) + "THIS IS NOT JSON\n" + jsonl([turn("b")]),
157+
);
158+
159+
await expect(store.load()).rejects.toThrow(/turns\.jsonl/);
160+
});
161+
64162
// Compacted head rewrites segment 0 while a prior multi-segment history's
65163
// tails stay on disk. Concatenating them reintroduces tool_call ids that the
66164
// compact head already kept — drop the orphan tails so the session can resume.

src/session/optimized-context-store.ts

Lines changed: 110 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@ import fs from "node:fs";
22
import path from "node:path";
33
import { type } from "arktype";
44
import { createIsogitStore } from "@intx/storage-isogit";
5-
import { ContentBlock, type ConversationTurn } from "@intx/types/runtime";
5+
import {
6+
ContentBlock,
7+
type ConnectorThreadState,
8+
type ConversationTurn,
9+
type PendingOperation,
10+
type TokenUsage,
11+
} from "@intx/types/runtime";
612
import { getLogger } from "@intx/log";
713
import {
814
createSegmentedJSONLWriter,
@@ -68,31 +74,86 @@ function sanitizeCallId(callId: string): string {
6874
* Parse conversation turns out of one JSONL segment. A crash can tear the final
6975
* line of the active (last) segment mid-write; when `tolerateTornTail` is set a
7076
* final line that fails to parse is dropped rather than aborting the resume.
77+
*
78+
* Null bytes (truncate-past-EOF padding from a stale keepBytes write) are stripped
79+
* so a poisoned segment can still yield its usable turns on resume. Errors name
80+
* `fileName` when provided so diagnostics point at the on-disk file, not a bare
81+
* Bun JSON token.
7182
*/
72-
function parseSegmentTurns(text: string, tolerateTornTail: boolean): ConversationTurn[] {
83+
function parseSegmentTurns(
84+
text: string,
85+
tolerateTornTail: boolean,
86+
fileName = "turns segment",
87+
): ConversationTurn[] {
7388
if (text.length === 0) return [];
74-
const lines = text.split("\n");
89+
// POSIX truncate past EOF pads with `\0`. Strip them so the rest of the JSONL
90+
// remains parseable instead of dying on Unrecognized token '\u0000'.
91+
const cleaned = text.includes("\0") ? text.replaceAll("\0", "") : text;
92+
if (cleaned.length === 0) return [];
93+
const lines = cleaned.split("\n");
7594
if (lines[lines.length - 1] === "") lines.pop();
7695

7796
const turns: ConversationTurn[] = [];
7897
for (let i = 0; i < lines.length; i++) {
98+
const line = lines[i]!;
99+
if (line.length === 0) continue;
79100
const isLast = i === lines.length - 1;
80101
let raw: unknown;
81102
try {
82-
raw = JSON.parse(lines[i]!);
103+
raw = JSON.parse(line);
83104
} catch (cause) {
84105
if (tolerateTornTail && isLast) break;
85-
throw new Error("turns segment has malformed JSON", { cause });
106+
throw new Error(`${fileName} has malformed JSON at line ${i + 1}`, { cause });
86107
}
87108
const result = ConversationTurnSchema(raw);
88109
if (result instanceof type.errors) {
89-
throw new Error(`turns segment has unexpected structure: ${result.summary}`);
110+
throw new Error(`${fileName} has unexpected structure at line ${i + 1}: ${result.summary}`);
90111
}
91112
turns.push(result);
92113
}
93114
return turns;
94115
}
95116

117+
const EMPTY_TOKEN_USAGE = {
118+
input: 0,
119+
output: 0,
120+
cacheRead: 0,
121+
cacheWrite: 0,
122+
thinking: 0,
123+
} as const;
124+
125+
type SessionMetadata = {
126+
pendingOperations: PendingOperation[];
127+
tokenUsage: TokenUsage;
128+
connectorState: ConnectorThreadState | null;
129+
};
130+
131+
function emptyMetadata(): SessionMetadata {
132+
return {
133+
pendingOperations: [],
134+
tokenUsage: { ...EMPTY_TOKEN_USAGE },
135+
connectorState: null,
136+
};
137+
}
138+
139+
/**
140+
* Prefer real metadata via the base store schema on recovery. Soft-default only
141+
* when metadata.json is missing, corrupt, or otherwise unreadable so poisoned
142+
* turns still resume without wiping pendingOperations / tokenUsage / connectorState.
143+
*/
144+
async function loadMetadataSoft(
145+
loadMetadata: () => Promise<SessionMetadata>,
146+
): Promise<SessionMetadata> {
147+
try {
148+
return await loadMetadata();
149+
} catch (cause) {
150+
log.warn("metadata.json unreadable during resilient load; using empty defaults", {
151+
cause: cause instanceof Error ? cause.message : String(cause),
152+
});
153+
return emptyMetadata();
154+
}
155+
}
156+
96157
// Mirrors assertWellFormedToolSequence without throwing. Used to choose the
97158
// longest segment prefix the reactor will accept after a load. Unpaired
98159
// trailing tool_calls are allowed; dups and orphan results fail.
@@ -333,12 +394,50 @@ export async function createOptimizedContextStore(dir: string): Promise<ContextS
333394
// the complete turn history is the actual live conversation state, not an
334395
// optional convenience — callers that only need a recent tail (e.g. TUI
335396
// resume hydration) should use `loadRecentTurns` instead.
397+
//
398+
// When the base isogit store hard-fails (e.g. null-padded turns.jsonl from
399+
// a stale truncate), recover usable turns via resilient segment parse and
400+
// re-read metadata via the base schema (soft-empty only if that fails too)
401+
// so resume does not die on a bare Bun JSON token or wipe pending ops.
336402
async load(signal) {
337-
const baseResult = await base.load(signal);
338-
const extraTexts = await readExtraSegmentTexts(dir, TURNS_FILE);
339-
if (extraTexts.length === 0) return baseResult;
340-
const turns = await loadTurnsWithoutMalformedToolSequence(baseResult.turns, extraTexts);
341-
return { ...baseResult, turns };
403+
try {
404+
const baseResult = await base.load(signal);
405+
const extraTexts = await readExtraSegmentTexts(dir, TURNS_FILE);
406+
if (extraTexts.length === 0) return baseResult;
407+
const turns = await loadTurnsWithoutMalformedToolSequence(baseResult.turns, extraTexts);
408+
return { ...baseResult, turns };
409+
} catch (cause) {
410+
log.warn(
411+
"base context store load failed; recovering turns from disk segments",
412+
{ cause: cause instanceof Error ? cause.message : String(cause) },
413+
);
414+
let baseTurns: ConversationTurn[];
415+
try {
416+
// Prefer resilient parse of segment 0 alone so orphan-tail heal still runs.
417+
const basePath = path.join(dir, TURNS_FILE);
418+
if (await pathExists(basePath)) {
419+
const text = await fs.promises.readFile(basePath, "utf-8");
420+
baseTurns = parseSegmentTurns(text, false, TURNS_FILE);
421+
} else {
422+
baseTurns = [];
423+
}
424+
} catch (parseCause) {
425+
// Unrecoverable: rethrow with the file name in the message.
426+
throw new Error(
427+
`failed to load ${TURNS_FILE}: ${
428+
parseCause instanceof Error ? parseCause.message : String(parseCause)
429+
}`,
430+
{ cause: parseCause },
431+
);
432+
}
433+
const extraTexts = await readExtraSegmentTexts(dir, TURNS_FILE);
434+
const turns =
435+
extraTexts.length === 0
436+
? baseTurns
437+
: await loadTurnsWithoutMalformedToolSequence(baseTurns, extraTexts);
438+
const metadata = await loadMetadataSoft(() => base.loadMetadata());
439+
return { turns, ...metadata };
440+
}
342441
},
343442
setConnectorState: (state) => base.setConnectorState(state),
344443
branch: (name, signal) => base.branch(name, signal),

0 commit comments

Comments
 (0)