Skip to content

Commit ce3ba86

Browse files
bwachmanTheGreatAxios
authored andcommitted
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 0bffe4f commit ce3ba86

3 files changed

Lines changed: 234 additions & 0 deletions

File tree

‎vendor/intx-inference/PATCHES.md‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,38 @@ batch committed); the turns still reach disk segments, so only the cycle's
498498
manifest records and metadata staging stay uncommitted. Intentionally not
499499
committed; re-verify after upstream doom-loop accounting changes.
500500

501+
## reactor-ts-resume-opens-message-run
502+
503+
`reactor.ts` — A correlated approval that resumes a parked call opens a
504+
message run when none is open. `tryCorrelate`, after the `redispatch` or
505+
`error_result` dispatch has consumed the pending operation and enqueued the
506+
continuation (and before `message.correlated`), calls `openMessageRun` keyed
507+
on the approval message's `messageId`. The re-dispatched call and its
508+
continuation then run inside a normal `message.run.started` /
509+
`message.run.ended` bracket, closed by the usual reply/wait/done terminal or
510+
a failure. When a run is already open (the live case, where the run that
511+
parked the call is still open) nothing changes: the resume continues that
512+
run and no second bracket opens. The `gate-cleared` mode (an async-marker
513+
operation with no suspended call) is untouched.
514+
515+
Upstream opens a run only when the loop dequeues `message.received`, and a
516+
correlated approval never becomes one. A pending operation rehydrated by a
517+
new reactor (restart, `corbits resume`, an agent rebuild) has no run from
518+
the parking process, so its approval drove tool execution and inference
519+
with no run open. Consumers that treat "no open run" as idle (the TUI defers
520+
agent reloads to `message.run.ended`) then saw the session as idle mid-call
521+
and could rebuild the agent under the running continuation. The
522+
`message.run.started` doc comment in `@intx/types` still describes the
523+
dequeue-only trigger; it is left verbatim.
524+
525+
**Disposition:** Promotion candidate. Upstream's run bracket has no owner
526+
for a resume that outlives the run that parked it.
527+
**Removal path:** Upstream PR opening a message run for a correlated resume
528+
when none is open.
529+
**Re-carry:** new at this patch; sits between the `tryCorrelate` dispatch
530+
switch and the `message.correlated` emit, inside
531+
#reactor-ts-atomic-approval-acceptance's claim. Re-verify after upstream
532+
changes to correlation dispatch or the run-bracket helpers.
501533
## reactor-ts-skip-unchanged-history
502534

503535
`reactor.ts` — Skip re-serializing unchanged history on checkpoint.
@@ -728,6 +760,7 @@ revisit point is the next vendored sync (see `docs/VENDORING.md`).
728760
| 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 |
729761
| 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 |
730762
| 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 |
763+
| 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 |
731764
| 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 |
732765
| 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 |
733766
| 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
@@ -6678,6 +6678,199 @@ describe("createReactor — message.run bracket emission", () => {
66786678
});
66796679
});
66806680

