Skip to content

Commit 0bffe4f

Browse files
committed
fix(session): drop answered pending operations on load
Crash recovery could reload a pending operation for a call the turns already answer, re-arming its gate and injecting a second tool result at the deadline. load() now drops any pending operation whose suspended call id already has a tool result, warning per drop; marker operations without a suspended call pass through. The removal converges to disk on the next metadata write.
1 parent 0278479 commit 0bffe4f

5 files changed

Lines changed: 268 additions & 200 deletions

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

‎vendor/intx-inference/PATCHES.md‎

Lines changed: 1 addition & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -484,8 +484,7 @@ second `tool_result` for the same call id, failing the well-formedness
484484
tripwire. Tool cycles that leave pending operations untouched still write
485485
turns only, so the coalescing savings hold. A crash between the turns write
486486
and the metadata write can still leave a stale op for an answered call;
487-
that narrower window is #reactor-ts-skip-answered-parked-call's concern, not
488-
this patch's.
487+
that narrower window is the rehydration guard's concern, not this patch's.
489488

490489
**Disposition:** Promotion candidate. Performance coalescing — collapses K
491490
git commits per agentic turn to one deferred commit while preserving
@@ -499,38 +498,6 @@ batch committed); the turns still reach disk segments, so only the cycle's
499498
manifest records and metadata staging stay uncommitted. Intentionally not
500499
committed; re-verify after upstream doom-loop accounting changes.
501500

502-
## reactor-ts-skip-answered-parked-call
503-
504-
`reactor.ts` — A parked call that history already answers is never answered
505-
again. `start()` drops every loaded pending operation whose
506-
`suspendedCall.id` already has a `tool_result` in the loaded turns: the
507-
state manager never holds it, `rehydrateGates` never re-arms it, and the
508-
removal is written to metadata before `reactor.start`. `onGateCleared`'s
509-
timeout path still removes the op but, when the parked call already has a
510-
result, enqueues no synthetic "approval timed out" result (and no
511-
`reactor.gate.cleared`) and logs a warning instead. Both checks scan the
512-
turns through the module-level `answeredCallIds` helper.
513-
514-
Defense in depth behind the pending-operation durability contract in
515-
#reactor-ts-tool-cycle-turns-only: that patch writes the result turn and then
516-
metadata in two awaits, so a crash between them leaves a stale op for an
517-
answered call. Re-armed, it fired at the original deadline, appended a second
518-
`tool_result` for the same call id, and the next inference failed the
519-
well-formedness tripwire (`Malformed tool sequence: duplicate tool_result`),
520-
killing the session. The timeout guard leaves `pendingOperationsChanged` set
521-
rather than writing metadata from the timer callback; the next turns-only
522-
write or terminal persists the removal, and a crash before then is caught by
523-
the startup check.
524-
525-
**Disposition:** Promotion candidate. Upstream's rehydration and timeout
526-
paths trust persisted pending operations without consulting history.
527-
**Removal path:** Upstream PR making rehydration and the approval timeout
528-
skip calls that already have a result.
529-
**Re-carry:** new at this patch; sits in `start()` around
530-
`createStateManager`/`rehydrateGates` and inside the `onGateCleared` timeout
531-
branch. Re-verify after upstream changes to rehydration or to the ask-rail
532-
timeout result.
533-
534501
## reactor-ts-skip-unchanged-history
535502

536503
`reactor.ts` — Skip re-serializing unchanged history on checkpoint.
@@ -761,7 +728,6 @@ revisit point is the next vendored sync (see `docs/VENDORING.md`).
761728
| harness-ts-inactivity-on-semantic-progress | Gate the inactivity watchdog's re-arm on parsed events, not raw SSE bytes | Alexander Guy <alexander.guy@pm.me> | This ledger (#harness-ts-inactivity-on-semantic-progress) | Next vendored sync |
762729
| reactor-ts-correlating-ids-leak | Wrap `tryCorrelate` in try/finally so `correlatingIds` clears on success dispatch paths | Alexander Guy <alexander.guy@pm.me> | This ledger (#reactor-ts-correlating-ids-leak) | Next vendored sync |
763730
| reactor-ts-tool-cycle-turns-only (supersedes reactor-ts-checkpoint-after-tool-cycle) | `executeTools` writes turns to store segments only and defers the cycle commit to terminals; metadata rides along only when a resolved approval dropped a pending operation | Alexander Guy <alexander.guy@pm.me> | This ledger (#reactor-ts-tool-cycle-turns-only) | Next vendored sync |
764-
| reactor-ts-skip-answered-parked-call | Drop pending operations whose parked call already has a result at rehydration, and inject no approval-timeout result for such a call | Alexander Guy <alexander.guy@pm.me> | This ledger (#reactor-ts-skip-answered-parked-call) | Next vendored sync |
765731
| reactor-ts-skip-unchanged-history (+ reactor-ts-last-written-turns-revision) | Skip `contextStore.writeTurns` when `getTurnsRevision()` is unchanged | Alexander Guy <alexander.guy@pm.me> | This ledger (#reactor-ts-skip-unchanged-history) | Next vendored sync |
766732
| reactor-ts-compact-publish-then-memory | Persist compact blobs, stage `writeTurns`, commit, then `replaceTurns` so memory stays on the old generation until publication | Alexander Guy <alexander.guy@pm.me> | This ledger (#reactor-ts-compact-publish-then-memory) | Next vendored sync |
767733
| reactor-ts-after-checkpoint-director-only | Gate `afterCheckpoint` on `hasOverride` so auto-commits do not emit it | Alexander Guy <alexander.guy@pm.me> | This ledger (#reactor-ts-after-checkpoint-director-only) | Next vendored sync |

0 commit comments

Comments
 (0)