Skip to content

Commit 7f88025

Browse files
committed
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 6070834 commit 7f88025

2 files changed

Lines changed: 70 additions & 17 deletions

File tree

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,52 @@ describe("createOptimizedContextStore load", () => {
8383
]);
8484
});
8585

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+
86132
test("soft-defaults metadata when metadata.json is corrupt but turns load", async () => {
87133
const dir = tempDir();
88134
const store = await createOptimizedContextStore(dir);

src/session/optimized-context-store.ts

Lines changed: 24 additions & 17 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,
@@ -116,11 +122,13 @@ const EMPTY_TOKEN_USAGE = {
116122
thinking: 0,
117123
} as const;
118124

119-
function emptyMetadata(): {
120-
pendingOperations: never[];
121-
tokenUsage: typeof EMPTY_TOKEN_USAGE;
122-
connectorState: null;
123-
} {
125+
type SessionMetadata = {
126+
pendingOperations: PendingOperation[];
127+
tokenUsage: TokenUsage;
128+
connectorState: ConnectorThreadState | null;
129+
};
130+
131+
function emptyMetadata(): SessionMetadata {
124132
return {
125133
pendingOperations: [],
126134
tokenUsage: { ...EMPTY_TOKEN_USAGE },
@@ -129,17 +137,15 @@ function emptyMetadata(): {
129137
}
130138

131139
/**
132-
* Soft-default metadata when the recovery path cannot use the base store.
133-
* Corrupt or missing metadata.json must not abort resume of usable turns.
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.
134143
*/
135-
async function loadMetadataSoft(dir: string): Promise<ReturnType<typeof emptyMetadata>> {
136-
const metadataPath = path.join(dir, METADATA_FILE);
144+
async function loadMetadataSoft(
145+
loadMetadata: () => Promise<SessionMetadata>,
146+
): Promise<SessionMetadata> {
137147
try {
138-
if (!(await pathExists(metadataPath))) return emptyMetadata();
139-
const text = await fs.promises.readFile(metadataPath, "utf-8");
140-
JSON.parse(text);
141-
// Schema lives in the base store; recovery only needs a safe shell.
142-
return emptyMetadata();
148+
return await loadMetadata();
143149
} catch (cause) {
144150
log.warn("metadata.json unreadable during resilient load; using empty defaults", {
145151
cause: cause instanceof Error ? cause.message : String(cause),
@@ -391,7 +397,8 @@ export async function createOptimizedContextStore(dir: string): Promise<ContextS
391397
//
392398
// When the base isogit store hard-fails (e.g. null-padded turns.jsonl from
393399
// a stale truncate), recover usable turns via resilient segment parse and
394-
// soft-default metadata so resume does not die on a bare Bun JSON token.
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.
395402
async load(signal) {
396403
try {
397404
const baseResult = await base.load(signal);
@@ -428,7 +435,7 @@ export async function createOptimizedContextStore(dir: string): Promise<ContextS
428435
extraTexts.length === 0
429436
? baseTurns
430437
: await loadTurnsWithoutMalformedToolSequence(baseTurns, extraTexts);
431-
const metadata = await loadMetadataSoft(dir);
438+
const metadata = await loadMetadataSoft(() => base.loadMetadata());
432439
return { turns, ...metadata };
433440
}
434441
},

0 commit comments

Comments
 (0)