Skip to content

Commit efa0a36

Browse files
authored
Merge pull request #1575 from corbitsdev/cl-11433-persist-rejected-timed-out-and-dropped-approvals-before-an
fix(reactor): keep rejected and timed-out approval results across a rebuild
2 parents 8b006eb + 31a5741 commit efa0a36

4 files changed

Lines changed: 495 additions & 18 deletions

File tree

‎e2e/reactor-approval-reload.test.ts‎

Lines changed: 185 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,31 @@ function toolResultsFor(
181181
);
182182
}
183183

184+
interface RequestMessage {
185+
content: string | { type: string; id?: string; tool_use_id?: string }[];
186+
}
187+
188+
/** The block types naming `callId` in the last model request, in order. */
189+
async function lastRequestBlocksFor(
190+
session: IntegrationSession,
191+
callId: string,
192+
): Promise<string[]> {
193+
const request = session.harness.scenario.matchedRequests().at(-1);
194+
if (request === undefined) throw new Error("no routed requests");
195+
const body = JSON.parse(
196+
await (request.clone() as unknown as Request).text(),
197+
) as { messages: RequestMessage[] };
198+
return body.messages.flatMap((message) =>
199+
typeof message.content === "string"
200+
? []
201+
: message.content.flatMap((block) =>
202+
block.id === callId || block.tool_use_id === callId
203+
? [block.type]
204+
: [],
205+
),
206+
);
207+
}
208+
184209
function collect(session: IntegrationSession): ReactorEmittedEvent[] {
185210
const events: ReactorEmittedEvent[] = [];
186211
void (async () => {
@@ -495,10 +520,166 @@ describe("integration: approving an ask, then reloading the agent", () => {
495520
);
496521
});
497522

498-
// Reloads at the run's end, where the TUI's deferred reload fires. A reload
499-
// after the decision but before the cycle commits is not covered here: the
500-
// rejected or timed-out result is not yet persisted, so the rebuilt agent
501-
// loads the parked call with no result at all.
523+
// Reloads once the model's next turn has started, before the cycle commits:
524+
// where an interrupt or session rotation lands mid-run.
525+
describe("integration: an ask settled without running, then reloading the agent before the cycle commits", () => {
526+
const continuationStarted = (session: IntegrationSession): boolean =>
527+
session.harness.scenario.matchedRequests().length === 2;
528+
529+
test.serial(
530+
"after a rejection: the rebuilt agent sends the denial once, and the old deadline adds no result",
531+
async () => {
532+
const reloaded = await decideThenReload(
533+
{ name: "run_shell", args: { command: CLOSED_PORT_PROBE } },
534+
continuationStarted,
535+
{ decision: "reject" },
536+
);
537+
const { session, callId } = reloaded;
538+
expect((await session.storage.load()).pendingOperations).toEqual([]);
539+
const settled = toolResultsFor(await session.agent.history(), callId);
540+
expect(settled).toHaveLength(1);
541+
expect(settled.join("")).toContain("denied by approver");
542+
543+
await passDeadline(reloaded);
544+
expect(toolResultsFor(await session.agent.history(), callId)).toEqual(
545+
settled,
546+
);
547+
548+
session.harness.scenario.replyOnce("anthropic", { text: "Done." });
549+
const followUp = await runUntilDone(session, "Continue.");
550+
expect(followUp.reply).toBe("Done.");
551+
expect(await lastRequestBlocksFor(session, callId)).toEqual([
552+
"tool_use",
553+
"tool_result",
554+
]);
555+
expect(reloaded.events.filter((e) => e.type === "reactor.error")).toEqual(
556+
[],
557+
);
558+
},
559+
20_000,
560+
);
561+
562+
test.serial(
563+
"after the deadline answers it: the rebuilt agent loads the timeout result and no parked call",
564+
async () => {
565+
const reloaded = await decideThenReload(
566+
{ name: "run_shell", args: { command: CLOSED_PORT_PROBE } },
567+
continuationStarted,
568+
{ decision: "expire" },
569+
);
570+
const { session, callId } = reloaded;
571+
// Read before the rebuilt agent runs: a parked call it loaded would be
572+
// re-armed at the already-past deadline and answered by the rebuilt
573+
// reactor itself, which would hide the lost result.
574+
expect((await session.storage.load()).pendingOperations).toEqual([]);
575+
const settled = toolResultsFor(await session.agent.history(), callId);
576+
expect(settled).toHaveLength(1);
577+
expect(settled.join("")).toContain("approval timed out");
578+
579+
await passDeadline(reloaded, reloaded.deadline + FRESH_WINDOW_PASSED_MS);
580+
expect(toolResultsFor(await session.agent.history(), callId)).toEqual(
581+
settled,
582+
);
583+
584+
session.harness.scenario.replyOnce("anthropic", { text: "Done." });
585+
const followUp = await runUntilDone(session, "Continue.");
586+
expect(followUp.reply).toBe("Done.");
587+
expect(await lastRequestBlocksFor(session, callId)).toEqual([
588+
"tool_use",
589+
"tool_result",
590+
]);
591+
},
592+
20_000,
593+
);
594+
});
595+
596+
// The loop is busy on a second message's inference when the rejection is
597+
// accepted, so the close's abort is served first and only the shutdown flush
598+
// writes the rejection's result. The reactor leaves the approval record
599+
// behind; the session store must close it on load.
600+
describe("integration: a rejection the reactor accepted but never ran, then reopening the agent", () => {
601+
test.serial(
602+
"the reopened agent loads the denial once and no parked call, and the old deadline adds no result",
603+
async () => {
604+
const clock = holdLongTimers();
605+
cleanups.push(() => clock.restore());
606+
const ctx = deferredApprovalGate();
607+
const sessionOpts = {
608+
permissionGate: ctx.gate,
609+
authorize: createReactorAuthorize(ctx.gate),
610+
pinToolSetAtBuild: true,
611+
};
612+
const live = { session: await openIntegrationSession(sessionOpts) };
613+
cleanups.push(() => closeIntegrationSession(live.session));
614+
live.session.harness.scenario.replyOnce("anthropic", {
615+
toolCalls: [
616+
{ name: "run_shell", args: { command: CLOSED_PORT_PROBE } },
617+
],
618+
});
619+
live.session.harness.scenario.stall();
620+
621+
const turn = await runUntilSuspended(
622+
live.session,
623+
"Probe the local port.",
624+
);
625+
const suspended = turn.result;
626+
if (suspended.type !== "suspended") throw new Error("did not park");
627+
const parked = (await live.session.storage.load()).pendingOperations.find(
628+
(op) => op.correlationId === suspended.correlationId,
629+
);
630+
const callId = parked?.suspendedCall?.id;
631+
const deadline = parked?.timeoutAt;
632+
if (callId === undefined || deadline === undefined) {
633+
throw new Error("parked call has no persisted call id or deadline");
634+
}
635+
636+
void live.session.agent.send("Still there?").catch(() => undefined);
637+
await waitFor(
638+
() => live.session.harness.scenario.matchedRequests().length === 2,
639+
"second message's inference",
640+
);
641+
const resume = createApprovalResume({
642+
getAgent: () => live.session.agent,
643+
gate: ctx.gate,
644+
resolveParkedCallId: (id) =>
645+
resolveParkedCallIdFromStore(live.session.storage, id),
646+
});
647+
const handling = resume.handle(suspended);
648+
await waitFor(ctx.asked, "approval prompt");
649+
ctx.reject();
650+
expect(await handling).toBe(true);
651+
await waitFor(
652+
() => turn.events.some((e) => e.type === "message.correlated"),
653+
"rejection accepted",
654+
);
655+
656+
live.session = await rebuildIntegrationSession(live.session, sessionOpts);
657+
const { session } = live;
658+
expect((await session.storage.load()).pendingOperations).toEqual([]);
659+
const settled = toolResultsFor(await session.agent.history(), callId);
660+
expect(settled).toHaveLength(1);
661+
expect(settled.join("")).toContain("denied by approver");
662+
663+
const reloaded = {
664+
session,
665+
events: collect(session),
666+
callId,
667+
deadline,
668+
clock,
669+
};
670+
await passDeadline(reloaded, deadline + FRESH_WINDOW_PASSED_MS);
671+
expect(reloaded.events.filter((e) => e.type === "reactor.error")).toEqual(
672+
[],
673+
);
674+
expect(toolResultsFor(await session.agent.history(), callId)).toEqual(
675+
settled,
676+
);
677+
},
678+
20_000,
679+
);
680+
});
681+
682+
// Reloads at the run's end, where the TUI's deferred reload fires.
502683
describe("integration: an ask settled without running, then reloading the agent at the run's end", () => {
503684
const runEnded = (
504685
_session: IntegrationSession,

‎vendor/intx-inference/PATCHES.md‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -508,9 +508,37 @@ segments in the working tree, and `load()` (the path an interrupt-rebuild
508508
takes) reads them, so the tool exchange survives a rebuild exactly as the
509509
old per-batch commit did.
510510

511+
The loop's `resume.tool_result` step (a parked call rejected, timed out or
512+
dropped without running) writes turns the same way, through the shared
513+
`writeTurnsIfChanged` helper `executeTools` also calls, so a rebuild before
514+
the next terminal loads the result. That closes the parked approval only
515+
because Corbits' optimized context store drops, on load, a pending operation
516+
whose call has a `tool_result` in the turn log (`openPendingOperations` in
517+
`src/session/optimized-context-store.ts`). The reactor does not clear the
518+
record itself: a store that returns pending operations from metadata as last
519+
committed, such as `@intx/storage-isogit`, reloads both the result and the
520+
parked approval, and the re-armed gate's deadline adds a second result.
521+
Upstream needs no equivalent while it commits every tool cycle. A staged
522+
compact skips the write in both places; a parked approval cannot coincide
523+
with one, since the suspend terminal commits and clears it.
524+
525+
`initiateShutdown` appends any `resume.tool_result` still queued behind the
526+
abort (a decision the reactor accepted and removed from its pending set, but
527+
never processed, since the loop serves `abort` first), before
528+
`gates.shutdown()` clears the gate timers. It then writes turns once, only
529+
when it appended something, inside a `try/catch` that reports a non-fatal
530+
`reactor.error`, so a failing store cannot skip `reactor.done` or leak the
531+
workdir lock. Approved calls (`resume.execute_tools`) are not flushed: one
532+
that never ran stays parked on disk rather than gaining a result for a
533+
tool that did not run. A flushed result closes the approval under the same
534+
store dependency as the `resume.tool_result` write.
535+
511536
**Disposition:** Promotion candidate. Performance coalescing — collapses K
512537
git commits per agentic turn to one deferred commit while preserving
513-
rebuild-from-disk durability.
538+
rebuild-from-disk durability. The `resume.tool_result` write and the
539+
shutdown flush rely on the store closing an answered call's approval, which
540+
upstream stores do not do, so promoting them also needs the reactor to clear
541+
that pending operation when it writes the result.
514542
**Removal path:** Upstream PR replacing the `executeTools` checkpoint with a
515543
writeTurns-only segments touch and deferring the cycle commit to cycle
516544
terminals.
@@ -750,7 +778,7 @@ revisit point is the next vendored sync (see `docs/VENDORING.md`).
750778
| errors-ts-classify-abort-reason | Add optional `reason` param to `classifyAbortError`, carried as `raw: { origin }` | Alexander Guy <alexander.guy@pm.me> | This ledger (#errors-ts-classify-abort-reason) | Next vendored sync |
751779
| 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 |
752780
| 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 |
753-
| 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 | Alexander Guy <alexander.guy@pm.me> | This ledger (#reactor-ts-tool-cycle-turns-only) | Next vendored sync |
781+
| reactor-ts-tool-cycle-turns-only (supersedes reactor-ts-checkpoint-after-tool-cycle) | `executeTools` and the `resume.tool_result` step write turns to store segments only and defer the cycle commit to terminals | Alexander Guy <alexander.guy@pm.me> | This ledger (#reactor-ts-tool-cycle-turns-only) | Next vendored sync |
754782
| 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 |
755783
| 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 |
756784
| 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)