Skip to content

fix(think): route a stream-stall watchdog abort into bounded recovery (#1626)#1643

Merged
threepointone merged 2 commits into
mainfrom
fix/stall-watchdog-bounded-recovery-1626
Jun 1, 2026
Merged

fix(think): route a stream-stall watchdog abort into bounded recovery (#1626)#1643
threepointone merged 2 commits into
mainfrom
fix/stall-watchdog-bounded-recovery-1626

Conversation

@threepointone

Copy link
Copy Markdown
Contributor

Summary

Closes #1626. When chatStreamStallTimeoutMs is 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

  • Automatic when the watchdog is enabled + chatRecovery is 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 existing maxAttempts/budget. With chatRecovery off, a stall stays terminal as before.
  • Reuses the existing incident budget (maxAttempts + 15-min wall-clock + no-progress window + progress-aware reset). No new budget. Boundedness is inherited from _beginChatRecoveryIncident.
  • Only the watchdog abort routes to recovery — in-band model errors stay terminal (distinguished by a dedicated ChatStreamStalledError).
  • Idempotency matches deploy recovery: settled tool results are durable (not re-run); a tool mid-execution when the stall fired re-runs on the continuation.
  • Think-only (the watchdog is Think-only; AIChatAgent delegates the read loop).

Implementation

  • _iterateWithStallWatchdog throws a distinct ChatStreamStalledError so the read-loop catch can tell a stall from an in-band error.
  • New _routeStallToBoundedRecovery — gated on chatRecovery enabled — opens/advances a recovery incident and schedules _chatRecoveryContinue (with recoveredRequestId when a submission is running), or returns false when the budget is exhausted so the caller falls through to the existing terminal path.
  • Both stream paths (_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

  • Unit (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 with chatRecovery off — the no-recovery gate. (Full think suite green: 469.)
  • E2E (stall-recovery.test.ts, real wrangler devno 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 check clean (91 projects); think unit suite 469; new stall-recovery e2e green.

Test plan

  • Unit: stall → recovery (recover) + watchdog → terminal (recovery off)
  • E2E: real-alarm stall→recovery, no orphaned fibers
  • npm run check clean

Made with Cursor

…#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-bot

changeset-bot Bot commented Jun 1, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5b430fe

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@cloudflare/think Minor

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

@threepointone

Copy link
Copy Markdown
Contributor Author

/bonk do a deep review on this, look for edge cases, is the ux/dx good and tuneable

@pkg-pr-new

pkg-pr-new Bot commented Jun 1, 2026

Copy link
Copy Markdown

Open in StackBlitz

agents

npm i https://pkg.pr.new/agents@1643

@cloudflare/ai-chat

npm i https://pkg.pr.new/@cloudflare/ai-chat@1643

@cloudflare/codemode

npm i https://pkg.pr.new/@cloudflare/codemode@1643

hono-agents

npm i https://pkg.pr.new/hono-agents@1643

@cloudflare/shell

npm i https://pkg.pr.new/@cloudflare/shell@1643

@cloudflare/think

npm i https://pkg.pr.new/@cloudflare/think@1643

@cloudflare/voice

npm i https://pkg.pr.new/@cloudflare/voice@1643

@cloudflare/worker-bundler

npm i https://pkg.pr.new/@cloudflare/worker-bundler@1643

commit: 5b430fe

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. _rescheduleRecoveryAfterStableTimeout (line 7822) now has no docstring — the docstring above it is _routeStallToBoundedRecovery's.
  2. _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 calls return; (void return), does NOT fire the response hook, does NOT call callback.onDone(). This means the RPC caller (parent agent) gets neither onDone nor onError — 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's chat() call will hang until the continuation eventually fires onDone. Is the RPC promise for chat() resolved at this point? If the for await loop 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 (where onError fires).

  • 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 chatStreamStallTimeoutMs and left chatRecovery at the default true, 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:exhausted events 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 chatStreamStallTimeoutMs override via TurnConfig would let subclasses handle known-slow turns (e.g., tool-heavy turns) without permanently raising the global timeout.
  • The _streamResultToRpcCallback stall path silently abandoning the StreamCallback is 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 _stallAttemptsRemaining gating 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 with wrangler dev. The 60s poll loop with 2.5s intervals is generous. The assertion on hasFiberRows === false is important (no orphaned fibers). The 120_000 timeout 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.

@ask-bonk

ask-bonk Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

The review has been posted to PR #1643. Here's a summary of the findings:

Bug found:

  • Orphaned JSDoc block at think.ts:7745 — the new _routeStallToBoundedRecovery method was inserted between _rescheduleRecoveryAfterStableTimeout's docstring and the method itself, leaving the docstring detached and giving the new method two consecutive JSDoc blocks. Should be fixed before merge.

Design validation:

  • The core approach is correct: stalls are routed through the existing _beginChatRecoveryIncident_chatRecoveryContinue pipeline with no new config surfaces
  • Budget exhaustion, progress tracking, idempotent scheduling, and backward compatibility (recovery off = terminal) all work correctly
  • Both stream paths (_streamResult and _streamResultToRpcCallback) handle the new error type consistently

Edge cases examined:

  • Recursive stalls (continuation itself stalls) — correctly handled by incident budget advancement
  • Zero-part stalls (parked before any chunks) — correct, equivalent to fresh turn
  • Stalls during tool execution — documented accurately in README
  • RPC callback asymmetry — the parent's chat() call resolves but gets neither onDone nor onError during recovery, which is defensible but subtle
  • Idempotent vs non-idempotent scheduling — correctly differentiated

DX suggestions (future, not blockers):

  • Per-turn chatStreamStallTimeoutMs override in TurnConfig
  • Document sub-agent stall behavior in README

github run

…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.
@threepointone threepointone merged commit bc86dce into main Jun 1, 2026
4 checks passed
@threepointone threepointone deleted the fix/stall-watchdog-bounded-recovery-1626 branch June 1, 2026 18:18
threepointone added a commit that referenced this pull request Jun 1, 2026
…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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

discussion: route a stream-stall watchdog abort into bounded recovery instead of a terminal error

1 participant