Skip to content

Harden Think and AIChat recovery foundations#1611

Merged
threepointone merged 16 commits into
mainfrom
think-recovery-foundation
May 29, 2026
Merged

Harden Think and AIChat recovery foundations#1611
threepointone merged 16 commits into
mainfrom
think-recovery-foundation

Conversation

@threepointone

@threepointone threepointone commented May 29, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR builds a recovery foundation for production chat reliability across @cloudflare/think, @cloudflare/ai-chat, generic Agent fibers, and retained agent-tool runs.

The work started from production reports that Think recovery was unreliable under Durable Object restarts and process churn. Instead of patching individual symptoms, this branch first adds characterization and e2e coverage for the failure modes, then introduces bounded recovery state machines, durable incident tracking, observability, transcript repair, and documentation that explains the difference between client stream resumption and Durable Object eviction recovery.

Problem

Production reports pointed at several related failure modes:

  • Think chat recovery could appear to succeed in simple cases but become unreliable under repeated restart churn.
  • Interrupted turns had too little lifecycle state, making it difficult to tell whether recovery was fresh, retried, exhausted, skipped, or stuck.
  • Request failures after message persistence were not surfaced consistently through onChatError or observability.
  • Parent agent-tool runs could stay in active states when the parent restarted around child execution.
  • Persisted transcript damage, especially orphaned tool calls/results, could poison the next provider call.
  • Session compaction's default heuristic could under-count tool-heavy histories and keep too much tail context.
  • Existing docs blurred client reconnect stream resumption with Durable Object eviction recovery.

What Changed

1. Characterization and e2e coverage

Adds focused coverage before the fixes:

  • Think e2e restart churn around interrupted turns.
  • Think e2e request failure after messages are persisted.
  • Think e2e stale parent agent-tool run reconciliation.
  • AIChatAgent e2e restart churn coverage.
  • Session compaction regression coverage for tool-heavy histories.
  • Think transcript poisoning regression coverage for persisted orphan tool calls.

These tests exercise the branch through realistic wrangler dev restarts and direct recovery status inspection instead of only unit-level mocks.

2. Recovery observability taxonomy

Adds granular recovery lifecycle events and channels:

  • fiber:run:* and fiber:recovery:*
  • chat:request:failed
  • chat:recovery:*
  • chat:transcript:repaired
  • agent_tool:recovery:*

Also adds dedicated diagnostics channels:

  • agents:chat
  • agents:transcript
  • agents:fiber
  • agents:agent_tool

The typed subscribe() helper exposes agentTool as the public camelCase key while raw diagnostics channel subscribers use agents:agent_tool.

3. Bounded generic fiber recovery

Generic runFiber() recovery now has clearer lifecycle behavior:

  • Emits started/completed/failed/interrupted events for fiber runs.
  • Adds recoveryReason: "interrupted" to FiberRecoveryContext.
  • Bounds recovery hook execution with fiberRecoveryHookTimeoutMs.
  • Bounds recovery scans with fiberRecoveryScanDeadlineMs.
  • Preserves user fiber rows if onFiberRecovered() throws, allowing a later scan to retry the recovery hook.
  • Marks managed framework fibers as error if framework recovery throws, rather than silently deleting failed internal recovery state.

This keeps application recovery hooks retryable while letting framework-managed fibers fail terminally when the framework knows the row cannot safely be recovered.

4. Bounded chat recovery incidents

Both Think and AIChatAgent now track durable chat recovery incidents with:

  • Stable incidentId
  • attempt
  • maxAttempts
  • recoveryKind (retry or continue)
  • status transitions for scheduled, completed, skipped, failed, and exhausted recovery
  • configurable chatRecovery = { maxAttempts, stableTimeoutMs, terminalMessage, onExhausted }

The recovery state machine now distinguishes:

  • retrying an unanswered user turn when no assistant chunks were persisted
  • continuing a partial assistant turn when stream chunks exist
  • stale or invalid recoveries that should be skipped
  • repeated failures that should exhaust and produce a terminal UX instead of wedging the turn queue

