Skip to content

Commit 2ac29d2

Browse files
committed
fix(reactor): open a message run when an approval resumes a parked call
A run opens only when the loop dequeues message.received, and a correlated approval never becomes one. A parked call rehydrated by a new reactor has no run from the process that parked it, so its approval re-dispatched the call and re-inferred with no run open, and consumers that treat no open run as idle could rebuild the agent under the running continuation. The approval now opens a run keyed on its own message id when none is open. A resume inside the still-open parking run is unchanged.
1 parent 0278479 commit 2ac29d2

3 files changed

Lines changed: 235 additions & 0 deletions

File tree

‎vendor/intx-inference/PATCHES.md‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,39 @@ skip calls that already have a result.
531531
branch. Re-verify after upstream changes to rehydration or to the ask-rail
532532
timeout result.
533533

534+
## reactor-ts-resume-opens-message-run
535+
536+
`reactor.ts` — A correlated approval that resumes a parked call opens a
537+
message run when none is open. `tryCorrelate`, after the `redispatch` or
538+
`error_result` dispatch has consumed the pending operation and enqueued the
539+
continuation (and before `message.correlated`), calls `openMessageRun` keyed
540+
on the approval message's `messageId`. The re-dispatched call and its
541+
continuation then run inside a normal `message.run.started` /
542+
`message.run.ended` bracket, closed by the usual reply/wait/done terminal or
543+
a failure. When a run is already open (the live case, where the run that
544+
parked the call is still open) nothing changes: the resume continues that
545+
run and no second bracket opens. The `gate-cleared` mode (an async-marker
546+
operation with no suspended call) is untouched.
547+
548+
Upstream opens a run only when the loop dequeues `message.received`, and a
549+
correlated approval never becomes one. A pending operation rehydrated by a
550+
new reactor (restart, `corbits resume`, an agent rebuild) has no run from
551+
the parking process, so its approval drove tool execution and inference
552+
with no run open. Consumers that treat "no open run" as idle (the TUI defers
553+
agent reloads to `message.run.ended`) then saw the session as idle mid-call
554+
and could rebuild the agent under the running continuation. The
555+
`message.run.started` doc comment in `@intx/types` still describes the
556+
dequeue-only trigger; it is left verbatim.
557+
558+
**Disposition:** Promotion candidate. Upstream's run bracket has no owner
559+
for a resume that outlives the run that parked it.
560+
**Removal path:** Upstream PR opening a message run for a correlated resume
561+
when none is open.
562+
**Re-carry:** new at this patch; sits between the `tryCorrelate` dispatch
563+
switch and the `message.correlated` emit, inside
564+
#reactor-ts-atomic-approval-acceptance's claim. Re-verify after upstream
565+
changes to correlation dispatch or the run-bracket helpers.
566+
534567
## reactor-ts-skip-unchanged-history
535568

536569
`reactor.ts` — Skip re-serializing unchanged history on checkpoint.
@@ -762,6 +795,7 @@ revisit point is the next vendored sync (see `docs/VENDORING.md`).
762795
| 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 |
763796
| 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 |
764797
| 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 |
798+
| reactor-ts-resume-opens-message-run | Open a message run when a correlated approval resumes a parked call and no run is open | Alexander Guy <alexander.guy@pm.me> | This ledger (#reactor-ts-resume-opens-message-run) | Next vendored sync |
765799
| 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 |
766800
| 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 |
767801
| 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 |

‎vendor/intx-inference/src/reactor.test.ts‎

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6797,6 +6797,199 @@ describe("createReactor — message.run bracket emission", () => {
67976797
});
67986798
});
67996799

