fix(think): route a stream-stall watchdog abort into bounded recovery (#1626)#1643
Conversation
…#1626) When `chatStreamStallTimeoutMs` is set and the inactivity watchdog fires on a hung model/transport stream, the turn was failed terminally. A stall is just another interruption — like a deploy or eviction — so it is now routed into the SAME bounded chat-recovery path: the settled partial is preserved, a continuation is scheduled (`_chatRecoveryContinue`), and the turn resumes, reusing the existing incident budget (maxAttempts + wall-clock no-progress window + progress-aware reset). A transient hang (the common cause under deploy churn) recovers automatically; a persistently hanging provider still terminalizes once the budget is exhausted (the watchdog's original "kill the infinite spinner" guarantee, after bounded retries). Automatic whenever the watchdog is enabled and chatRecovery is on (the Think default) — no new config; with chatRecovery off, a stall stays terminal as before. Idempotency matches deploy recovery (settled tool results durable; a mid-execution tool re-runs on the continuation). Implementation: - `_iterateWithStallWatchdog` now throws a distinct `ChatStreamStalledError` so the read-loop catch can tell a stall from an in-band model error (only the stall routes to recovery). - New `_routeStallToBoundedRecovery` opens/advances a recovery incident and schedules the continuation (or returns false when the budget is exhausted, so the caller falls through to the existing terminal path). Gated on chatRecovery being enabled. - Both stream paths (`_streamResult`, `_streamResultToRpcCallback`) branch their catch: persist the settled partial (re-anchor), route to recovery, and suppress the terminal error when a continuation is scheduled. Tests: - Unit: a stall on the first inference recovers via the scheduled continuation (no terminal error); the existing watchdog→terminal test now runs with recovery off (the no-recovery gate). - E2E (real wrangler dev, no process kill — a stall is an in-isolate hang): the watchdog fires, the real alarm fires the continuation, and the turn completes with the continuation's output and no orphaned fiber rows. Think-only (the watchdog is Think-only; AIChatAgent delegates the read loop). Co-authored-by: Cursor <[email protected]>
🦋 Changeset detectedLatest commit: 5b430fe The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
/bonk do a deep review on this, look for edge cases, is the ux/dx good and tuneable |
agents
@cloudflare/ai-chat
@cloudflare/codemode
hono-agents
@cloudflare/shell
@cloudflare/think
@cloudflare/voice
@cloudflare/worker-bundler
commit: |
There was a problem hiding this comment.
Deep Review: Stall Watchdog → Bounded Recovery (#1626)
I did a thorough read of all 8 changed files. The design is sound — treating a stall as "just another interruption" and routing it through the existing recovery machinery is the right call. The implementation correctly reuses _beginChatRecoveryIncident, the existing budget (maxAttempts + wall-clock + progress-aware reset), and the existing _chatRecoveryContinue continuation path. That said, I found one concrete bug and several edge cases worth discussing.
Bug: Orphaned JSDoc block
The new _routeStallToBoundedRecovery method was inserted between the JSDoc comment for _rescheduleRecoveryAfterStableTimeout and the method definition itself (think.ts:7745-7821). This means:
_rescheduleRecoveryAfterStableTimeout(line 7822) now has no docstring — the docstring above it is_routeStallToBoundedRecovery's._routeStallToBoundedRecovery(line 7772) has two consecutive JSDoc blocks — the first one (lines 7745-7754) is actually_rescheduleRecoveryAfterStableTimeout's orphaned docstring.
The first JSDoc block is silently ignored (the second one wins), but the orphaned docstring is confusing for anyone reading this code. The fix is to move the new method either above or below the _rescheduleRecoveryAfterStableTimeout docstring+method pair, or re-attach the orphaned docstring to its method.
Edge Cases & Questions
1. Stall during a continuation that is itself a stall recovery
If the provider is persistently hanging and the first stall routes to recovery, the continuation itself will also stall and hit the watchdog again. The code handles this because _routeStallToBoundedRecovery calls _beginChatRecoveryIncident which advances the attempt counter on the same incident (keyed by the same user message). Eventually the budget exhausts and it falls through to terminal. This is correct, but worth verifying: does the continuation also have chatStreamStallTimeoutMs armed? Yes — both _streamResult (line 6671) and _streamResultToRpcCallback (line 6302) read the instance property each time, so the watchdog is always armed for continuations. Good.
2. Stall with zero accumulated parts
In _streamResultToRpcCallback (line 6416): if accumulator.parts.length === 0 (the provider parked before emitting any chunks), the stall-recovery path skips the persist step (assistantMsg remains null) and targetAssistantId is undefined. The continuation will then run with no prior assistant message to re-anchor from, which should be equivalent to a fresh turn (the model sees the user message and starts fresh). This seems correct — just calling it out because it's an implicit behavior rather than an explicit one.
3. Stall fires during tool execution (watchdog gap in docs)
The README correctly documents that chatStreamStallTimeoutMs "measures the gap between UI-message-stream chunks, which includes time spent executing server-side tools." This is the key usability concern: a tool that takes 3 minutes to run will trigger a 120s watchdog. The README's advice ("set it comfortably above your slowest tool") is good, but there's no per-turn override for chatStreamStallTimeoutMs in TurnConfig — if a subclass knows a particular turn will invoke a slow tool, they can't bump the timeout for just that turn. This is a DX gap (not a bug, and probably fine for v1 since the property can be mutated in beforeTurn), but worth noting for future API design.
4. _streamResultToRpcCallback vs _streamResult asymmetry
The two catch blocks that handle ChatStreamStalledError are structurally different:
-
In
_streamResultToRpcCallback(line 6415): the recovery path callsreturn;(void return), does NOT fire the response hook, does NOT callcallback.onDone(). This means the RPC caller (parent agent) gets neitheronDonenoronError— the callback is silently abandoned. The comment says "the scheduled continuation owns the real terminal outcome" which mirrors deploy interruption, but this does mean the parent agent'schat()call will hang until the continuation eventually firesonDone. Is the RPC promise forchat()resolved at this point? If thefor awaitloop completed (via the catch), and the method returns, yes — the RPC resolves. But the parent never learns the turn was interrupted and recovered. This is probably fine for the sub-agent case (the parent just sees a delayed completion), but it's a subtle semantic difference from an abort (whereonErrorfires). -
In
_streamResult(line 6753): the recovery path returns{ status: "aborted" }. This correctly reports the interrupted status to the caller (e.g.,saveMessages/submissions).
5. { idempotent: true } for the stall-recovery schedule
Line 7817: await this.schedule(0, "_chatRecoveryContinue", ..., { idempotent: true }). The comment on _rescheduleRecoveryAfterStableTimeout (line 7843-7846) explicitly says "Must NOT be idempotent" for reschedules. The initial stall-recovery schedule uses idempotent: true which is correct (it's the first schedule, not a reschedule from within an executing alarm row). Just want to confirm: if two stalls fire in rapid succession (e.g., a watchdog fires, recovery begins, the continuation immediately stalls again before the first schedule's alarm fires), the idempotent schedule deduplicates correctly. Yes — the second schedule(0, ...) with idempotent: true would be a no-op if the first hasn't fired yet. This is correct behavior.
6. Progress marker not bumped on stall recovery
When a stall fires mid-stream, chunks were flowing before the hang. Those chunks were stored durably via _storeChunkDurably which calls _bumpChatRecoveryProgress. So progress IS recorded before the stall fires — this is correct. The recovery incident will see currentProgress > prevProgress if the stalled attempt produced any durable content, and will reset the attempt counter. Good.
UX/DX Assessment
What's good:
- Zero-config for the common case. If you've set
chatStreamStallTimeoutMsand leftchatRecoveryat the defaulttrue, stalls just recover. No new flags, no new hooks, no new config objects. - The "fail fast" knob is the existing
maxAttempts/budget. Consistent with deploy recovery. - Observability is there:
chat:stream:stalled,chat:recovery:detected,chat:recovery:scheduled,chat:recovery:attempt,chat:recovery:exhaustedevents all fire at the right points. - Backward compatible: with
chatRecovery: false, the behavior is unchanged (terminal error).
What could be better (future, not blockers):
- A per-turn
chatStreamStallTimeoutMsoverride viaTurnConfigwould let subclasses handle known-slow turns (e.g., tool-heavy turns) without permanently raising the global timeout. - The
_streamResultToRpcCallbackstall path silently abandoning theStreamCallbackis defensible but undocumented. A brief note in the README's stall-recovery section about sub-agent behavior would help.
Tests
- Unit test (
think-session.test.ts:520-535): Good coverage — verifies no terminal error, scheduled continuation fires, and final transcript has content. The_stallAttemptsRemaininggating in the mock model is clean. - Existing watchdog→terminal test now correctly runs with
chatRecovery = false— this is the right way to preserve both test paths. - E2E test (
stall-recovery.test.ts): Tests the real alarm path withwrangler dev. The 60s poll loop with 2.5s intervals is generous. The assertion onhasFiberRows === falseis important (no orphaned fibers). The120_000timeout is appropriate for CI.
Summary
The implementation is correct and well-designed. The one concrete issue is the orphaned JSDoc block at line 7745 which should be fixed before merge. The rest are observations and future DX suggestions, not blockers.
|
The review has been posted to PR #1643. Here's a summary of the findings: Bug found:
Design validation:
Edge cases examined:
DX suggestions (future, not blockers):
|
…t recovery + per-turn timeout override Follow-up to the stall-watchdog→bounded-recovery commit, addressing PR review. Bug: stall-recovery budget exhaustion bypassed the recovery exhaustion path. When `_routeStallToBoundedRecovery` ran out of budget it returned `false` and both stream catches fell through to the GENERIC terminal path. That skipped `_exhaustChatRecovery`, so `onExhausted` never fired, the `chat:recovery:exhausted` event was not emitted, the submission was not marked interrupted, and the configured `terminalMessage` was not shown — the user saw the raw "Chat stream stalled…" error. This diverged from deploy-recovery exhaustion, contradicting the "same bounded path" goal. `_routeStallToBoundedRecovery` now returns `"scheduled" | "exhausted" | "disabled"`. On `"exhausted"` it routes through the SAME `_exhaustChatRecovery` path as deploy recovery (passing the partial + streamId + `firstSeenAt` as the turn-start proxy); both `_streamResult` and `_streamResultToRpcCallback` finalize the stream and return without re-running the generic terminal. Only `"disabled"` (chatRecovery off) falls through to the unchanged terminal path. Cleanup: fixed an orphaned JSDoc block — the method had been inserted between `_rescheduleRecoveryAfterStableTimeout`'s docstring and its definition; moved it next to `_exhaustChatRecovery` so the docstring reattaches. Feature (review #3): added a per-turn `TurnConfig.chatStreamStallTimeoutMs` override (returned from `beforeTurn`). Resolved in `_runInferenceLoop` and stashed on `_activeStallTimeoutMs`, read by both stream loops; an explicit `0` disables the watchdog for that turn; it auto-resets each turn. Lets a turn with a known-slow tool raise/disable the watchdog without permanently widening the instance-level window. Tests: - `testStallRouteExhaustion` (surgical, deterministic): seeds an incident at the budget edge and routes one stall — asserts `onExhausted` fires once with `max_attempts_exceeded`, the incident is sealed `exhausted`, and the client sees the configured `terminalMessage` (not the raw stall error). - per-turn override test: instance watchdog off, per-turn override arms it, a stall still routes into recovery and the continuation completes. Docs: think/index.md (config row + Chat Recovery section), lifecycle-hooks.md (TurnConfig table), observability.md (`chat:stream:stalled` now routes to recovery), README, and the changeset. Note: review #4 (the RPC `StreamCallback` is abandoned — no onDone/onError — on a recovering/exhausting stall) is intentionally left as-is for now; the only strictly-correct fix is a new optional `onInterrupted()` signal, tracked as a follow-up.
…rns (#1644) (#1647) * fix(think): add StreamCallback.onInterrupted so a recovering chat() turn isn't silently abandoned (#1644) When a turn driven through `chat(userMessage, callback)` is interrupted and routed into bounded recovery (a stream-stall watchdog abort, #1626/#1643), the scheduled continuation runs in a LATER isolate invocation that does not hold the original `callback` — so neither `onDone()` nor `onError()` ever fires for it. Because the isolate is still alive, the RPC promise resolves CLEANLY, and the callback contract `onStart → onEvent* → (onDone | onError)` is silently violated. The deploy/stall asymmetry is the subtle part: a deploy/eviction kills the isolate, so the caller sees a transport break and re-attaches; a stall→recovery interruption returns cleanly, so the caller cannot distinguish "finished" from "interrupted, continuing elsewhere." A consumer that keys off the clean resolve mis-reads it as success and finalizes whatever partial it streamed. Real-world impact: messenger delivery (`deliverMessengerReply`) calls `callback.close()` right after `chat()` resolves and posts the streamed text as the final reply — so a recovering stall made it post a TRUNCATED answer, while the real recovered answer was produced later by the continuation and broadcast only to WebSocket connections (never to the messenger surface). ## What changed - **API:** add optional `onInterrupted?()` to `StreamCallback` (think.ts). It means "not done, not a terminal error — a continuation owns the final outcome"; consumers should keep the channel open / show a recovering state / re-attach instead of finalizing. Optional → default no-op, so existing implementers are unaffected (fully backward-compatible). - **Emit:** `_streamResultToRpcCallback` now calls `await callback.onInterrupted?.()` in BOTH stall branches (`scheduled` and `exhausted`) instead of returning silently. (`_streamResult` is the WebSocket-broadcast path and has no `StreamCallback`, so it is unaffected. A deploy/eviction can't fire this — the isolate is already gone.) - **Messenger wiring:** `TextStreamCallback` gains `onInterrupted()` (+ `wasInterrupted()`); `deliverMessengerReply` no longer marks the turn complete or posts the truncated partial on interruption — it surfaces the "interrupted, please retry" reply (`INTERRUPTED_MESSENGER_RESPONSE`) and checkpoints the one-shot delivery completed. ## Tests - `think-session.test.ts`: a stall-recovering `chat()` turn calls `onInterrupted` exactly once (NOT onDone/onError); a normally-completing turn calls onDone and an in-band stream error calls onError, neither firing `onInterrupted`. - `messengers.test.ts`: an interrupted messenger turn surfaces the interrupted apology (not the truncated partial, not the empty-response fallback) and checkpoints completed. - Harness: `TestCollectingCallback` captures `interruptedCalls`; `TestChatResult` carries it; `testChatWithStallThenRecover` surfaces `firstInterruptedCalls`. - README: documents `onInterrupted` on the `StreamCallback` sub-agent-streaming section. Full think suite green (481). Backward-compatible: a `StreamCallback` implementer that does not define `onInterrupted` behaves exactly as before. Co-authored-by: Cursor <[email protected]> * docs(think): make StreamCallback.onInterrupted contract honest for the exhausted case The JSDoc said a "scheduled continuation owns the final outcome", but `onInterrupted` also fires from the budget-EXHAUSTED stall branch, where the turn is terminally over (terminalMessage + onExhausted already delivered out-of-band) and NO continuation will run. A custom consumer taking the docstring literally — e.g. parking to re-attach to the continuation — would block/leak on exhaustion. Broadened the contract to cover both branches (continuation will produce it OR it was already terminalized via exhaustion) and added the explicit warning to not block indefinitely waiting for a continuation. Behavior unchanged (the messenger apology + checkpoint-completed UX is already correct for both cases); this only fixes the documented contract so it can't mislead custom onInterrupted handlers. Co-authored-by: Cursor <[email protected]> --------- Co-authored-by: Cursor <[email protected]>
Summary
Closes #1626. When
chatStreamStallTimeoutMsis set and the inactivity watchdog fires on a hung model/transport stream (a provider that parks without ever throwing), the turn was failed terminally. A stall is really just another interruption — like a deploy or eviction — so it is now routed into the same bounded chat-recovery path: the settled partial is preserved, a continuation is scheduled, and the turn resumes. A transient hang (the common cause under deploy churn) recovers automatically; a persistently hanging provider still terminalizes once the recovery budget is exhausted — the watchdog's original "kill the infinite spinner" guarantee, now after bounded retries.This removes the last framework gap behind a customer's stall-watchdog→terminal hack (g3's
classifyWatchdogTick+ manual stall→terminal path).Design decisions
chatRecoveryis on (the Think default) — no new config. A{ ms, recover }flag would be exactly the new-API knob we avoid; a stall routing through the existing recovery (like every other interruption) is the consistent default. The "fail fast" knob is the existingmaxAttempts/budget. WithchatRecoveryoff, a stall stays terminal as before.maxAttempts+ 15-min wall-clock + no-progress window + progress-aware reset). No new budget. Boundedness is inherited from_beginChatRecoveryIncident.ChatStreamStalledError).AIChatAgentdelegates the read loop).Implementation
_iterateWithStallWatchdogthrows a distinctChatStreamStalledErrorso the read-loop catch can tell a stall from an in-band error._routeStallToBoundedRecovery— gated on chatRecovery enabled — opens/advances a recovery incident and schedules_chatRecoveryContinue(withrecoveredRequestIdwhen a submission is running), or returnsfalsewhen the budget is exhausted so the caller falls through to the existing terminal path._streamResult,_streamResultToRpcCallback) branch their catch: persist the settled partial (re-anchor), route to recovery, and suppress the terminal error frame when a continuation is scheduled (the continuation owns the real terminal outcome).Tests
think-session.test.ts): a stall on the first inference recovers via the scheduled continuation (no terminal error, the continuation streams to completion). The existing watchdog→terminal test now runs withchatRecoveryoff — the no-recovery gate. (Full think suite green: 469.)stall-recovery.test.ts, realwrangler dev— no process kill, since a stall is an in-isolate hang): the model stalls the first inference then streams; the watchdog fires, the framework's real alarm fires the scheduled continuation, and the turn completes with the continuation's output and no orphaned fiber rows.Verification
npm run checkclean (91 projects); think unit suite 469; new stall-recovery e2e green.Test plan
npm run checkcleanMade with Cursor