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>
C
claude[bot] committed
a3af29fd8000e284f2f03c519071619b94b91044
Parent: 15dd973
Committed by GitHub <noreply@github.com>
on 8/27/2026, 4:34:54 PM