6800+
describe("createReactor — message.run bracket for a resumed parked call", () => {
6801+
function askExtension() {
6802+
return createAuthzExtension({
6803+
authorize: async () => ({
6804+
effect: "ask" as const,
6805+
matchingGrants: [],
6806+
resolvedBy: null,
6807+
}),
6808+
approvalTimeoutMs: 60_000,
6809+
});
6810+
}
6811+
6812+
// What a restarted reactor loads after the original run parked on the ask
6813+
// gate: the call is in history, its pending operation is on disk, and no
6814+
// run from the parking process survives.
6815+
function parkedStore(correlationId: string): PersistedCell {
6816+
return {
6817+
turns: [suspendToolCallTurn],
6818+
pendingOperations: [
6819+
{
6820+
correlationId,
6821+
kind: "approval",
6822+
registeredAt: Date.now(),
6823+
gateId: `pending-${correlationId}`,
6824+
timeoutAt: Date.now() + 60_000,
6825+
suspendedCall: { id: "call-ask", name: "charge_card", arguments: {} },
6826+
},
6827+
],
6828+
tokenUsage: emptyUsage(),
6829+
};
6830+
}
6831+
6832+
function resumeDirector(): ReactorDirector {
6833+
return directorFromTable(
6834+
{
6835+
"resume.execute_tools": (e, _s, caps) =>
6836+
caps.executeTools(e.calls, false, true),
6837+
"resume.tool_result": (_e, _s, caps) => caps.infer(),
6838+
"tool.done": (_e, _s, caps) => caps.infer(),
6839+
"inference.done": (_e, _s, caps) => caps.reply("resumed"),
6840+
},
6841+
"wait",
6842+
);
6843+
}
6844+
6845+
function runEvents(events: ReactorEmittedEvent[]) {
6846+
const started = events.filter((e) => e.type === "message.run.started");
6847+
const ended = events.filter((e) => e.type === "message.run.ended");
6848+
return { started, ended };
6849+
}
6850+
6851+
test("an approval for a rehydrated call opens a run around the re-dispatch and its continuation", async () => {
6852+
const correlationId = "rehydrated-run-approved";
6853+
const { reactor, events, waitFor } = createTestReactor({
6854+
contextStore: makePersistingContextStore(parkedStore(correlationId)),
6855+
director: resumeDirector(),
6856+
inferenceRunner: makeInferenceRunner({
6857+
type: "done",
6858+
turn: makeAssistantTurn("resumed"),
6859+
usage: emptyUsage(),
6860+
}),
6861+
toolRunner: makeToolRunner(async (call) => ({
6862+
callId: call.id,
6863+
content: "charged",
6864+
})),
6865+
beforeToolExtensions: [askExtension()],
6866+
});
6867+
reactor.start();
6868+
await waitFor("reactor.start");
6869+
6870+
const approval = makeApprovalMessage(correlationId);
6871+
reactor.deliver(approval);
6872+
await waitFor("connector.reply");
6873+
6874+
const { started, ended } = runEvents(events);
6875+
expect(started).toHaveLength(1);
6876+
expect(ended).toHaveLength(1);
6877+
const run = getEvent(events, "message.run.started");
6878+
const end = getEvent(events, "message.run.ended");
6879+
expect(run.data.messageId).toBe(approval.headers.messageId);
6880+
expect(end.data.messageRunId).toBe(run.data.messageRunId);
6881+
expect(end.data.status).toBe("completed");
6882+
6883+
const types = events.map((e) => e.type);
6884+
expect(types.indexOf("message.run.started")).toBeLessThan(
6885+
types.indexOf("tool.start"),
6886+
);
6887+
expect(types.indexOf("message.run.ended")).toBeGreaterThan(
6888+
types.indexOf("connector.reply"),
6889+
);
6890+
6891+
reactor.abort("admin_kill");
6892+
await waitFor("reactor.done");
6893+
});
6894+
6895+
test("a rejection for a rehydrated call opens a run around its continuation", async () => {
6896+
const correlationId = "rehydrated-run-rejected";
6897+
const { reactor, events, waitFor } = createTestReactor({
6898+
contextStore: makePersistingContextStore(parkedStore(correlationId)),
6899+
director: resumeDirector(),
6900+
inferenceRunner: makeInferenceRunner({
6901+
type: "done",
6902+
turn: makeAssistantTurn("resumed"),
6903+
usage: emptyUsage(),
6904+
}),
6905+
beforeToolExtensions: [askExtension()],
6906+
});
6907+
reactor.start();
6908+
await waitFor("reactor.start");
6909+
6910+
reactor.deliver(makeApprovalMessage(correlationId, "rejected"));
6911+
await waitFor("connector.reply");
6912+
6913+
const { started, ended } = runEvents(events);
6914+
expect(started).toHaveLength(1);
6915+
expect(ended).toHaveLength(1);
6916+
const types = events.map((e) => e.type);
6917+
expect(types.indexOf("message.run.started")).toBeLessThan(
6918+
types.indexOf("inference.done"),
6919+
);
6920+
expect(types.indexOf("message.run.ended")).toBeGreaterThan(
6921+
types.indexOf("connector.reply"),
6922+
);
6923+
6924+
reactor.abort("admin_kill");
6925+
await waitFor("reactor.done");
6926+
});
6927+
6928+
test("an approval for a call parked by the open run opens no second run", async () => {
6929+
let inferences = 0;
6930+
const inferenceRunner = async function* (
6931+
opts: InferenceHarnessOptions,
6932+
): AsyncGenerator<InferenceEvent> {
6933+
inferences += 1;
6934+
yield {
6935+
type: "inference.done",
6936+
seq: opts.nextSeq(),
6937+
data: {
6938+
turn: inferences === 1 ? suspendToolCallTurn : makeAssistantTurn("ok"),
6939+
usage: emptyUsage(),
6940+
source: TEST_SOURCE,
6941+
},
6942+
};
6943+
};
6944+
const { reactor, events, waitFor } = createTestReactor({
6945+
director: directorFromTable(
6946+
{
6947+
"message.received": (_e, _s, caps) => caps.infer(),
6948+
"inference.done": (_e, _s, caps) =>
6949+
inferences === 1
6950+
? caps.executeTools([
6951+
{ id: "call-ask", name: "charge_card", arguments: {} },
6952+
])
6953+
: caps.reply("done"),
6954+
"resume.execute_tools": (e, _s, caps) =>
6955+
caps.executeTools(e.calls, false, true),
6956+
"tool.done": (_e, _s, caps) => caps.infer(),
6957+
},
6958+
"wait",
6959+
),
6960+
inferenceRunner,
6961+
toolRunner: makeToolRunner(async (call) => ({
6962+
callId: call.id,
6963+
content: "charged",
6964+
})),
6965+
beforeToolExtensions: [askExtension()],
6966+
});
6967+
reactor.start();
6968+
const inbound = makeInboundMessage();
6969+
reactor.deliver(inbound);
6970+
const blocked = await waitFor("reactor.gate.blocked");
6971+
if (blocked.type !== "reactor.gate.blocked") throw new Error("unreachable");
6972+
const correlationId = blocked.data.correlationId;
6973+
if (correlationId === undefined) throw new Error("expected correlationId");
6974+
6975+
reactor.deliver(makeApprovalMessage(correlationId));
6976+
await waitFor("message.run.ended");
6977+
6978+
const { started, ended } = runEvents(events);
6979+
expect(started).toHaveLength(1);
6980+
expect(ended).toHaveLength(1);
6981+
const run = getEvent(events, "message.run.started");
6982+
expect(run.data.messageId).toBe(inbound.headers.messageId);
6983+
const types = events.map((e) => e.type);
6984+
expect(types.indexOf("message.run.ended")).toBeGreaterThan(
6985+
types.indexOf("connector.reply"),
6986+
);
6987+
6988+
reactor.abort("admin_kill");
6989+
await waitFor("reactor.done");
6990+
});
6991+
});
6992+
68006993
describe("createReactor — source failover", () => {
68016994
// Simulates a priority-ordered source list the reactor fails over through.
68026995
// `resultFor(id, attemptOnThisSource)` decides what the inference runner

‎vendor/intx-inference/src/reactor.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -845,6 +845,14 @@ export function createReactor(config: ReactorConfig): Reactor {
845845
break;
846846
}
847847
}
848+
// Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-resume-opens-message-run
849+
// A parked call normally resumes inside the run that parked it. One
850+
// rehydrated from the store after a restart has no run open, so the
851+
// approval opens one for the re-dispatch and its continuation; the
852+
// usual reply/wait/done terminal or failure closes it.
853+
if (dispatch.mode !== "gate-cleared" && currentMessageRunId === null) {
854+
openMessageRun(message.headers.messageId);
855+
}
848856
// Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-atomic-approval-acceptance
849857
// Observers may reenter: publish only after consumption and continuation
850858
// enqueue, while the claim still excludes a duplicate delivery.

0 commit comments

Comments
 (0)