Skip to content

Commit ebd3fbe

Browse files
Merge pull request #1492 from corbitsdev/cl-10765-never-inject-a-timeout-result-for-a-parked-call-that-already
fix(reactor): never re-answer a parked call that already has a result
2 parents 19ce192 + 0bffe4f commit ebd3fbe

2 files changed

Lines changed: 264 additions & 1 deletion

File tree

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

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2268,3 +2268,222 @@ describe("createOptimizedContextStore metadata ownership (CL-10667)", () => {
22682268
expect((await resumed.load()).tokenUsage).toEqual(USAGE_TWO);
22692269
});
22702270
});
2271+
2272+
describe("createOptimizedContextStore load drops pending operations for answered calls", () => {
2273+
function callTurn(callId: string, timestamp: number): ConversationTurn {
2274+
return {
2275+
role: "assistant",
2276+
content: [
2277+
{ type: "tool_call", id: callId, name: "run_shell", arguments: {} },
2278+
],
2279+
timestamp,
2280+
};
2281+
}
2282+
2283+
function resultTurn(
2284+
callId: string,
2285+
text: string,
2286+
timestamp: number,
2287+
): ConversationTurn {
2288+
return {
2289+
role: "user",
2290+
content: [
2291+
{ type: "tool_result", callId, content: [{ type: "text", text }] },
2292+
],
2293+
timestamp,
2294+
};
2295+
}
2296+
2297+
function approvalOp(correlationId: string, callId: string) {
2298+
return {
2299+
correlationId,
2300+
kind: "approval" as const,
2301+
registeredAt: 1_700_000_000_000,
2302+
gateId: `pending-${correlationId}`,
2303+
timeoutAt: Date.now() + 60_000,
2304+
suspendedCall: { id: callId, name: "run_shell", arguments: {} },
2305+
};
2306+
}
2307+
2308+
function markerOp(correlationId: string) {
2309+
return {
2310+
correlationId,
2311+
kind: "approval" as const,
2312+
registeredAt: 1_700_000_000_000,
2313+
gateId: `pending-${correlationId}`,
2314+
};
2315+
}
2316+
2317+
function placeCrashState(
2318+
dir: string,
2319+
turns: ConversationTurn[],
2320+
pendingOperations: unknown[],
2321+
): void {
2322+
fs.writeFileSync(path.join(dir, TURNS_FILE), jsonl(turns));
2323+
fs.writeFileSync(
2324+
path.join(dir, "metadata.json"),
2325+
JSON.stringify({ pendingOperations, connectorState: null }, null, 2),
2326+
);
2327+
}
2328+
2329+
// The crash window between the result-turn write and the metadata op-removal
2330+
// write leaves turns answering a parked call while metadata.json still lists
2331+
// its pending operation. load() is the only path into the rebuilt
2332+
// reactor's rehydrateGates, so the stale operation must die here.
2333+
test("drops an answered operation while keeping unanswered and marker operations", async () => {
2334+
const dir = tempDir();
2335+
placeCrashState(
2336+
dir,
2337+
[callTurn("call-answered", 1), resultTurn("call-answered", "ok", 2)],
2338+
[
2339+
approvalOp("stale-corr", "call-answered"),
2340+
approvalOp("live-corr", "call-live"),
2341+
markerOp("marker-corr"),
2342+
],
2343+
);
2344+
2345+
const store = await createOptimizedContextStore(dir);
2346+
const loaded = await store.load();
2347+
expect(
2348+
loaded.pendingOperations.map((op) => op.correlationId).sort(),
2349+
).toEqual(["live-corr", "marker-corr"]);
2350+
});
2351+
2352+
test("repeated loads are stable and emit no metadata write", async () => {
2353+
const dir = tempDir();
2354+
placeCrashState(
2355+
dir,
2356+
[callTurn("call-answered", 1), resultTurn("call-answered", "ok", 2)],
2357+
[approvalOp("stale-corr", "call-answered"), markerOp("marker-corr")],
2358+
);
2359+
2360+
const store = await createOptimizedContextStore(dir);
2361+
const before = fs.readFileSync(path.join(dir, "metadata.json"), "utf8");
2362+
const first = await store.load();
2363+
const second = await store.load();
2364+
expect(second.pendingOperations).toEqual(first.pendingOperations);
2365+
expect(first.pendingOperations.map((op) => op.correlationId)).toEqual([
2366+
"marker-corr",
2367+
]);
2368+
expect(fs.readFileSync(path.join(dir, "metadata.json"), "utf8")).toBe(
2369+
before,
2370+
);
2371+
});
2372+
2373+
test("the next metadata write persists the removal", async () => {
2374+
const dir = tempDir();
2375+
placeCrashState(
2376+
dir,
2377+
[callTurn("call-answered", 1), resultTurn("call-answered", "ok", 2)],
2378+
[
2379+
approvalOp("stale-corr", "call-answered"),
2380+
approvalOp("live-corr", "call-live"),
2381+
],
2382+
);
2383+
2384+
const store = await createOptimizedContextStore(dir);
2385+
const loaded = await store.load();
2386+
await store.writeMetadata({
2387+
pendingOperations: loaded.pendingOperations,
2388+
tokenUsage: loaded.tokenUsage,
2389+
});
2390+
2391+
const persisted = JSON.parse(
2392+
fs.readFileSync(path.join(dir, "metadata.json"), "utf8"),
2393+
) as { pendingOperations: { correlationId: string }[] };
2394+
expect(persisted.pendingOperations.map((op) => op.correlationId)).toEqual([
2395+
"live-corr",
2396+
]);
2397+
2398+
const resumed = await createOptimizedContextStore(dir);
2399+
expect(
2400+
(await resumed.load()).pendingOperations.map((op) => op.correlationId),
2401+
).toEqual(["live-corr"]);
2402+
});
2403+
2404+
test("crash-window replay across segments yields no re-armable gate", async () => {
2405+
const dir = tempDir();
2406+
fs.writeFileSync(
2407+
path.join(dir, TURNS_FILE),
2408+
jsonl([callTurn("call-answered", 1)]),
2409+
);
2410+
fs.writeFileSync(
2411+
path.join(dir, segmentFileName(TURNS_FILE, 1)),
2412+
jsonl([resultTurn("call-answered", "ok", 2)]),
2413+
);
2414+
fs.writeFileSync(
2415+
path.join(dir, "metadata.json"),
2416+
JSON.stringify({
2417+
pendingOperations: [
2418+
approvalOp("stale-corr", "call-answered"),
2419+
approvalOp("live-corr", "call-live"),
2420+
markerOp("marker-corr"),
2421+
],
2422+
connectorState: null,
2423+
}),
2424+
);
2425+
2426+
const store = await createOptimizedContextStore(dir);
2427+
const loaded = await store.load();
2428+
const answered = new Set(
2429+
loaded.turns.flatMap((turn) =>
2430+
turn.content.flatMap((block) =>
2431+
block.type === "tool_result" ? [block.callId] : [],
2432+
),
2433+
),
2434+
);
2435+
// Every operation load() hands the rebuilt reactor is safe to rehydrate:
2436+
// none parks a call the turns already answer.
2437+
for (const op of loaded.pendingOperations) {
2438+
if (op.suspendedCall !== undefined) {
2439+
expect(answered.has(op.suspendedCall.id)).toBe(false);
2440+
}
2441+
}
2442+
expect(
2443+
loaded.pendingOperations.map((op) => op.correlationId).sort(),
2444+
).toEqual(["live-corr", "marker-corr"]);
2445+
});
2446+
2447+
test("matches a scrubbed on-disk operation by call id", async () => {
2448+
const dir = tempDir();
2449+
const store = await createOptimizedContextStore(dir);
2450+
const secret = { api_key: "hunter2" };
2451+
await store.writeMetadata({
2452+
pendingOperations: [
2453+
{
2454+
...approvalOp("scrubbed-corr", "call-scrubbed"),
2455+
suspendedCall: {
2456+
id: "call-scrubbed",
2457+
name: "run_shell",
2458+
arguments: secret,
2459+
},
2460+
approvalSnapshot: {
2461+
name: "run_shell",
2462+
description: "runs a shell command",
2463+
inputSchema: {},
2464+
arguments: secret,
2465+
},
2466+
},
2467+
],
2468+
tokenUsage: {
2469+
input: 0,
2470+
output: 0,
2471+
cacheRead: 0,
2472+
cacheWrite: 0,
2473+
thinking: 0,
2474+
},
2475+
});
2476+
// The on-disk approval snapshot is redacted while suspendedCall keeps
2477+
// the raw call; matching must work by call id alone.
2478+
const raw = fs.readFileSync(path.join(dir, "metadata.json"), "utf8");
2479+
expect(raw).toContain("[redacted");
2480+
expect(raw).toContain("call-scrubbed");
2481+
await store.writeTurns([
2482+
callTurn("call-scrubbed", 1),
2483+
resultTurn("call-scrubbed", "ok", 2),
2484+
]);
2485+
2486+
const resumed = await createOptimizedContextStore(dir);
2487+
expect((await resumed.load()).pendingOperations).toEqual([]);
2488+
});
2489+
});

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

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,22 @@ function readMetadataDocument(raw: string): Pick<
336336
};
337337
}
338338