Think keeps chatRecovery enabled by default. Plain AIChatAgent remains opt-in with chatRecovery = false by default to preserve existing behavior.

5. More robust recovery identity and continuation handling

The branch hardens edge cases found during review:

  • Adds recoveryRootRequestId to chat fiber snapshots so retries and continuations remain tied to the original interrupted request.
  • Uses stable incident IDs across repeated recovery attempts for the same interrupted turn.
  • Recomputes continuation targets after orphaned stream persistence to avoid stale conversation_changed skips.
  • Restores active recovery root request IDs in finally blocks so nested or failed scheduled recovery paths do not leak state.
  • Treats stable-state timeout as a failed recovery incident rather than a benign skip.
  • Makes completed-stream orphan persistence idempotent so repeated restarts do not duplicate recovered assistant messages.

6. onChatError request failure context

Think's onChatError now accepts an optional context object:

onChatError(error, ctx)

The context includes:

  • requestId
  • stage
  • messagesPersisted

Think now emits chat:request:failed for request and stream failures, giving users and operators a consistent hook for distinguishing pre-persist failures from failures after the user message is already durable.

7. Bounded parent agent-tool recovery

Parent agent-tool recovery now has a total scan deadline and emits detailed recovery events:

  • begin
  • per-row outcome
  • deadline
  • complete
  • failed

If retained child runs cannot be reconciled in time, they are marked interrupted instead of leaving parent tool calls in active states indefinitely.

8. Transcript repair before provider calls

Think now repairs provider-breaking transcript damage before calling the model:

  • removes orphaned tool calls
  • removes orphaned tool results
  • repairs malformed persisted tool input where possible
  • emits chat:transcript:repaired
  • broadcasts repaired session state to clients

The repair pass is intentionally narrow: it avoids deleting otherwise-valid empty or step-start assistant messages and only drops messages made empty by orphan tool-call removal.

9. Compaction token counter support

createCompactFunction() now accepts a custom tokenCounter so applications with model-reported usage or tokenizer access can make better tail-budget decisions for tool-heavy histories.

CompactTokenCounter is exported from the memory utilities package.

10. Docs, README, examples, and design notes

Updates user-facing and contributor-facing surfaces so the new recovery model is discoverable:

  • Clarifies client resumable streaming vs Durable Object eviction recovery.
  • Documents AIChatAgent opt-in recovery and Think's default recovery.
  • Adds Think onChatRecovery guidance.
  • Adds observability cross-links for chat, transcript, fiber, and agent-tool recovery.
  • Updates onChatError(error, ctx) docs.
  • Fixes stale durable execution wording around recovery row deletion.
  • Documents compaction tokenCounter usage.
  • Adds agent-tool parent recovery guidance.
  • Updates example READMEs for recovery testing expectations.
  • Marks older Think design notes as historical where appropriate.

Commit Stack

The branch is intentionally split for review:

  1. test: cover Think recovery failure surfaces
  2. test: cover AIChatAgent recovery failure surfaces
  3. feat: add recovery observability channels
  4. feat: bound and observe fiber recovery
  5. feat: add bounded chat recovery incidents
  6. fix: surface Think chat request failures
  7. fix: bound agent-tool recovery scans
  8. fix: repair poisoned chat history before model calls
  9. chore: add recovery foundation changeset
  10. fix: export chat recovery lifecycle types
  11. fix: harden recovery edge cases and docs
  12. docs: clarify recovery UX surfaces

API and Behavior Notes

  • Think.chatRecovery remains enabled by default, but can now be configured with an object.
  • AIChatAgent.chatRecovery remains disabled by default and is opt-in.
  • onChatRecovery(ctx) receives incident metadata: incidentId, attempt, maxAttempts, and recoveryKind.
  • onChatError(error, ctx) is backward-compatible for subclasses that only accept one parameter.
  • FiberRecoveryContext now includes recoveryReason.
  • User onFiberRecovered() failures preserve the fiber row for retry instead of deleting it.
  • Framework-managed internal fibers can transition to terminal failure when framework recovery throws.
  • Agent-tool reconciliation can mark stale unrecoverable rows interrupted instead of hanging.
  • Transcript repair may mutate persisted Think session messages when it detects provider-invalid tool-call structure.

