Skip to content

Commit a3af29f

Browse files
claude[bot]claudematt-aitkenericallam
authored
fix(sdk): re-dispatch a single in-flight user on recovery boot (#4768)
<!-- ccr-slack-attribution --> _Requested by **Matt Aitken** · [Slack thread](https://triggerdotdev.slack.com/archives/C061L2MHW93/p1787615162456839?thread_ts=1787615162.456839&cid=C061L2MHW93)_ **Before:** a `chat.agent` run is killed mid-answer (OOM, crash, eviction) while the message it was answering is the only one still outstanding. The new run boots, puts that message and the half-written reply into its context, and then waits for a message that already arrived. Nobody ever answers the user; the run sits idle until it times out. **After:** the new run re-runs that message as a fresh turn and replies to it. The half-written reply is dropped. When two or more messages are outstanding, nothing changes — the interrupted one still goes into context and the newer ones are re-run, exactly as before. ## ✅ Checklist - [x] I have followed every step in the [contributing guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md) - [x] The PR title follows the convention. - [x] I ran and tested the code works --- ## Testing New regression test in `packages/trigger-sdk/test/recovery-boot.test.ts` — seeds a partial assistant plus exactly one in-flight user, no `onRecoveryBoot`, and asserts one turn fires for that user with the orphan partial dropped from the chain. It fails on `main` (`turnCount` 0, no turn at all) and passes with this change. - `pnpm exec vitest run` in `packages/trigger-sdk` — 373 passed, 1 skipped (31 files passed, 1 skipped) - `pnpm exec oxfmt --check` on the changed files — clean - `pnpm exec oxlint packages/trigger-sdk/src packages/trigger-sdk/test` — clean - `pnpm run build --filter @trigger.dev/sdk` — clean **What it does:** with exactly one in-flight user on a recovery boot, re-dispatch that user as a fresh turn instead of splicing it into the seed chain, where it was never answered. **How:** the recovery-boot smart default made one decision in two halves — the seed chain and the recovered-turn list — both gated on `partialAssistant !== undefined && inFlightUsers.length > 0`. The splice consumes `inFlightUsers[0]` into the chain as "the question the partial was answering" and dispatches the rest. That only works when there *is* a rest: at n=1 `recoveredTurns` came out empty, the boot-injected queue stayed empty, the `session.in` cursor was advanced past the message anyway, and on a `preload` or continuation boot (no `message` on the wire payload) neither dispatch site fired. Both branches now require `length > 1`, so n=1 falls through to the documented default — chain = `settledMessages`, re-dispatch every in-flight user. The submit-message boot is unaffected: the existing dedup still drops a queued message identical to the one already on the wire payload. Also corrected alongside it: the two SDK docstrings and the `docs/ai-chat/patterns/recovery-boot.mdx` defaults section, which described the default as "re-dispatch every user" and never mentioned the splice. Follow-up (not in this PR): the webapp e2e OOM helper never streams a token before throwing, so it exercises the no-partial path only and would not have caught this. Worth a variant that emits a token first. --- ## Changelog Fixed a chat agent hanging after an interrupted turn: when a run was killed mid-answer and only the one message it was answering was still outstanding, the new run never replied to it. That message is now re-answered on the new run. Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Matt Aitken <matt@mattaitken.com> Co-authored-by: Eric Allam <eric@trigger.dev>
1 parent 15dd973 commit a3af29f

4 files changed

Lines changed: 93 additions & 12 deletions

File tree

.changeset/brave-otters-recover.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
Fixed a chat agent hanging after an interrupted turn: when a run was killed mid-answer (out of memory, crash, or eviction) and only the one message it was answering was still outstanding, the new run never replied to it. That message is now re-answered on the new run.

docs/ai-chat/patterns/recovery-boot.mdx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ On a continuation boot, the runtime reads:
3232
- **`session.out` tail past the snapshot cursor** — closed assistant turns plus, optionally, a `partialAssistant` (the trailing message whose stream never received a `finish` chunk). `cleanupAbortedParts` has already stripped streaming-in-progress fragments.
3333
- **`session.in` tail past the last `turn-complete` cursor** — user messages the dead run hadn't acknowledged.
3434

35-
If both `partialAssistant` and `inFlightUsers` are non-empty, the runtime splices `[firstInFlightUser, partialAssistant]` onto the chain. The remaining in-flight users dispatch as fresh turns. The model sees:
35+
If there's a `partialAssistant` and two or more `inFlightUsers`, the runtime splices `[firstInFlightUser, partialAssistant]` onto the chain. The remaining in-flight users dispatch as fresh turns. The model sees:
3636

3737
```
3838
[ ...settledMessages, // chain through the last completed turn
@@ -138,10 +138,17 @@ type RecoveryBootResult<TUIM extends UIMessage = UIMessage> = {
138138
};
139139
```
140140

141-
- **`chain`** — replaces the seed chain. Defaults to `[...settledMessages, firstInFlightUser, partialAssistant]` when both partial and in-flight users exist, otherwise `settledMessages` alone.
141+
- **`chain`** — replaces the seed chain. Defaults to `[...settledMessages, firstInFlightUser, partialAssistant]` when there's a partial **and two or more** in-flight users, otherwise `settledMessages` alone.
142142
- **`recoveredTurns`** — user messages to dispatch as fresh turns after the chain is restored. Defaults to `inFlightUsers.slice(1)` when the smart default consumed the first user, otherwise `inFlightUsers`.
143143
- **`beforeBoot`** — runs after the writer flushes and before the first recovered turn fires. Use for blocking persistence (write the partial to your DB so a later turn can reference it). Errors bubble — wrap your own try/catch if you want to soft-fail.
144144

145+
<Note>
146+
The splice needs a follow-up user to answer, so it only applies with two or
147+
more in-flight users. With exactly one — the plain OOM or crash-mid-answer
148+
case — the orphan partial is dropped and that single user is re-dispatched as
149+
a fresh turn, so the interrupted question still gets answered.
150+
</Note>
151+
145152
## Examples
146153

147154
### Drop the partial — strict "cancel means discard"

packages/trigger-sdk/src/v3/ai.ts

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4554,8 +4554,11 @@ export type RecoveryBootEvent<TUIM extends UIMessage = UIMessage> = {
45544554
/**
45554555
* User messages that arrived on `session.in` past the cursor — i.e.
45564556
* the message(s) the predecessor was processing or had queued when
4557-
* it died. The runtime's default is to re-dispatch each as a fresh
4558-
* turn after the chain is restored. Return a different list via
4557+
* it died. The runtime's default re-dispatches each as a fresh turn
4558+
* after the chain is restored, except when a `partialAssistant` is
4559+
* present AND there are two or more of them: the first is then
4560+
* spliced into the chain (as the question the partial was answering)
4561+
* rather than dispatched. Return a different list via
45594562
* `recoveredTurns` to skip / reorder / collapse them.
45604563
*/
45614564
inFlightUsers: TUIM[];
@@ -4600,8 +4603,12 @@ export type RecoveryBootResult<TUIM extends UIMessage = UIMessage> = {
46004603
chain?: TUIM[];
46014604
/**
46024605
* The user messages to re-dispatch as fresh turns after the chain is
4603-
* restored. Default: `inFlightUsers` (re-process every in-flight
4604-
* user). Return `[]` to suppress all of them; return a filtered /
4606+
* restored. Default: `inFlightUsers.slice(1)` when a
4607+
* `partialAssistant` is present and there are two or more in-flight
4608+
* users (the first one is spliced into the chain instead), otherwise
4609+
* `inFlightUsers` — including the single-user case, where the
4610+
* interrupted user is re-dispatched and the orphan partial is
4611+
* dropped. Return `[]` to suppress all of them; return a filtered /
46054612
* reordered subset to skip specific ones.
46064613
*/
46074614
recoveredTurns?: TUIM[];
@@ -5168,8 +5175,13 @@ export type ChatAgentOptions<
51685175
* customer's DB.
51695176
*
51705177
* Defaults (returned when the hook is omitted or returns no field):
5171-
* - `chain` = `settledMessages` (drop the orphan partial)
5172-
* - `recoveredTurns` = `inFlightUsers` (re-dispatch every user)
5178+
* - With two or more in-flight users, the partial and the user it
5179+
* was answering are spliced into the chain:
5180+
* `chain` = `[...settledMessages, inFlightUsers[0], partialAssistant]`
5181+
* and `recoveredTurns` = `inFlightUsers.slice(1)`.
5182+
* - Otherwise `chain` = `settledMessages` (drop the orphan partial)
5183+
* and `recoveredTurns` = `inFlightUsers` (re-dispatch every user)
5184+
* — so a single interrupted user is answered on the new run.
51735185
*
51745186
* @example
51755187
* ```ts
@@ -6111,20 +6123,29 @@ function chatAgent<
61116123
}
61126124
}
61136125

6114-
// Default: splice partial + the user it was answering into
6115-
// the chain so follow-ups like "keep going" still have context.
6126+
// Default: splice partial + the user it was answering into the chain
6127+
// so follow-ups like "keep going" still have context, and re-dispatch
6128+
// the users that arrived after it.
6129+
//
6130+
// The splice needs a follow-up user to answer — it consumes
6131+
// `inFlightUsers[0]` into the chain instead of dispatching it. With
6132+
// exactly ONE in-flight user (the plain OOM / crash-mid-answer case)
6133+
// there is nothing left to dispatch, so splicing would strand that
6134+
// user unanswered and idle the run. Require `length > 1` on both
6135+
// branches: at n=1 the orphan partial is dropped and the interrupted
6136+
// user is re-dispatched as a fresh turn instead.
61166137
let seedChain: TUIMessage[];
61176138
let recoveredTurns: TUIMessage[];
61186139
if (hookChain !== undefined) {
61196140
seedChain = hookChain;
6120-
} else if (partialAssistant !== undefined && inFlightUsers.length > 0) {
6141+
} else if (partialAssistant !== undefined && inFlightUsers.length > 1) {
61216142
seedChain = [...settledMessages, inFlightUsers[0]!, partialAssistant];
61226143
} else {
61236144
seedChain = settledMessages;
61246145
}
61256146
if (hookRecoveredTurns !== undefined) {
61266147
recoveredTurns = hookRecoveredTurns;
6127-
} else if (partialAssistant !== undefined && inFlightUsers.length > 0) {
6148+
} else if (partialAssistant !== undefined && inFlightUsers.length > 1) {
61286149
recoveredTurns = inFlightUsers.slice(1);
61296150
} else {
61306151
recoveredTurns = inFlightUsers;

packages/trigger-sdk/test/recovery-boot.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,54 @@ describe("onRecoveryBoot — chat.agent recovery hook", () => {
295295
}
296296
});
297297

298+
it("smart default: a single in-flight user is re-dispatched, not swallowed by the splice", async () => {
299+
// The plain OOM / crash-mid-answer shape: the run died while answering
300+
// the only outstanding user message. Splicing that user into the chain
301+
// would leave nothing to dispatch, so the run would boot and idle with
302+
// the message unanswered. The default must re-dispatch it instead (and
303+
// drop the orphan partial).
304+
let observedChain: Array<{ role: string; idHead: string }> = [];
305+
let turnCount = 0;
306+
const model = new MockLanguageModelV3({
307+
doStream: async () => {
308+
turnCount++;
309+
return { stream: textStream("ok") };
310+
},
311+
});
312+
const partial = assistantMessage("partial answer in progress", "a-partial");
313+
const u1 = userMessage("the question that OOM'd", "u-1");
314+
const agent = chat.agent({
315+
id: "recovery-boot.single-inflight-user",
316+
// NO onRecoveryBoot — exercise the default path
317+
onTurnStart: async ({ uiMessages }) => {
318+
if (turnCount === 0) {
319+
observedChain = uiMessages.map((m) => ({
320+
role: m.role,
321+
idHead: m.id.slice(0, 10),
322+
}));
323+
}
324+
},
325+
run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }),
326+
});
327+
const harness = mockChatAgent(agent, {
328+
chatId: "single-inflight-user",
329+
continuation: true,
330+
previousRunId: "run_prior",
331+
});
332+
harness.seedSessionOutPartial(partial as never);
333+
harness.seedSessionInTail([u1 as never]);
334+
try {
335+
await new Promise((r) => setTimeout(r, 100));
336+
// One turn fires, for the interrupted user.
337+
expect(turnCount).toBe(1);
338+
// The orphan partial is dropped — the chain is just the re-dispatched user.
339+
expect(observedChain.map((m) => m.role)).toEqual(["user"]);
340+
expect(observedChain[0]!.idHead).toBe("u-1");
341+
} finally {
342+
await harness.close();
343+
}
344+
});
345+
298346
it("hook's recoveredTurns: [] suppresses re-dispatch of in-flight users", async () => {
299347
let turnCount = 0;
300348
const model = new MockLanguageModelV3({

0 commit comments

Comments
 (0)