339+
/**
340+
* Calls the turn history already answers. A crash between the result-turn
341+
* write and the metadata op-removal write leaves turns answering a parked
342+
* call while metadata.json still lists its pending operation; matching is by
343+
* call id, which survives the on-disk approval-snapshot scrub.
344+
*/
345+
function answeredCallIds(turns: readonly ConversationTurn[]): Set<string> {
346+
const answered = new Set<string>();
347+
for (const turn of turns) {
348+
for (const block of turn.content) {
349+
if (block.type === "tool_result") answered.add(block.callId);
350+
}
351+
}
352+
return answered;
353+
}
354+
339355
/**
340356
* metadata.json + sidecar merge. Sidecar wins over a legacy tokenUsage field,
341357
* which wins over empty. Parse failures stay inside this function: a torn
@@ -1674,7 +1690,35 @@ export async function createSessionStores(
16741690
);
16751691
metadata = emptyMetadata();
16761692
}
1677-
return { turns, ...metadata };
1693+
// Crash-window filter: load() is the only path into the rebuilt
1694+
// reactor's gate rehydration, so a pending operation whose parked call
1695+
// the turns already answer dies here instead of re-arming its gate.
1696+
// Marker operations carry no suspendedCall and pass through. Disk is
1697+
// untouched (no write-in-read): the removal converges on the next
1698+
// normal metadata write, which publishes because the filtered payload
1699+
// differs from the seeded disk bytes.
1700+
const answered = answeredCallIds(turns);
1701+
const stale = metadata.pendingOperations.filter(
1702+
(op) =>
1703+
op.suspendedCall !== undefined && answered.has(op.suspendedCall.id),
1704+
);
1705+
for (const op of stale) {
1706+
log.warn(
1707+
"dropped pending operation {correlationId}: its parked call {callId} already has a result",
1708+
{
1709+
correlationId: op.correlationId,
1710+
callId: op.suspendedCall?.id ?? "",
1711+
},
1712+
);
1713+
}
1714+
if (stale.length === 0) return { turns, ...metadata };
1715+
return {
1716+
turns,
1717+
...metadata,
1718+
pendingOperations: metadata.pendingOperations.filter(
1719+
(op) => !stale.includes(op),
1720+
),
1721+
};
16781722
},
16791723
// Buffer is carried only by this store: base.writeMetadata is never
16801724
// called through the wrapper, so base's pendingConnectorState would

0 commit comments

Comments
 (0)