6681+
describe("createReactor — message.run bracket for a resumed parked call", () => {
6682+
function askExtension() {
6683+
return createAuthzExtension({
6684+
authorize: async () => ({
6685+
effect: "ask" as const,
6686+
matchingGrants: [],
6687+
resolvedBy: null,
6688+
}),
6689+
approvalTimeoutMs: 60_000,
6690+
});
6691+
}
6692+
6693+
// What a restarted reactor loads after the original run parked on the ask
6694+
// gate: the call is in history, its pending operation is on disk, and no
6695+
// run from the parking process survives.
6696+
function parkedStore(correlationId: string): PersistedCell {
6697+
return {
6698+
turns: [suspendToolCallTurn],
6699+
pendingOperations: [
6700+
{
6701+
correlationId,
6702+
kind: "approval",
6703+
registeredAt: Date.now(),
6704+
gateId: `pending-${correlationId}`,
6705+
timeoutAt: Date.now() + 60_000,
6706+
suspendedCall: { id: "call-ask", name: "charge_card", arguments: {} },
6707+
},
6708+
],
6709+
tokenUsage: emptyUsage(),
6710+
};
6711+
}
6712+
6713+
function resumeDirector(): ReactorDirector {
6714+
return directorFromTable(
6715+
{
6716+
"resume.execute_tools": (e, _s, caps) =>
6717+
caps.executeTools(e.calls, false, true),
6718+
"resume.tool_result": (_e, _s, caps) => caps.infer(),
6719+
"tool.done": (_e, _s, caps) => caps.infer(),
6720+
"inference.done": (_e, _s, caps) => caps.reply("resumed"),
6721+
},
6722+
"wait",
6723+
);
6724+
}
6725+
6726+
function runEvents(events: ReactorEmittedEvent[]) {
6727+
const started = events.filter((e) => e.type === "message.run.started");
6728+
const ended = events.filter((e) => e.type === "message.run.ended");
6729+
return { started, ended };
6730+
}
6731+
6732+
test("an approval for a rehydrated call opens a run around the re-dispatch and its continuation", async () => {
6733+
const correlationId = "rehydrated-run-approved";
6734+
const { reactor, events, waitFor } = createTestReactor({
6735+
contextStore: makePersistingContextStore(parkedStore(correlationId)),
6736+
director: resumeDirector(),
6737+
inferenceRunner: makeInferenceRunner({
6738+
type: "done",
6739+
turn: makeAssistantTurn("resumed"),
6740+
usage: emptyUsage(),
6741+
}),
6742+
toolRunner: makeToolRunner(async (call) => ({
6743+
callId: call.id,
6744+
content: "charged",
6745+
})),
6746+
beforeToolExtensions: [askExtension()],
6747+
});
6748+
reactor.start();
6749+
await waitFor("reactor.start");
6750+
6751+
const approval = makeApprovalMessage(correlationId);
6752+
reactor.deliver(approval);
6753+
await waitFor("connector.reply");
6754+
6755+
const { started, ended } = runEvents(events);
6756+
expect(started).toHaveLength(1);
6757+
expect(ended).toHaveLength(1);
6758+
const run = getEvent(events, "message.run.started");
6759+
const end = getEvent(events, "message.run.ended");
6760+
expect(run.data.messageId).toBe(approval.headers.messageId);
6761+
expect(end.data.messageRunId).toBe(run.data.messageRunId);
6762+
expect(end.data.status).toBe("completed");
6763+
6764+
const types = events.map((e) => e.type);
6765+
expect(types.indexOf("message.run.started")).toBeLessThan(
6766+
types.indexOf("tool.start"),
6767+
);
6768+
expect(types.indexOf("message.run.ended")).toBeGreaterThan(
6769+
types.indexOf("connector.reply"),
6770+
);
6771+
6772+
reactor.abort("admin_kill");
6773+
await waitFor("reactor.done");
6774+
});
6775+
6776+
test("a rejection for a rehydrated call opens a run around its continuation", async () => {
6777+
const correlationId = "rehydrated-run-rejected";
6778+
const { reactor, events, waitFor } = createTestReactor({
6779+
contextStore: makePersistingContextStore(parkedStore(correlationId)),
6780+
director: resumeDirector(),
6781+
inferenceRunner: makeInferenceRunner({
6782+
type: "done",
6783+
turn: makeAssistantTurn("resumed"),
6784+
usage: emptyUsage(),
6785+
}),
6786+
beforeToolExtensions: [askExtension()],
6787+
});
6788+
reactor.start();
6789+
await waitFor("reactor.start");
6790+
6791+
reactor.deliver(makeApprovalMessage(correlationId, "rejected"));
6792+
await waitFor("connector.reply");
6793+
6794+
const { started, ended } = runEvents(events);
6795+
expect(started).toHaveLength(1);
6796+
expect(ended).toHaveLength(1);
6797+
const types = events.map((e) => e.type);
6798+
expect(types.indexOf("message.run.started")).toBeLessThan(
6799+
types.indexOf("inference.done"),
6800+
);
6801+
expect(types.indexOf("message.run.ended")).toBeGreaterThan(
6802+
types.indexOf("connector.reply"),
6803+
);
6804+
6805+
reactor.abort("admin_kill");
6806+
await waitFor("reactor.done");
6807+
});
6808+
6809+
test("an approval for a call parked by the open run opens no second run", async () => {
6810+
let inferences = 0;
6811+
const inferenceRunner = async function* (
6812+
opts: InferenceHarnessOptions,
6813+
): AsyncGenerator<InferenceEvent> {
6814+
inferences += 1;
6815+
yield {
6816+
type: "inference.done",
6817+
seq: opts.nextSeq(),
6818+
data: {
6819+
turn: inferences === 1 ? suspendToolCallTurn : makeAssistantTurn("ok"),
6820+
usage: emptyUsage(),
6821+
source: TEST_SOURCE,
6822+
},
6823+
};
6824+
};
6825+
const { reactor, events, waitFor } = createTestReactor({
6826+
director: directorFromTable(
6827+
{
6828+
"message.received": (_e, _s, caps) => caps.infer(),
6829+
"inference.done": (_e, _s, caps) =>
6830+
inferences === 1
6831+
? caps.executeTools([
6832+
{ id: "call-ask", name: "charge_card", arguments: {} },
6833+
])
6834+
: caps.reply("done"),
6835+
"resume.execute_tools": (e, _s, caps) =>
6836+
caps.executeTools(e.calls, false, true),
6837+
"tool.done": (_e, _s, caps) => caps.infer(),
6838+
},
6839+
"wait",
6840+
),
6841+
inferenceRunner,
6842+
toolRunner: makeToolRunner(async (call) => ({
6843+
callId: call.id,
6844+
content: "charged",
6845+
})),
6846+
beforeToolExtensions: [askExtension()],
6847+
});
6848+
reactor.start();
6849+
const inbound = makeInboundMessage();
6850+
reactor.deliver(inbound);
6851+
const blocked = await waitFor("reactor.gate.blocked");
6852+
if (blocked.type !== "reactor.gate.blocked") throw new Error("unreachable");
6853+
const correlationId = blocked.data.correlationId;
6854+
if (correlationId === undefined) throw new Error("expected correlationId");
6855+
6856+
reactor.deliver(makeApprovalMessage(correlationId));
6857+
await waitFor("message.run.ended");
6858+
6859+
const { started, ended } = runEvents(events);
6860+
expect(started).toHaveLength(1);
6861+
expect(ended).toHaveLength(1);
6862+
const run = getEvent(events, "message.run.started");
6863+
expect(run.data.messageId).toBe(inbound.headers.messageId);
6864+
const types = events.map((e) => e.type);
6865+
expect(types.indexOf("message.run.ended")).toBeGreaterThan(
6866+
types.indexOf("connector.reply"),
6867+
);
6868+
6869+
reactor.abort("admin_kill");
6870+
await waitFor("reactor.done");
6871+
});
6872+
});
6873+
66816874
describe("createReactor — source failover", () => {
66826875
// Simulates a priority-ordered source list the reactor fails over through.
66836876
// `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
@@ -834,6 +834,14 @@ export function createReactor(config: ReactorConfig): Reactor {
834834
break;
835835
}
836836
}
837+
// Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-resume-opens-message-run
838+
// A parked call normally resumes inside the run that parked it. One
839+
// rehydrated from the store after a restart has no run open, so the
840+
// approval opens one for the re-dispatch and its continuation; the
841+
// usual reply/wait/done terminal or failure closes it.
842+
if (dispatch.mode !== "gate-cleared" && currentMessageRunId === null) {
843+
openMessageRun(message.headers.messageId);
844+
}
837845
// Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-atomic-approval-acceptance
838846
// Observers may reenter: publish only after consumption and continuation
839847
// enqueue, while the claim still excludes a duplicate delivery.

0 commit comments

Comments
 (0)