Test Plan

Already run locally:

  • npm run format
  • npm run check

npm run check completed successfully, including:

  • sherif
  • export checks
  • oxfmt --check .
  • oxlint .
  • typecheck across all 89 projects

Focused tests and e2e suites were also run during the implementation/review cycle for the touched areas, including Think recovery, AIChatAgent recovery, observability routing, fiber recovery, transcript repair, and session compaction regressions.

Note: sherif still reports the repository's existing warnings for example folders without package.json, but the command exits successfully.

Review Guide

Suggested review order:

  1. Start with the characterization tests to understand the failures this branch is protecting against.
  2. Review observability event names and channel routing before the runtime changes.
  3. Review generic fiber recovery behavior in packages/agents/src/index.ts.
  4. Review shared chat recovery lifecycle types and the Think / AIChatAgent recovery state machines together.
  5. Review agent-tool reconciliation separately from chat recovery; it is related but has a different parent/child failure model.
  6. Review transcript repair and compaction token counting as targeted fixes for provider-call and context-budget correctness.
  7. Review docs last to ensure the public model matches the implementation.

Risk and Rollout

The main runtime risk is recovery behavior changing from implicit/unbounded attempts to explicit bounded incident tracking. That is intentional: repeated failures now become observable and terminal instead of leaving users with a stuck turn.

The highest-risk code paths are:

  • scheduled chat recovery retry/continue callbacks
  • Think transcript repair before provider conversion
  • parent agent-tool run reconciliation after restarts
  • generic fiber recovery row retention when user hooks throw

The PR includes focused tests for these areas and e2e restart coverage for both Think and AIChatAgent.

Made with Cursor


Open in Devin Review

@changeset-bot

changeset-bot Bot commented May 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 93d3d55

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

This PR includes changesets to release 3 packages
Name Type
agents Minor
@cloudflare/ai-chat Minor
@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

@devin-ai-integration devin-ai-integration 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.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 5 additional findings.

Open in Devin Review

@pkg-pr-new

pkg-pr-new Bot commented May 29, 2026

Copy link
Copy Markdown

Open in StackBlitz

agents

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

@cloudflare/ai-chat

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

@cloudflare/codemode

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

hono-agents

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

@cloudflare/shell

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

@cloudflare/think

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

@cloudflare/voice

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

@cloudflare/worker-bundler

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

commit: 93d3d55

threepointone and others added 15 commits May 29, 2026 12:49
Add characterization coverage for the production recovery gaps before changing
runtime behavior. These tests capture the current failure surfaces so the
follow-up fixes can prove that they improve behavior rather than only reshaping
APIs.

The Think recovery e2e harness now covers repeated restart churn around an
interrupted turn, the post-persist/pre-turn request failure path, and parent
agent-tool recovery interruption after restart. The test worker persists
recovery observations across restarts and exposes test-only controls for forced
turn failures and retained agent-tool rows.

The focused regression tests also document two non-restart reliability issues:
poisoned transcripts with orphan tool calls fail later turns, and
createCompactFunction can skip summarization when tool-heavy histories are
under-counted by the heuristic tail budget.

This commit intentionally contains only tests and test harness changes. Runtime
recovery behavior is left unchanged for the subsequent fix commits.

Co-authored-by: Cursor <cursoragent@cursor.com>
Mirror the Think restart-churn characterization coverage in the AIChatAgent
recovery e2e harness before changing shared recovery behavior. Both chat
packages use the same underlying fiber recovery pattern, so the shared fix work
needs coverage that proves AIChatAgent receives the same protection rather than
only improving Think.

The e2e worker now persists recovery observations in Durable Object storage so
restart churn does not lose the evidence we need to assert on. The new e2e test
starts a slow recovered chat turn, repeatedly kills and restarts wrangler around
the interrupted fiber, and verifies recovery still fires and stale fiber rows are
cleaned up.

