fix(think,ai-chat,agents): harden recovery, transcript integrity & compaction under deploy churn#1623
Conversation
…ted mid-turn deploys Under repeated real `wrangler deploy`s mid-turn, chat recovery runs a chain of continuations. Three bugs combined to mark a turn's durable submission `error` even when it actually completed every step (validated end-to-end with the deploy-churn harness + a recovery trace): 1. Lost ownership: the submission link (`recoveredRequestId`) was derived from each continuation's own fresh requestId, so chained continuations dropped it and the continuation that finally completed the turn could not mark the submission `completed`. Now keyed off the stable recovery root and threaded through the whole chain. 2. Stale-continuation clobber: a superseded continuation tripped the `conversation_changed` guard because the leaf had advanced via recovery's own forward progress (a new assistant message), not a new user turn, and overwrote the still-running submission to `error`. Now a superseded continuation skips benignly; only a genuinely newer user turn marks the submission `skipped` (never `error`). 3. Premature stable_timeout: a timeout while waiting for the isolate to settle (common while a deploy is in flight) failed the turn terminally at attempt 1. Now it reschedules within the `maxAttempts` budget. `@cloudflare/ai-chat` shares the recovery machinery but has no durable-submission layer, so it receives only the stable_timeout reschedule fix (mirrored in both `_chatRecoveryContinue` and `_chatRecoveryRetry`). Tests: 7 deterministic unit tests (5 in think, 2 in ai-chat) covering chained ownership, benign superseded skip, newer-user-turn -> skipped, stable_timeout reschedule within budget, and exhaustion. think 441 / ai-chat 475 green. Co-authored-by: Cursor <cursoragent@cursor.com>
…recovery testing Extends the deploy-churn example to drive a long, tool-using session via HTTP (no browser) against a REAL model (Workers AI or Anthropic) while firing real `wrangler deploy`s mid-turn, and measures whether completed tool calls re-run or the durable submission is wrongly errored. - `recordStep` tool: one ledger row per execution, so a re-run of a completed step shows up as a duplicate index (the "rollback" signal). - provider switch (workers-ai | anthropic) stored in a SQL config table so getModel()/getTools() observe it on a fresh post-deploy isolate. - `/drive/start|status|reset` HTTP routes driving `submitMessages` + ledger. - `scripts/deploy-rollback.ts` orchestrator: real deploys during the session, then a CLEAN / MINIMAL / ROLLBACK verdict plus submission status. This reproduced and validated both the #1621 tool-result durability fix and the recovery submission-status fix in the preceding commit. Co-authored-by: Cursor <cursoragent@cursor.com>
…edule row
The stable-timeout retry added in the previous commit used
`schedule(..., { idempotent: true })` from INSIDE the currently-executing
one-shot `_chatRecoveryContinue`/`_chatRecoveryRetry` schedule row. Because
`alarm()` deletes that one-shot row only AFTER the callback returns, the
idempotent reschedule deduped onto the still-present executing row and was then
deleted with it — so the retry silently never fired and the turn STALLED
(incident frozen at `stable_timeout_retry`, submission stuck `running`).
A real-deploy repro with a 12s tool reproduced this: the turn stalled at 4/8.
With the reschedule switched to a fresh (non-idempotent) delayed row it now
completes 8/8 under the same churn.
The unit tests previously passed only because they drive the callback directly
(no executing row to dedup against). They now pre-insert a matching schedule row
to simulate the executing one-shot, and assert the reschedule creates a NEW row.
Co-authored-by: Cursor <cursoragent@cursor.com>
…under churn Adds rigorous reproductions used to characterize recovery behavior under deploy churn (all show the framework is BOUNDED — re-runs at most the in-flight step, no deep rollback, no task amplification): - think e2e `tool-rollback.test.ts` + `ThinkToolRollbackE2EAgent`: a long deterministic tool loop with a non-idempotent ledger, rapid SIGKILL/restart; measures rollback DEPTH (re-runs vs evictions). - think e2e `task-amplification.test.ts` + `ThinkTaskParentE2EAgent`: a parent `runTask` tool driving a child agent; verifies an eviction mid-task does NOT re-run the whole child turn. - deploy-churn: configurable per-tool delay (`--delay-ms` / `delayMs`) so a real ~33s `wrangler deploy` lands DURING a tool execution (code-update reset mid-tool). This repro surfaced the stable-timeout reschedule stall fixed in the previous commit. Co-authored-by: Cursor <cursoragent@cursor.com>
…tent Pins the property that protects against the "disappearing/duplicated completed tool calls" failure mode under churn: re-running recovery on the same interrupted stream (e.g. a second eviction during the persist window) replaces the reconstructed assistant message by its stable id (taken from the stream's `start` chunk) rather than appending a duplicate or losing it. Verified the content is preserved across two recovery passes. Co-authored-by: Cursor <cursoragent@cursor.com>
…eting them
`_repairToolTranscriptParts` deleted any tool call with no recorded output
before the next turn. For a tool interrupted mid-execution (deploy/eviction) or
an `ask_user` answered by the next message, that:
- removed the call from the durable + broadcast transcript (it visibly
"disappeared" — the exact symptom in the customer's deploy-churn video), and
- let the model silently re-run it, duplicating non-idempotent side effects.
Now the orphan is flipped to `state: "output-error"` with an explanatory
message: the record is preserved, the model is told the tool errored (so it
doesn't blindly re-run it), and conversion still gets a valid tool-result so the
provider doesn't 400 with AI_MissingToolResultsError. Stringified `input`s are
normalized in the same pass.
Blast radius was a single test (which asserted the old deletion); it now asserts
preservation. Full think suite green (442).
Co-authored-by: Cursor <cursoragent@cursor.com>
After `_repairTranscriptForProvider` heals orphan tool calls (preserving them as errored results), pass `ignoreIncompleteToolCalls: true` to `convertToModelMessages` so any incomplete tool call that still slips through (compaction edges, addToolOutput races, unrecognized part shapes) is dropped at conversion instead of throwing AI_MissingToolResultsError and wedging the turn. No-op in the common path (the repair runs first); verified no test churn. Co-authored-by: Cursor <cursoragent@cursor.com>
…#1593) A tokenCounter set on Session.compactAfter() only drove the fire/no-fire trigger; createCompactFunction's tail-budget boundary still used the chars/4 heuristic. On tool-heavy histories that under-counts ~4-5x, so the tail budget covered the whole history and compaction fired every turn but returned null — never shortening history (worse than not configuring it). The Session now passes its counter to the compaction function via a new CompactContext argument; createCompactFunction uses it for the tail walk when no explicit CompactOptions.tokenCounter was given. One counter on compactAfter() now drives both "should we compact?" and "what should we compact?". If the trigger fires but compaction still returns null, the Session logs a one-time warning instead of looping silently. CompactFunction gains an optional second context?: CompactContext arg (backward compatible). Session suite green (74). Co-authored-by: Cursor <cursoragent@cursor.com>
…unter caveat - Reset the one-time auto-compaction no-op warning when compaction succeeds, so a later regression is surfaced again instead of staying silent. - Document that the Session counter flowed into createCompactFunction is invoked per-message: usage-only counters degrade the tail budget to minTailMessages, and the counter runs O(n) per compaction. Recommend an explicit per-message CompactOptions.tokenCounter for precise budgeting. Co-authored-by: Cursor <cursoragent@cursor.com>
🦋 Changeset detectedLatest commit: be29d60 The changes in this PR will be included in the next version bump. This PR includes changesets to release 3 packages
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 run |
|
Done. Ran |
|
/bonk do a deep review on this PR, look for edge cases, what do you think |
There was a problem hiding this comment.
Deep Review — PR #1623
I've read every changed file and diff carefully. This is a serious hardening PR for a real customer failure mode (repeated wrangler deploy churn during long, tool-heavy agent turns). The problem analysis is excellent and the three-bug decomposition is precise. Here's my detailed review, focused on edge cases, correctness concerns, and things that could bite.
1. Recovery Submission Ownership (recoveryRootRequestId)
The fix is correct and well-motivated. Keying submission ownership off recoveryRootRequestId instead of the per-continuation requestId is the right design. The threading through _runChatRecoveryFiber → snapshot → _chatRecoveryContinue looks clean.
Edge case concern — _activeChatRecoveryRootRequestId initialization: In _runChatRecoveryFiber (think.ts:1970), the root is set as this._activeChatRecoveryRootRequestId ?? requestId. This means the FIRST turn in a recovery chain gets its own requestId as the root, and subsequent continuations inherit it. This is correct for the happy path. But consider: if two independent recovery incidents overlap (e.g., a user sends a new message that triggers a new fiber while an old recovery is still draining), _activeChatRecoveryRootRequestId is a single instance field — the second turn would inherit the first's root. The try/finally restore in _chatRecoveryContinue:7455 mitigates this, but it's worth noting the field is inherently not concurrency-safe. The turn queue serialization makes this safe in practice, but it's a subtle invariant worth a comment.
2. Superseded Continuation Skip Logic
This is the most important behavioral change and the analysis is correct. The old code marked superseded continuations as error, which was wrong. The new logic at think.ts:7386-7411 is:
- Leaf is an assistant message but not the target → recovery's own progress advanced, skip benignly, don't touch the submission (correct!)
- Leaf is a user message → genuinely new turn, mark submission
skipped(correct!)
Edge case — lastLeaf is null: If getLatestLeaf() returns null (empty conversation after a clear), lastLeaf?.role === "user" is false, so supersededByNewerUserTurn is false. The continuation is skipped but the submission stays running. This is debatable — a cleared conversation arguably should terminate the submission. However, this is a pre-existing behavior (the clear path resets the turn queue generation, which would cause the continuation's continueLastTurn to return skipped anyway), so it's not a regression.
3. Stable Timeout Reschedule
The idempotent: false insight is a gem. The comment explaining WHY idempotent would fail here (the currently-executing one-shot row gets deleted by alarm() after return, taking an idempotent dedup with it) is one of the most important in this PR. The test at think-session.test.ts that pre-inserts a schedule row to verify count === 2 is exactly the right test.
Edge case — unbounded retries via progress: The recovery incident has a progress field (high-water mark of assistant messages) used elsewhere to reset the attempt budget when forward progress is detected. The stable timeout retry increments attempt but does not check CHAT_RECOVERY_MAX_WINDOW_MS. However, the wall-clock ceiling IS checked in _beginChatRecoveryIncident, not here. If a deploy churn storm produces stable timeouts for 15+ minutes, the attempt counter would exhaust naturally (6 attempts × 3s delay = 18s, well within the window). So this is fine in practice, but worth noting that the wall-clock guard is only checked at incident creation, not at retry time.
Code duplication: The stable timeout reschedule logic is inlined identically in _chatRecoveryRetry and _chatRecoveryContinue in think.ts (two ~25-line blocks). ai-chat correctly extracts this into _rescheduleRecoveryAfterStableTimeout. Think should arguably do the same. Not a correctness issue, but a maintainability concern — a future fix to one copy but not the other would be a bug.
4. Interrupted Tool Call Preservation
The behavioral change from "delete" to "error" is clearly correct. The old behavior was wrong in two ways: disappearing tool calls and silent re-execution. The new output-error state with an explanatory message is well-chosen.
Edge case — JSON parsing of stringified inputs: The input normalization at think.ts:1499-1510 tries JSON.parse on string inputs that start with {. This is a best-effort heuristic. Consider:
- Input
'{ invalid json'— caught by the try/catch, falls back to the original string. ✓ - Input
'{"key": "value"}'— correctly parsed to object. ✓ - Input that is a string representation of a number like
'42'— doesn't start with{, so no parse attempt. ✓ - Input
'{"key": "val"}extra'—JSON.parsewould throw, falls back to original. ✓
This looks robust. The same normalization runs for both orphaned and completed tool parts, which is a nice consistency.
Edge case — ignoreIncompleteToolCalls: true as backstop: The backstop at think.ts:2485-2488 is a good defense-in-depth layer. However, ignoreIncompleteToolCalls silently drops tool calls, which could mask bugs in the repair logic. The repair is supposed to catch everything — if incomplete calls reach the backstop, it means the repair missed something. A debug-level log when the backstop fires would help surface these cases without breaking the turn.
5. Compaction TokenCounter Flow-Through
The fix is structurally sound. The CompactContext type is backward-compatible (optional second argument to the compaction function), and the session counter adapter correctly feeds individual messages with empty system/context.
Edge case — the per-message caveat is well-documented but could be confusing: The comment at compaction-helpers.ts:496-504 correctly warns that a usage-only counter (one that returns a fixed total regardless of which messages are passed) degrades tailTokenBudget to minTailMessages. This is important but only documented in code comments. Users who wire usage.inputTokens as their counter will get compaction that "works" but with a degraded tail budget — and they'll get a console warning every turn that compaction returned null. The one-time warning at session.ts:551-554 helps surface this, but the message suggests "configure a tokenCounter" — which they already did, just the wrong kind. Consider making the warning message more specific about per-message vs. whole-prompt counters.
Edge case — _warnedCompactionNoOp is instance-level: The warning flag at session.ts:78 is an instance field, not persisted. After DO hibernation + wake, the warning resets and fires again. This is actually fine — a fresh isolate should surface the issue — but worth noting it's by design.
6. Test Coverage Assessment
The test coverage is thorough and well-structured:
- Chain ownership test (think-session.test.ts) — verifies
recoveredRequestIdin the scheduled payload is the root, not the continuation's id - Superseded continuation — verifies submission stays
runningwhen an assistant leaf supersedes - New user turn supersede — verifies submission goes to
skipped - Stable timeout reschedule — verifies non-idempotent scheduling creates 2 rows
- Budget exhaustion — verifies terminal failure when attempts are spent
- Idempotent re-recovery — verifies no duplicate assistant messages
- CompactContext flow — verifies session counter is used when no explicit counter
- Orphan tool preservation — verifies
output-errorstate and input normalization
Gap I notice: There's no test for the _chatRecoveryRetry stable timeout reschedule path in Think (only _chatRecoveryContinue). The ai-chat tests cover both, but Think's _chatRecoveryRetry has its own inlined copy of the reschedule logic. Given the duplication concern above, a test for the retry path would be valuable.
7. deploy-churn Example
The harness is well-designed — deterministic mock model, real wrangler deploy churn, non-idempotent tool ledger for rollback detection, and structured JSONL timeline output. The CLEAN/MINIMAL/ROLLBACK classification is a nice touch.
Note: The harness uses @callable() decorators, which require the agents vite plugin for decorator transforms. The vite.config.ts isn't in the changed files — worth verifying it includes agents().
Summary
This is a high-quality fix for a genuinely difficult problem. The three-bug decomposition is precise, the fixes are well-motivated, and the test coverage is strong. The main areas I'd want to see addressed:
- Extract the stable timeout reschedule logic in Think into a helper method (like ai-chat does) to eliminate the inlined duplication
- Add a test for
_chatRecoveryRetrystable timeout in Think - Minor: consider a debug log when the
ignoreIncompleteToolCallsbackstop fires
|
Review posted on PR #1623. Here's a summary of what I found: Overall: This is a high-quality hardening PR. The three-bug decomposition (lost ownership, stale-continuation clobber, premature stable_timeout) is precise, and the fixes are well-motivated by a real customer failure mode. Key findings:
Three actionable suggestions:
|
…e return type A `Record<string, unknown>` return collapses to `never` across the Durable Object RPC stub boundary (Workers RPC drops `unknown`-valued records as non-serializable), so the chained-continuation test saw `payload` as `never` and failed typecheck. Return the concrete recovery-link fields instead. Co-authored-by: Cursor <cursoragent@cursor.com>
Transcript repair already parsed stringified-JSON tool inputs, but a tool call
with a missing or null `input` was left unrepaired — Anthropic rejects a
`tool_use` block whose `input` is absent, so the turn 400s forever. A new
`_normalizeToolInput` helper now also defaults a missing/null input to `{}` on
both the orphan-healing and settled-part paths.
Co-authored-by: Cursor <cursoragent@cursor.com>
agentTool() collapsed every non-completed sub-agent run to an opaque
{ ok: false, error: string }, so a parent agent could not tell a transient
interruption (child reset/superseded by a deploy or parent recovery) apart
from a terminal failure or an intentional cancellation — and would often
parrot the interruption text to the user as final.
Failures now return AgentToolFailure { ok: false, status, error, retryable }:
interrupted -> retryable: true (and surfaces the interruption reason), while
aborted and error -> retryable: false. Backward compatible for consumers
reading ok/error; AgentToolFailure is exported from `agents`.
Co-authored-by: Cursor <cursoragent@cursor.com>
agents
@cloudflare/ai-chat
@cloudflare/codemode
hono-agents
@cloudflare/shell
@cloudflare/think
@cloudflare/voice
@cloudflare/worker-bundler
commit: |
A model stream that parks without ever throwing (no chunk, no error, no `done`) left the chat read loop waiting forever — an infinite spinner with no terminal state. There was no detection for a silently hung turn. Add `chatStreamStallTimeoutMs` (default 0 = off): if no UI-message-stream chunk arrives within the window, the watchdog aborts the turn so the loop exits with a terminal stream error (routed through onChatError stage "stream") and emits a new `chat:stream:stalled` observability event. Applies to both the WebSocket turn loop and the chat()/sub-agent callback loop. The watchdog aborts the turn's signal (not a reader cancel) so the AI SDK pipeline tears down without writing to an already-cancelled readable; the abandoned read's rejection is pre-caught to avoid an unhandled rejection. It measures inter-chunk inactivity (which includes tool execution), so it must be set above the slowest expected model TTFT and tool latency. Co-authored-by: Cursor <cursoragent@cursor.com>
…th test, sharper diagnostics - Extract the stable-timeout reschedule into a shared `_rescheduleRecoveryAfterStableTimeout` helper (mirrors @cloudflare/ai-chat), removing the two inlined near-identical copies in `_chatRecoveryRetry` / `_chatRecoveryContinue` so a future fix can't diverge between them. - Add a `_chatRecoveryRetry` stable-timeout reschedule test (the path previously only covered for `_chatRecoveryContinue`). - Surface when an incomplete tool call survives transcript repair and is about to be dropped by `ignoreIncompleteToolCalls` (warns + emits), so the backstop can't silently mask a repair gap. - Make the compaction no-op warning distinguish a per-message vs whole-prompt (usage) tokenCounter, since "configure a tokenCounter" was misleading when one was already configured. - Document the single-field `_activeChatRecoveryRootRequestId` serialization invariant (safe only because turns are serialized by the turn queue). Co-authored-by: Cursor <cursoragent@cursor.com>
- chat-agents: transcript repair now heals orphaned tool calls (preserved as errored results) and normalizes malformed/missing inputs — was "removing". - observability: add the new chat:stream:stalled event (agents:chat channel) and clarify chat:transcript:repaired counts (preserved-as-errored + backstop). - think README: document the opt-in chatStreamStallTimeoutMs inactivity watchdog. - agent-tools: document the AgentToolFailure shape and retryable semantics. - sessions: note the compactAfter tokenCounter now also drives the boundary walk (CompactContext), with the per-message/usage-counter caveat. Co-authored-by: Cursor <cursoragent@cursor.com>
…erence Mirrors the package README so the developers.cloudflare.com Think config table (the target of the observability chat:stream:stalled link) documents the new inactivity watchdog. Co-authored-by: Cursor <cursoragent@cursor.com>
…exits early The inactivity-watchdog generator wraps the model stream, but on an early consumer exit (a `break` on an in-band stream error, where the abort signal is NOT set) it never forwarded `.return()` to the source — leaking the wrapped ReadableStream that the old direct `for await` would have cancelled. Add a top-level finally that cancels the source on early termination, skipped after a watchdog stall (which already aborted the upstream, where a late cancel would make the AI SDK write to an already-cancelled readable). Tests: watchdog does not false-fire on a slow-but-steady stream (timer resets per chunk), and an in-band error under an armed watchdog terminates cleanly with no unhandled rejection. Co-authored-by: Cursor <cursoragent@cursor.com>
…he continuation id
_markRecoveredSubmissionInterrupted was called with the per-continuation
`requestId` in both terminal abandon paths — recovery exhaustion
(`_exhaustChatRecovery`) and `{ continue: false }`. Under chained continuations
(recoveryRootRequestId !== requestId) the durable submission row still carries
the root id, so the `WHERE request_id = ?` lookup missed it and left the
submission stuck `running` forever instead of `error`.
Thread the recovery root through both paths (storing it on the incident record
for the exhaustion path). Regression test drives a disabled-recovery chained
continuation and asserts the root submission flips to `error` (verified to fail
without the fix).
Co-authored-by: Cursor <cursoragent@cursor.com>
_repairToolTranscriptParts' hasOutput check omitted `state === "output-error"`, so a tool part already healed to output-error (no `output` field) — or a tool that legitimately errored — re-entered the heal branch on every turn. That clobbered a real errorText with the generic "interrupted" message and emitted a spurious chat:transcript:repaired event + updateMessage write + broadcast each turn for the life of the conversation. Treat output-error as a settled terminal state (matching _incompleteToolCallIds). Regression test asserts a real errorText survives a follow-up turn (verified to fail without the fix). Co-authored-by: Cursor <cursoragent@cursor.com>
…he enumeration Sweep for the recurring "incomplete terminal-state enumeration" bug class found one more instance: `output-denied` (a user-denied tool approval) is a settled state the AI SDK converts into a denial tool-result, but transcript repair, the backstop detector, and the immediate-flush check all omitted it. Repair therefore flipped a denial into a generic "interrupted" error (losing the denial), and the denied result wasn't durably flushed. - Centralize the terminal-state check into `_toolPartHasSettledResult` (output-available | output-error | output-denied, plus legacy output/result), shared by `_repairToolTranscriptParts` and `_incompleteToolCallIds` so the two can no longer drift. - Flush `tool-output-denied` chunks immediately, like other settled results. - Regression test: an output-denied part survives a follow-up turn (verified to fail without the fix). Reviewed and confirmed complete (no change needed): _isTerminalSubmissionStatus, streamIsTerminal, _messageHasPendingInteraction, shouldMarkSkippedAfterGenerationChange. Co-authored-by: Cursor <cursoragent@cursor.com>
…e-client clobber AIChatAgent sweep for the terminal-state-enumeration bug class: ai-chat's own tool-state handling already covers output-denied (it's the HITL home), but the shared reconciler did not. reconcileMessages (run at persist by both Think and AIChatAgent) only carried over the server's `output-available` result into a stale client part — so a client that persisted a stale `input-available` for a tool the server had already resolved to `output-error`/`output-denied` clobbered the resolved result, losing the error or the user's denial. Index all three terminal states and overlay the matching result field (output / errorText / approval). Tests assert a server output-error and output-denied survive a stale client input-available (verified to fail before). Co-authored-by: Cursor <cursoragent@cursor.com>
Update — review-driven fixes + a terminal-state sweepSince the deep review, I've addressed both review findings and run a focused sweep for the bug class they exposed ("is every terminal tool/stream state enumerated as terminal?"). All of the below are on the branch; Review findings
Sweep fixes (same bug class, additional instances)
Reviewed and confirmed complete (no change needed)
Also addressed from the review
Deferred (tracked, intentionally out of scope)
|
|
/bonk do another deep review of this PR, look at the existing replies and updates, look for edge cases and production worthiness |
There was a problem hiding this comment.
Deep Review #2 — PR #1623
I've read every changed file against the current HEAD (9b522fd), the first review's findings, and the follow-up fix commits. This review focuses on production-worthiness: edge cases that survived the first review pass, latent correctness issues in the new code, and things that could bite in long-running production deploys.
Overall Assessment
The follow-up commits (1e36ea3a through 9b522fd7) addressed the first review's findings well — the stable timeout helper was deduplicated, the output-error/output-denied terminal-state gaps were closed, and the reconciler now protects all three terminal tool states. The centralized _toolPartHasSettledResult predicate is the right design. The test coverage is unusually thorough for a fix PR of this scope.
The PR is production-worthy with the items below addressed or acknowledged.
1. Dead Code in _repairTranscriptForProvider (think.ts:1632-1638)
The removedIds branch is now dead code. _repairToolTranscriptParts no longer removes messages — it always pushes every message to repaired (line 1613: repaired.push(messageChanged ? { ...message, parts } : message)), so repaired.length === messages.length always. The repairedIds set will contain every ID from the input, and removedIds will always be empty.
This is harmless but misleading — it suggests messages can still be deleted by repair, which is no longer true (the whole point of this fix). Consider removing the removedIds branch to keep the code aligned with its behavior, or at minimum add a comment explaining it's vestigial.
2. _completeRecoveredSubmission SELECT can find stale rows (think.ts:7520-7528)
After the UPDATE WHERE request_id = ${originalRequestId} AND status = 'running', the SELECT queries WHERE request_id = COALESCE(${requestId}, ${originalRequestId}). If the UPDATE matched and changed request_id from originalRequestId to requestId, this correctly finds the updated row.
But if the UPDATE matched zero rows (no running submission with that originalRequestId — possible if the submission was concurrently completed between the _hasRunningSubmission guard and this call), and requestId is non-null, the SELECT searches for request_id = ${requestId}. If a previous submission happened to use that same request ID, we'd find and emit a status event for a stale, unrelated submission.
In practice, request IDs are random-enough that collision is negligible, and the _isTerminalSubmissionStatus guard (line 7530) prevents most damage. But the gap is architecturally subtle. A tighter fix: do the UPDATE with RETURNING (D1/SQLite supports it) to atomically get the updated row, avoiding the two-query race entirely.
3. _normalizeToolInput doesn't handle array-typed inputs (think.ts:1492)
The heuristic checks raw.trim().startsWith("{") to decide whether to JSON-parse a stringified input. A stringified array input like '[{"key": "value"}]' won't be parsed — it starts with [, not {. If a tool receives array-typed args that were stringified (same way objects get stringified), they'd persist as a raw string forever.
This may be acceptable if array-typed tool inputs are not expected from any provider, but worth documenting the assumption. The fix is trivial: change startsWith("{") to startsWith("{") || startsWith("[").
4. Stall watchdog: timer accumulation on very-high-throughput streams
In _iterateWithStallWatchdog (think.ts:6300-6332), a new setTimeout is created for every chunk and cleared after the race. On a high-throughput stream (thousands of chunks/second from a fast model), this creates and clears thousands of timers per second. The Workers runtime handles this fine, but the pattern allocates a new Promise + setTimeout per chunk, which is more GC pressure than necessary.
An alternative: use a single setTimeout that is .refresh()'d on each chunk (Node 10+ / Workers support timer.refresh()). This avoids the per-chunk Promise allocation. Not a blocking concern — the current approach is correct — but a production optimization worth considering for high-volume streaming.
5. AgentToolRunInspection.status excludes "interrupted" (agent-tool-types.ts:84)
AgentToolRunInspection is typed as Exclude<AgentToolRunStatus, "interrupted">, but AgentToolRunState (line 131) and the database both include "interrupted" as a valid status. The child adapter's inspectAgentToolRun returns AgentToolRunInspection, but the agent-tool recovery code at index.ts:4788 reads row.status !== "interrupted" — meaning the database can have interrupted status, but the type system says the inspection result can't.
This looks like a pre-existing inconsistency that this PR inherits rather than introduces. But since this PR adds the AgentToolFailure type and exports it, it's worth flagging: a consumer who calls inspectAgentToolRun and exhaustively switches on status won't handle "interrupted" because the type excludes it, even though the runtime value can be "interrupted".
6. AIChatAgent _chatRecoveryContinue doesn't distinguish assistant-leaf vs. user-leaf supersession
Think's _chatRecoveryContinue (think.ts:7607-7625) carefully distinguishes:
- Leaf is assistant → recovery's own progress, skip benignly, don't touch submission
- Leaf is user → genuinely new turn, mark submission
skipped
AIChatAgent's equivalent (ai-chat/index.ts:3417-3424) just marks skipped/conversation_changed regardless. This is correct because AIChatAgent has no submission layer, but it means the two implementations have different semantics for the same concept name (conversation_changed). If AIChatAgent ever gains submissions, this will silently clobber them. A comment noting this divergence would prevent a future regression.
7. Recovery progress marker: forward progress can be false-positive after compaction
_chatRecoveryProgressMarker() (used in _beginChatRecoveryIncident at line 6833) likely counts assistant messages. After a compaction, the message count drops. If recovery fires post-compaction, currentProgress < prevProgress even though the turn actually advanced — madeProgress would be false, and the attempt counter wouldn't reset.
The inverse is also possible: if compaction happens during a recovery chain (auto-compaction fires on appendMessage), the progress marker jumps down, making the next interruption look like no progress was made. This would be rare (compaction + churn simultaneously) but could cause premature budget exhaustion.
If the progress marker is based on persisted row IDs (auto-incrementing) rather than count, this concern doesn't apply — worth confirming.
8. Test gap: concurrent tool output + repair race
The repair path (_repairTranscriptForProvider) runs at the start of _runInferenceLoop (line 2562). If a HITL tool-output arrives via WebSocket between the repair check and convertToModelMessages (line 2584), the repair would have already flipped that tool to output-error, but the real output wouldn't be in the repaired message. The ignoreIncompleteToolCalls backstop would then drop it.
This race window is small (synchronous code between lines 2562 and 2584), so it's likely safe in practice because the turn queue serializes chat turns. But addToolOutput from a WebSocket handler doesn't go through the turn queue — it's a direct mutation. A test that calls addToolOutput concurrently with a turn start would verify the invariant holds.
9. Minor: removedToolCalls counter name is now misleading
In _repairToolTranscriptParts, the removedToolCalls counter (line 1557) is incremented when a tool call is flipped to errored — but nothing is removed. The name is a holdover from the old delete behavior. It flows through to the chat:transcript:repaired observability event. The semantic mismatch between the field name and behavior could confuse observability consumers.
Consider renaming to repairedToolCalls or erroredToolCalls for clarity. Not blocking, but a low-effort improvement.
10. Reconciler: mergeServerToolOutputs field-overlay could carry stale output onto an output-error part
In message-reconciler.ts:131-137, the overlay carries output, errorText, and approval from the server part when the server state is terminal. But it applies all three fields unconditionally via spread:
...("output" in server ? { output: server.output } : {}),
...("errorText" in server ? { errorText: server.errorText } : {}),
...("approval" in server ? { approval: server.approval } : {})If a server part has state: "output-error" and a leftover output field (e.g., from a partial output before the error), both output and errorText would be overlaid onto the client part. The AI SDK might interpret the presence of output as a valid result despite the output-error state. This depends on whether the SDK checks state or output first. If the server-side tool state is always clean (no leftover output on output-error), this is fine.
Summary
The PR is solid. The five original bugs are correctly fixed, the follow-up sweep caught real additional instances of the same class, and test coverage is strong. The items above are mostly "hardening the hardening" — none are likely to cause production incidents, but items 1, 3, and 9 are low-effort improvements worth addressing before merge. Items 2 and 7 are worth confirming the invariants hold.
|
Review posted on PR #1623. Here's a summary of what I found: Overall verdict: The PR is production-worthy. The original five bugs are correctly fixed, the follow-up terminal-state sweep was thorough, and test coverage is strong. 10 findings, ranked by priority:
Items 1, 3, and 9 are low-effort improvements worth addressing before merge. Items 2 and 7 need invariant confirmation. |
- think: remove the now-dead message-deletion branch in _repairTranscriptForProvider (repair preserves every message, never deletes). - think: _normalizeToolInput now also parses a stringified-ARRAY input (`[...]`), not just objects. + test. - agents(reconciler): make the server-state overlay state-driven so only the field matching the terminal state is carried (a stray `output` on an output-error part can't ride along). + test. - ai-chat: document why _chatRecoveryContinue's conversation_changed skip does NOT split assistant-leaf vs user-leaf like Think (no submission layer to protect) — guards against a future regression if submissions are added. Co-authored-by: Cursor <cursoragent@cursor.com>
Deep review #2 — dispositionTriaged all 10 items against HEAD. Landed the clear wins in Landed (
|
Summary
This PR hardens Think (and, where the machinery is shared, AIChatAgent and the agents Session memory / sub-agent layer) against the failure modes a customer hit while running long, tool-heavy agent turns through repeated
wrangler deploychurn. A mid-turn deploy resets the Durable Object ("reset because its code was updated"), and under rapid redeploys a single turn can be interrupted several times in a row. The bugs below combined to make turns that actually completed look failed, make completed tool calls re-run, make interrupted tool calls vanish, make malformed tool inputs 400 forever, make auto-compaction silently no-op on tool-heavy histories, and make sub-agent interruptions look like terminal failures.All fixes are covered by unit tests plus new e2e/real-deploy churn harnesses (
examples/deploy-churn, Think e2etool-rollback+task-amplification).Changesets included:
recovery-submission-status-under-churn.md—@cloudflare/think+@cloudflare/ai-chat(patch)preserve-interrupted-tool-calls.md—@cloudflare/think(patch)compaction-tokencounter-drives-boundary.md—agents(patch, Auto-compaction trigger fires, but compaction doesn't run due to internal token counting #1593)agent-tool-structured-failure.md—agents(minor)1. Recovery falsely erroring a completed turn under repeated mid-turn deploys
When several deploys interrupt one turn, recovery runs a chain of continuations. Three bugs combined to leave the durable submission in
erroreven though every step completed:recoveredRequestId) was derived from each continuation's own fresh requestId, so chained continuations dropped it; the continuation that finally finished the turn could no longer mark the submissioncompleted.conversation_changedguard because the leaf had advanced via recovery's own forward progress (a new assistant message), not a new user turn, and overwrote the still-running submission toerror.stable_timeout— a timeout while waiting for the isolate to settle (common with a deploy in flight) failed the turn terminally on the first attempt.Fix: submission ownership is keyed off the stable recovery root and threaded through the entire continuation chain; a superseded continuation skips benignly (only a genuinely newer user turn marks the submission
skipped, nevererror); and a stable-state timeout reschedules within themaxAttemptsbudget instead of abandoning.@cloudflare/ai-chatshares the recovery machinery but has no durable-submission layer, so it gets thestable_timeoutreschedule fix only.Commits:
ff8a4340,d59a7287.2. Interrupted tool calls disappeared and were silently re-run
Transcript repair previously removed a tool call with no recorded output (interrupted mid-execution by a deploy, or an
ask_user-style tool answered by the user's next message) before the next turn. That made the call vanish from the broadcast transcript and let the model re-run it — duplicating non-idempotent side effects.Fix: the orphan is now flipped to
state: "output-error"with an explanatory message, so the user-visible record survives, the model sees the tool errored instead of re-running it blind, and the provider still receives a valid tool-result (noAI_MissingToolResultsError). As a last-line backstop,convertToModelMessagesis now called withignoreIncompleteToolCalls: true.Commits:
aad63224,bfec18c2,5ca0e378.3. Malformed tool inputs 400 the provider forever
Repair parsed stringified-JSON tool
inputs, but a tool call with a missing ornullinputwas left unrepaired — Anthropic rejects atool_useblock whoseinputis absent, so the turn 400s on every retry.Fix: a new
_normalizeToolInputhelper parses stringified JSON and defaults a missing/nullinput to{}, applied on both the orphan-healing and settled-part paths.Commit:
5c57ac52.4. Auto-compaction fired every turn but silently no-op'd on tool-heavy histories (#1593)
Session.compactAfter(threshold, { tokenCounter })fired theonCompactioncallback, but the bundledcreateCompactFunctionboundary walk still used the Workers-safechars/4heuristic. On tool-heavy histories that under-counts ~4–5×, so the configured tail budget covered the entire history,compressEnd <= compressStart, and the function returnednull— compaction fired every turn but never shortened anything.Fix: the Session passes its counter to the compaction function via a new
CompactContextargument;createCompactFunctionuses it for the tail-budget walk when no explicitCompactOptions.tokenCounterwas given. A singletokenCounteroncompactAfter()now drives both "should we compact?" and "what should we compact?". A trigger that still returnsnulllogs a one-time warning (re-armed on the next successful compaction).Commits:
ce06564e,52e69855.5. Sub-agent interruptions looked like terminal failures
agentTool()collapsed every non-completed sub-agent run to an opaque{ ok: false, error: string }. A child reset/superseded by a deploy or parent recovery (interrupted) was indistinguishable from a genuine failure or an intentional cancellation, so the parent model would often parrot the interruption text back to the user as final.Fix: failures now return a structured
AgentToolFailure { ok: false, status, error, retryable }.interrupted→retryable: true(and surfaces the interruption reason);abortedanderror→retryable: false. Backward compatible for consumers readingok/error;AgentToolFailureis exported fromagentsso an orchestration harness (or a parent prompt convention) can re-dispatch an interrupted sub-agent instead of reporting it as final.Commit:
1f62e9ff.6. Test harnesses (no shipped behavior)
examples/deploy-churn— a realwrangler deploychurn harness (scripts/deploy-rollback.ts).packages/think/src/e2e-tests/tool-rollback.test.ts— settled-tool durability under recovery.packages/think/src/e2e-tests/task-amplification.test.ts— proves sub-agent (task) re-runs stay bounded under churn (no parent↔child budget amplification).think-session.test.ts,message-reconciliation.test.ts(orphan-preserved + stringified and missing input),agent-tools-failure.test.ts(the new failure envelope),durable-chat-recovery.test.ts,session.test.ts.Commits:
1fc27c2e,ce07fccc.7. Silent-hang watchdog for the streaming read loop
A model stream that parked without ever throwing (no chunk, no error, no
done) left the chat read loop waiting forever — an infinite spinner with no terminal state. There was no detection for a silently hung turn (only the recovery-pathstable_timeout, which guards recovery scheduling, not a live stream).Fix: opt-in
chatStreamStallTimeoutMs(default0= off). If no UI-message-stream chunk arrives within the window, the watchdog aborts the turn so the loop exits with a terminal stream error (routed throughonChatErrorwithstage: "stream") and emits a newchat:stream:stalledobservability event. Applies to both the WebSocket turn loop and thechat()/ sub-agent callback loop. It measures inter-chunk inactivity (which includes server-side tool execution), so it must be set above the slowest expected model TTFT and tool latency — hence opt-in, not a surprising default.Commit:
2d5f9f2d. Changeset:think-stream-stall-watchdog.md.Test plan
npm run check(sherif + export checks + oxfmt + oxlint + typecheck) — all 91 projects typecheck, lint + format cleanthinkmessage-reconciliation (5/5),think/ai-chatagent-tools reconciliation (14/14, 19/19),agentsagentTool envelope (4/4),agentssession (74/74)npx nx affected -t test(full affected suite in CI)tool-rollback,task-amplification,chat-recoveryexamples/deploy-churnreal-deploy churn — turn endscompleted, no duplicate tool execution, no disappearing tool callsOut of scope (separate follow-ups)
These remain genuine gaps that no engine owns yet — intentionally not in this PR:
withCachedPromptis opt-in and the cached prefix freezes untilrefreshSystemPrompt(); no AnthropiccacheControlbreakpoints in core).Silent mid-stream hang watchdog— addressed in this PR (see section 7).