You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Think recovery skips an interrupted opened stream with no assistant content #2276
Think 0.17.0 has two recovery windows that can terminalize a recoverable chat submission after a Durable Object restart.
An opened durable stream with zero assistant content carries a streamId. _recoverablePreStreamUserId rejects that state before checking whether the durable leaf is still the originating user message. Recovery settles the submission as skipped and never retries the prompt.
A retry callback can also be durably scheduled before the current chat fiber disappears. On the next startup, _hasScheduledRecoveredContinuation recognizes _chatRecoveryContinue alone. A pending _chatRecoveryRetry therefore fails the running submission as interrupted before the retry executes.
Reproduction
Environment:
Node 24.18.0
@cloudflare/think 0.17.0
agents 0.22.0
Miniflare with a SQLite Durable Object
Empty-stream case:
Submit one user message through Think.submitMessages.
Return a readable model stream that opens and emits zero chunks.
Restart the Durable Object.
Let the same model complete on its next call.
Actual result: the submission settles as skipped with Recovery skipped. and onChatResponse does not fire.
Expected result: Think retries the same durable user leaf, completes once, and fires one response callback.
Scheduled-retry case:
Leave a submission running before the model stream opens.
Schedule _chatRecoveryRetry for that submission and remove the interrupted fiber, reproducing the durable state between recovery classification and callback execution.
Restart the Durable Object.
Actual result: startup changes the submission to error with Submission was interrupted after messages were applied.
Expected result: the submission remains running while the retry callback is pending.
Proposed fix
Let _recoverablePreStreamUserId accept a streamId when durable assistant content is empty, then retain its existing latest-user-leaf validation.
Let _hasScheduledRecoveredContinuation recognize _chatRecoveryRetry alongside _chatRecoveryContinue.
A Miniflare regression covering baseline completion, empty-stream retry, partial continuation, pending retry preservation, and cancellation passes with those two changes.
Follow-up: standalone reproduction, related fixes, and our current workaround
We reproduced these failures in a standalone Think subclass, independently of our application and Convex. We use a scripted model, durable submitMessages() submissions, and Miniflare setOptions() restarts over the same persisted SQLite. The reproduction does not modify SDK tables or manually create recovery jobs.
The empty-stream portion of this issue overlaps #2105. Thank you for the correction in #2256. That merged after the published 0.18.0 release. The remaining pending-retry recognition problem deserves separate attention within this issue.
Exact environments
Think 0.17.0 with Agents 0.22.0.
Think 0.18.0 with Agents 0.23.0.
Node 24.18.0, AI SDK 7.0.66, Miniflare 5.20260811.1-alpha, Wrangler 4.111.0.
Fresh npm installs with locked dependencies. No external model calls or credentials.
The harness applies a separate lazy-init workaround for chat@4.39.0 creating an AbortController at module scope, which workerd rejects. This workaround is identical in the stock-Think and patched-Think runs; no Think recovery code is modified in the stock runs.
Failure 1: an empty opened stream is skipped
Submit a user message using submitMessages() with a stable submission ID.
Have the model open its response stream without emitting assistant content.
Restart the Worker while retaining storage.
Let the model answer on recovery.
Expected: retry the same saved user prompt and complete the submission.
Observed on published 0.17.0 and 0.18.0: status: "skipped", error: "Recovery skipped.", and the transcript still contains only the prompt.
_recoverablePreStreamUserId rejects retry when streamId exists even though the partial is empty. Recovery selects continuation, and continueLastTurn() skips because the latest leaf is a user message. #2256 removes that classifier condition on main.
Failure 2: startup overlooks a legitimate pending retry
On 0.17, restart during beforeTurn preparation before the first model call. Native recovery schedules _chatRecoveryRetry, but startup reconciliation fails the submission. On 0.18, a second restart while the native recovery retry callback is active demonstrates the corresponding Tasks lookup problem.
Expected: the matching recovery retry keeps the submission running until recovery ends.
Observed:
Submission was interrupted after messages were applied.
_hasScheduledRecoveredContinuation recognizes only _chatRecoveryContinue. A native _chatRecoveryRetry with the same recoveredRequestId is excluded. In 0.18 this restriction exists in both the Tasks metadata predicate and the legacy schedule fallback.
The restriction is still present in the main snapshot we inspected, commit 9efee5f0c23d6a217fe4ba5e73c078e64e9cd28c:
// ── Programmatic API ───────────────────────────────────────────
The callback-window scenario overrides the declared, underscore-prefixed _chatRecoveryRetry method solely to delay the retry the SDK scheduled. It then delegates to super. The separate active-turn scenario below uses the ordinary beforeTurn hook.
Additional 0.18 case: an answer appears under an errored submission
We also restarted after the retry callback handed execution to its new turn:
Park the original turn in beforeTurn.
Restart and let the SDK begin its retry.
Park the retried turn in its own beforeTurn.
Restart again with a completing model.
The original submission becomes error. Four seconds later, the transcript contains the answer while the submission still reports error. This happens with published 0.18 and remains after the three targeted edits described below.
Our source-based interpretation is that the recovery Task has completed its handoff, while the active chat-turn Task has a fresh request ID. The original submission's request ID no longer locates that execution. The status/answer inconsistency is observed; the identity explanation is an inference we would appreciate help confirming.
Current workaround and results
We currently pin Think 0.17.0 and carry a two-line pnpm patch in our fix PR:
- WHERE callback = '_chatRecoveryContinue'+ WHERE callback IN ('_chatRecoveryContinue', '_chatRecoveryRetry')
The second edit removes streamId from _recoverablePreStreamUserId's exclusion condition, retaining the empty-partial, latest-user, continuation, and terminal-stream checks. Both edits are needed on 0.17: changing classification alone can create a retry that startup immediately invalidates.
For 0.18 we tested the classifier correction plus recognizing Retry in both Tasks and legacy schedule predicates.
Scenario
Stock 0.17
Patched 0.17
Stock 0.18
Patched 0.18
Normal completion
completed
completed
completed
completed
Empty stream, restart
skipped
completed
skipped
completed
Retry callback interrupted before handoff
error
completed
error
completed
Retried turn interrupted after handoff
error at first recovery
completed
error; answer later
error; answer later
The 0.18 correction is therefore incomplete. We are keeping the 0.18 upgrade separate. Its storage migration also needs its own validation.
Our production agent class separately passed seven local restart scenarios with the 0.17 patch, including partial-response preservation, Stop, stale retry, and preservation of a completed read-tool result. This does not establish exactly-once behavior for interrupted product writes or verify a live Cloudflare deployment.
Application impact and related issues
Our application stores product turn state in Convex and delivers settlement through onChatResponse or recovery exhaustion. A skipped execution can bypass normal settlement and leave the UI waiting for our twelve-minute abandonment sweep. That delay is our application behavior; the incorrect submission recovery is reproducible without our integration. We also need to harden terminal-outcome reconciliation on our side.
Would you prefer to keep pending-retry recognition in this issue and open a separate report for the 0.18 post-handoff case? Could retry recognition ship alongside the pending #2256 release, or be backported to 0.17? We have a standalone version-locked reproduction and JSON traces prepared for sharing.
Activity
antony4859 commentedon Sep 17, 2026
Follow-up: standalone reproduction, related fixes, and our current workaround
We reproduced these failures in a standalone
Thinksubclass, independently of our application and Convex. We use a scripted model, durablesubmitMessages()submissions, and MiniflaresetOptions()restarts over the same persisted SQLite. The reproduction does not modify SDK tables or manually create recovery jobs.The empty-stream portion of this issue overlaps #2105. Thank you for the correction in #2256. That merged after the published 0.18.0 release. The remaining pending-retry recognition problem deserves separate attention within this issue.
Exact environments
The harness applies a separate lazy-init workaround for
chat@4.39.0creating an AbortController at module scope, which workerd rejects. This workaround is identical in the stock-Think and patched-Think runs; no Think recovery code is modified in the stock runs.Failure 1: an empty opened stream is skipped
submitMessages()with a stable submission ID.Expected: retry the same saved user prompt and complete the submission.
Observed on published 0.17.0 and 0.18.0:
status: "skipped",error: "Recovery skipped.", and the transcript still contains only the prompt._recoverablePreStreamUserIdrejects retry whenstreamIdexists even though the partial is empty. Recovery selects continuation, andcontinueLastTurn()skips because the latest leaf is a user message. #2256 removes that classifier condition on main.Failure 2: startup overlooks a legitimate pending retry
On 0.17, restart during
beforeTurnpreparation before the first model call. Native recovery schedules_chatRecoveryRetry, but startup reconciliation fails the submission. On 0.18, a second restart while the native recovery retry callback is active demonstrates the corresponding Tasks lookup problem.Expected: the matching recovery retry keeps the submission running until recovery ends.
Observed:
_hasScheduledRecoveredContinuationrecognizes only_chatRecoveryContinue. A native_chatRecoveryRetrywith the samerecoveredRequestIdis excluded. In 0.18 this restriction exists in both the Tasks metadata predicate and the legacy schedule fallback.The restriction is still present in the main snapshot we inspected, commit
9efee5f0c23d6a217fe4ba5e73c078e64e9cd28c:agents/packages/think/src/think.ts
Lines 11250 to 11283 in 9efee5f
The callback-window scenario overrides the declared, underscore-prefixed
_chatRecoveryRetrymethod solely to delay the retry the SDK scheduled. It then delegates tosuper. The separate active-turn scenario below uses the ordinarybeforeTurnhook.Additional 0.18 case: an answer appears under an errored submission
We also restarted after the retry callback handed execution to its new turn:
beforeTurn.beforeTurn.The original submission becomes
error. Four seconds later, the transcript contains the answer while the submission still reportserror. This happens with published 0.18 and remains after the three targeted edits described below.Our source-based interpretation is that the recovery Task has completed its handoff, while the active chat-turn Task has a fresh request ID. The original submission's request ID no longer locates that execution. The status/answer inconsistency is observed; the identity explanation is an inference we would appreciate help confirming.
Current workaround and results
We currently pin Think 0.17.0 and carry a two-line pnpm patch in our fix PR:
The second edit removes
streamIdfrom_recoverablePreStreamUserId's exclusion condition, retaining the empty-partial, latest-user, continuation, and terminal-stream checks. Both edits are needed on 0.17: changing classification alone can create a retry that startup immediately invalidates.For 0.18 we tested the classifier correction plus recognizing Retry in both Tasks and legacy schedule predicates.
The 0.18 correction is therefore incomplete. We are keeping the 0.18 upgrade separate. Its storage migration also needs its own validation.
Our production agent class separately passed seven local restart scenarios with the 0.17 patch, including partial-response preservation, Stop, stale retry, and preservation of a completed read-tool result. This does not establish exactly-once behavior for interrupted product writes or verify a live Cloudflare deployment.
Application impact and related issues
Our application stores product turn state in Convex and delivers settlement through
onChatResponseor recovery exhaustion. A skipped execution can bypass normal settlement and leave the UI waiting for our twelve-minute abandonment sweep. That delay is our application behavior; the incorrect submission recovery is reproducible without our integration. We also need to harden terminal-outcome reconciliation on our side.onChatResponsenever fires if the DO resets between persisting the assistant message and running the hook #2266 describes a separate crash window after assistant persistence but beforeonChatResponse. This patch does not fix it.skippedinstead of completing with output #1727 concerns structured workflow output recovery; we have not verified that path here.Would you prefer to keep pending-retry recognition in this issue and open a separate report for the 0.18 post-handoff case? Could retry recognition ship alongside the pending #2256 release, or be backported to 0.17? We have a standalone version-locked reproduction and JSON traces prepared for sharing.
fix(think): preserve scheduled retries across restarts (#2297)
ben-reitz commentedon Sep 18, 2026
Aiming to get a new release out with these fixes this afternoon @antony4859 !This is now released.