This commit is intentionally limited to characterization coverage. It excludes
Think-only concerns such as Session compaction and durable submissions, which do
not apply to AIChatAgent.

Co-authored-by: Cursor <cursoragent@cursor.com>
Introduce dedicated observability event types and diagnostics channels for the
recovery work that follows. Fiber recovery, chat recovery, transcript repair,
and agent-tool reconciliation now have stable event names instead of being mixed
into unrelated message or lifecycle streams.

The channel routing tests lock in the public names for the new channels and
ensure chat:transcript:* events land on the transcript channel while chat
recovery events stay on the chat channel.

Co-authored-by: Cursor <cursoragent@cursor.com>
Make the generic fiber substrate safer before adding chat-specific retry policy.
Recovered fibers now carry a recoveryReason, interrupted run rows emit fiber
recovery lifecycle events, and internal framework recovery hooks are bounded by
a default timeout so startup cannot wedge forever on a broken internal recovery
path.

Managed fibers whose recovery hook throws are now marked terminal error instead
of staying indefinitely interrupted with only an error message attached. Unmanaged
run rows continue to be pruned after recovery handling so the same broken stale
row does not re-trigger forever across boots.

This also exposes the agentTool observability channel under the camelCase
subscribe key while preserving the diagnostics channel name agents:agent_tool.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add shared chat recovery configuration and incident context so Think and
AIChatAgent recovery are bounded by framework-owned attempt state. Recovery
hooks now receive incidentId, attempt, maxAttempts, and recoveryKind, with
attempt === 1 representing the first handling of an incident.

Both chat runtimes persist recovery incidents in Durable Object storage, emit
chat recovery lifecycle events, use configurable stable-state timeouts, and stop
scheduling more recovery work once maxAttempts is exceeded. Exhaustion emits a
terminal chat recovery event and sends a user-visible terminal chat error frame;
Think also marks any matching running submission as error.

The default behavior is enabled for existing chatRecovery=true users with
maxAttempts 6 and stableTimeoutMs 10000, while custom policy can be supplied via
chatRecovery object configuration.

Co-authored-by: Cursor <cursoragent@cursor.com>
Extend Think's existing onChatError hook with optional stage context and route
post-persist request failures through it. The request path now emits
chat:request:failed with request id, stage, persistence state, and sanitized
error text before sending the terminal chat error frame.

This gives applications a server-side hook for failures that occur after user
messages have been accepted but before or during the model turn, without routing
those chat failures through the generic Agent onError hook.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add a total recovery deadline for parent-side agent-tool reconciliation so a
restart cannot spend unbounded time inspecting stale child rows. Per-child
inspection remains bounded, and any rows reached after the total deadline are
terminalized as interrupted with an explicit recovery-deadline error.

The recovery loop now emits structured agent_tool recovery lifecycle events for
begin, per-row outcome, deadline, completion, and unexpected failure, giving
operators visibility into exactly which child run was finalized and why.

Co-authored-by: Cursor <cursoragent@cursor.com>
Repair incomplete tool-call transcript shapes before Think converts UI messages
for provider calls. Persisted orphan tool calls are stripped from the active
Session history, clients are rebroadcast with the repaired transcript, and a
chat:transcript:repaired event records the repair counts and tool call ids.

Also let createCompactFunction use an optional tokenCounter for tail-budget
selection. Callers with tokenizer or model-reported accounting can avoid the
heuristic under-count that otherwise protects too much tool-heavy history and
skips summarization.

Co-authored-by: Cursor <cursoragent@cursor.com>
Record the public recovery API, observability, and behavior changes across the
published packages so the release notes explain the new default recovery bounds
and transcript/compaction fixes.

Co-authored-by: Cursor <cursoragent@cursor.com>
Expose the new chat recovery config and exhaustion context types from
agents/chat so downstream packages can typecheck against the shared lifecycle
surface. Update the Think test fixture to construct the enriched fiber recovery
context.

Co-authored-by: Cursor <cursoragent@cursor.com>
Stabilize chat recovery incident accounting across retry fibers, preserve failed internal recovery rows for later scans, and avoid stale continuation targets after orphan persistence. Tighten transcript repair behavior and document the new recovery and observability surfaces so users can configure and debug bounded recovery.

Co-authored-by: Cursor <cursoragent@cursor.com>
Document the distinction between stream resumption and durable chat recovery so users can configure and observe recovery behavior correctly.

Co-authored-by: Cursor <cursoragent@cursor.com>
Track length-preserving part replacements so repaired tool inputs are written back before provider calls.

Co-authored-by: Cursor <cursoragent@cursor.com>
Align transcript repair observability with AI SDK v6 message parts by dropping the unused removedToolResults field.

Co-authored-by: Cursor <cursoragent@cursor.com>
Follow-up hardening from a full review of the recovery foundation. These
close edge cases where bounded recovery could still leak storage, exceed
its attempt budget, or fail to surface a stuck turn.

Chat recovery incidents (Think + AIChatAgent):

- Drop `recoveryKind` from the incident identity so a single interrupted
  turn that flips between `retry` (no chunks persisted) and `continue`
  (partial chunks exist) across restarts shares one attempt budget. The
  kind is still tracked as a mutable field on the incident record.
- Delete the incident record on `completed` (success) and add a TTL sweep
  (1h inactivity) on each new incident so durable storage no longer grows
  without bound. Exhausted/failed/skipped records are retained for
  inspection until they age out.
- Guard a throwing `onExhausted` hook so the terminal error frame (and
  Think's running-submission interruption) is always delivered.
- Wrap the post-begin recovery dispatch (onChatRecovery, orphan persist,
  scheduling) so any throw flips the incident to a terminal `failed`
  state and emits `chat:recovery:failed` instead of leaking in
  `attempting`.

Generic fiber recovery (Agent):

- Add `fiberRecoveryMaxAgeMs` (default 24h). A repeatedly-throwing
  unmanaged `onFiberRecovered()` row is still retried while fresh but is
  evicted with a `fiber:recovery:skipped` / `max_age_exceeded` event once
  it ages out, so a poison row cannot re-trigger forever across boots.
- Note that the fiber recovery hook timeout bounds the wait but does not
  cancel the underlying internal operation.

Observability:

- Replace the hand-coded `agentTool` subscribe special-case with a
  `CHANNEL_DIAGNOSTIC_NAME_OVERRIDES` lookup to prevent future drift
  between camelCase keys and snake_case diagnostics channel names.

Tests:

- Think + AIChatAgent: shared attempt budget across retry/continue flip,
  incident deletion on completion, stale incident sweep, `failed`
  transition when onChatRecovery throws, and terminal UX delivery when
  onExhausted throws.
- Agent fibers: a fresh throwing unmanaged row is retained (retryable),
  an aged throwing row is evicted with `max_age_exceeded`.
- Update existing incident-id assertions to the kind-less format.

All unit and e2e suites pass (Think, AIChatAgent, Agent fiber recovery,
observability routing).

Co-authored-by: Cursor <cursoragent@cursor.com>
@threepointone
threepointone force-pushed the think-recovery-foundation branch from d9343c2 to 0716c1c Compare May 29, 2026 12:28
Clarify that an `onFiberRecovered()` hook which always throws is retried
only until the row exceeds `fiberRecoveryMaxAgeMs` (default 24h), after
which it is discarded with a `fiber:recovery:skipped` / `max_age_exceeded`
event. Previously the bullet implied a thrown hook kept the row
indefinitely.

Co-authored-by: Cursor <cursoragent@cursor.com>
@threepointone
threepointone merged commit 02f9380 into main May 29, 2026
4 checks passed
@threepointone
threepointone deleted the think-recovery-foundation branch May 29, 2026 12:58
@github-actions github-actions Bot mentioned this pull request May 29, 2026
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.

1 participant