Skip to content

fix(think,ai-chat): wall-clock-keyed-to-progress recovery budget + alarm debounce (#1637)#1638

Merged
threepointone merged 6 commits into
mainfrom
feat/recovery-wall-clock-budget
Jun 1, 2026
Merged

fix(think,ai-chat): wall-clock-keyed-to-progress recovery budget + alarm debounce (#1637)#1638
threepointone merged 6 commits into
mainfrom
feat/recovery-wall-clock-budget

Conversation

@threepointone

Copy link
Copy Markdown
Contributor

Closes #1637.

Stack / base

Built on the compaction-immune progress counter from #1633 (#1628), so this PR's base is fix/chat-recovery-progress-monotonic, not main — GitHub will retarget to main once #1633 merges. It's the structural follow-up in the #1631 recovery hardening series.

Problem

After #1623's bounded recovery, the budget is capped primarily by attempt count (attempt > maxAttempts) plus a 15-min incident-age ceiling. Under continuous deploys the attempt count is the wrong primary bound, and a healthy in-flight turn gets sealed while still advancing.

Producer-side evidence (customer session): one prompt → 23 successful LLM calls / 16.8k output tokens / ~6 min, 0/23 errored, 0 MB (not OOM) — yet recovery exhausted and sealed. Why:

  • One deploy ≠ one alarm. A rollout takes ~11–22s and the socket drops/reconnects several times — ~15 disconnects from ~10 deploys; 4 disconnects in 37s when deploys bunched. Each fires a recovery alarm, so the counter inflates far faster than the real interruption rate.
  • Retries aren't independent. Each eviction forces a cold re-run with growing context (no prompt cache) → each attempt is slower and less likely to finish a unit of work before the next deploy. So "raise the cap" buys little.

Change

Budget is now wall-clock-keyed to forward progress, attempt cap demoted to a secondary backstop:

  1. Primary — 5-min no-progress window. lastProgressAt resets on every progress-bearing attempt; exhaust only when now - lastProgressAt > 5min. A turn that keeps producing content survives churn indefinitely; a stuck turn seals within 5 min.
  2. Alarm debounce (30s). Alarms bunched within the window (a rollout's reconnect storm) collapse into one attempt.
  3. Attempt cap → high secondary backstop (default 6 → 10; resets on progress) — only catches a pathological tight alarm-loop.
  4. 15-min absolute incident-age ceiling kept (non-resetting final stop).
  5. Applies to the TaskSubAgent/sub-agent recovery path (extends Think).
  6. No new public config — internal constants + the existing maxAttempts.

Progress signal moved to production time (required, not cosmetic)

The no-progress window only works if "progress" means new content was produced. The previous bump fired at persist time — which also fires on a client reconnect (_handleStreamResumeAck_persistOrphanedStream) and on re-persisting unchanged content. Either would reset lastProgressAt without real progress, so a reconnecting-but-stuck turn would dodge the timeout forever. The bump now happens at production time:

  • think: on each durable flush in _storeChunkDurably (_storeChunkDurably is now async).
  • ai-chat (no _storeChunkDurably): on production milestones (text-start / reasoning-start / tool-input-available / tool-output-*) in the single chunk chokepoint _storeStreamChunk.

Both are growth-based and immune to reconnects/recovery re-persists. Builds on #1633's durable, compaction-immune counter.

Tests

  • New per-package tests: no-progress window seals below the cap, debounce collapses a reconnect storm into one attempt, and reconnect-immunity (streaming advances progress; an orphan re-persist does not).
  • Existing budget/flow tests updated for the new semantics (rapid successive triggers now correctly debounce; a nowMs test-clock injection + an ageIncidentForTest harness make timing deterministic). The default maxAttempts assertion updated 6 → 10.

Verification

  • tsc clean (think + ai-chat); oxlint clean; oxfmt applied.
  • Full suites green: think 145, ai-chat 479.

Test plan

  • CI green
  • Validate against examples/deploy-churn + the customer's chaos harness (the acceptance test: a healthy multi-tool turn survives 90s-ish churn; a genuinely stuck turn seals at ~5 min)
  • Sanity-check defaults don't regress a simple (no-tool) chat agent
  • Review the debounce window (30s) / no-progress window (5 min) / cap (10) values

Made with Cursor

@changeset-bot

changeset-bot Bot commented Jun 1, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b8092ef

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

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

@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@1638

@cloudflare/ai-chat

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

@cloudflare/codemode

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

hono-agents

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

@cloudflare/shell

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

@cloudflare/think

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

@cloudflare/voice

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

@cloudflare/worker-bundler

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

commit: b8092ef

Base automatically changed from fix/chat-recovery-progress-monotonic to main June 1, 2026 01:54
threepointone and others added 6 commits June 1, 2026 02:58
…unce (#1637)

Recovery budget no longer keyed primarily on raw attempt count (wrong under
deploy churn — one rollout fires several alarms). Now:

- PRIMARY: no-progress wall clock (5 min) keyed to `lastProgressAt`, which
  resets on every progress-bearing attempt — a turn that keeps producing
  content survives churn indefinitely; a stuck turn seals within 5 min.
- DEBOUNCE: alarms within 30s collapse into one attempt (a rollout's reconnect
  storm is one logical interruption, not N).
- SECONDARY: attempt cap raised to 10 (default) as a high backstop; resets on
  progress.
- ABSOLUTE: existing 15-min incident-age ceiling kept as the non-resetting
  final stop.

Adds `lastProgressAt` to the incident, a `nowMs` test-clock injection, and an
`ageIncidentForTest` harness; reworks the budget tests for debounce and adds
no-progress-window + debounce tests. think-side only — ai-chat mirror and the
production-time (reconnect-immune) progress signal are the next commits.

Co-authored-by: Cursor <cursoragent@cursor.com>
)

Same budget redesign as the think commit: lastProgressAt + 5-min no-progress
window (primary) + 30s alarm debounce + secondary cap raised to 10 + the
existing 15-min absolute ceiling, plus nowMs test-clock injection and an
ageIncidentForTest harness. Budget tests reworked for debounce; no-progress and
debounce tests added. Full ai-chat suite green (478).

Co-authored-by: Cursor <cursoragent@cursor.com>
…onnect-immune) (#1637)

The no-progress window needs a growth-based, reconnect-immune progress signal:
the persist-time bump fired on client reconnects (resume-ack -> _persistOrphanedStream)
and on re-persisting unchanged content, so a reconnecting-but-stuck turn would
reset `lastProgressAt` forever and dodge the timeout.

Move the bump to `_storeChunkDurably`'s flush (production time): it fires only
on genuinely new durable content and never on reconnects/recovery re-persists.
`_storeChunkDurably` is now async (awaited in the stream loop; per-flush, not
per-chunk). Adds a reconnect-immunity test (flush advances progress; an orphan
re-persist does not). ai-chat's equivalent (its different streaming model) is
the next commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ct-immune) (#1637)

ai-chat equivalent of the think production-signal commit. Its streaming model has
no _storeChunkDurably, so the bump goes on production milestones in the single
chunk chokepoint `_storeStreamChunk` (text-start / reasoning-start /
tool-input-available / tool-output-available|error|denied) via
`_maybeBumpRecoveryProgress` — fires only on genuinely new streamed content,
never on reconnects/recovery re-persists. `_storeStreamChunk` (and the plaintext
`_broadcastTextEvent`) are now async; removed the persist-time bump from
`_persistOrphanedStream`. Adds a reconnect-immunity test. Full ai-chat suite
green (479).

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…ityForTest

The progress-bump production-time move made _storeChunkDurably async
(Promise<void>), but probeToolResultDurabilityForTest still cast it as
returning void, so its store() calls discarded the promise and let
_bumpChatRecoveryProgress' storage get/put float as a fire-and-forget
rejection. Type the cast as Promise<void> and await all store() calls so
storage writes settle deterministically (matches the sibling
probeProgressReconnectImmunityForTest helper).

Co-authored-by: Cursor <cursoragent@cursor.com>
@threepointone
threepointone force-pushed the feat/recovery-wall-clock-budget branch from 76205cb to b8092ef Compare June 1, 2026 02:01
@threepointone
threepointone merged commit b6c8dea into main Jun 1, 2026
4 checks passed
@threepointone
threepointone deleted the feat/recovery-wall-clock-budget branch June 1, 2026 02:07
@github-actions github-actions Bot mentioned this pull request Jun 1, 2026
threepointone added a commit that referenced this pull request Jun 1, 2026
…en up (#1631)

Two paths could throw away a partial assistant message holding completed,
often non-idempotent tool results — the most valuable, least-reproducible
state in a turn:

1. Framework budget exhaustion sealed the turn (terminal status + banner)
   BEFORE the orphaned stream was persisted, so settled tool results were
   discarded and re-run on the next message. Exhaustion now persists the
   settled partial first, reusing the normal path's gating so it can't
   duplicate an already-saved partial. (This now also covers N1/#1638's
   wall-clock and no-progress exhaustion paths, not just the attempt cap.)

2. A subclass onChatRecovery returning { persist: false } silently dropped
   the settled partial. Settled work is now NEVER dropped: { persist: false }
   only suppresses persistence of a partial that has nothing settled to lose;
   a partial carrying settled tool results is persisted regardless. An app can
   no longer accidentally discard completed work, and never needs
   { persist: true } just to stay safe. A safe default beats a warning about
   an unsafe one (R1).

Adds _shouldPersistOrphanedPartial / _partialHasSettledToolResults helpers.
Applied identically to @cloudflare/think and @cloudflare/ai-chat.

Rebased onto main (dropping the already-merged #1633 commit) and adapted the
exhaustion test to N1/#1638's alarm-debounce (age the seeded incident past the
debounce window so the wake counts as a genuine attempt and exhausts).

Co-authored-by: Cursor <cursoragent@cursor.com>
threepointone added a commit that referenced this pull request Jun 1, 2026
…en up (#1631)

Two paths could throw away a partial assistant message holding completed,
often non-idempotent tool results — the most valuable, least-reproducible
state in a turn:

1. Framework budget exhaustion sealed the turn (terminal status + banner)
   BEFORE the orphaned stream was persisted, so settled tool results were
   discarded and re-run on the next message. Exhaustion now persists the
   settled partial first, reusing the normal path's gating so it can't
   duplicate an already-saved partial. (This now also covers N1/#1638's
   wall-clock and no-progress exhaustion paths, not just the attempt cap.)

2. A subclass onChatRecovery returning { persist: false } silently dropped
   the settled partial. Settled work is now NEVER dropped: { persist: false }
   only suppresses persistence of a partial that has nothing settled to lose;
   a partial carrying settled tool results is persisted regardless. An app can
   no longer accidentally discard completed work, and never needs
   { persist: true } just to stay safe. A safe default beats a warning about
   an unsafe one (R1).

Adds _shouldPersistOrphanedPartial / _partialHasSettledToolResults helpers.
Applied identically to @cloudflare/think and @cloudflare/ai-chat.

Tests:
- Unit (think + ai-chat): exhaustion preserves a settled TOOL RESULT (not just
  text); { persist: false } never drops settled tool results, and is honored
  for a text-only partial. (Exhaustion test aged past N1/#1638's alarm-debounce
  so the wake counts as a genuine attempt and actually exhausts.)
- E2E (think, real SIGKILL): a recordStep loop whose onChatRecovery returns
  { persist: false, continue: false } is killed mid-turn; after recovery the
  settled tool results produced before the kill are still in the durable
  transcript (R1) and the turn does not continue. (ThinkPersistFalseE2EAgent.)

NOTE on coverage: the EXHAUSTION-with-prior-settled-work path stays unit-tested
(not e2e) on purpose — under N1's budget, settling a tool result IS forward
progress that resets the budget and prevents exhaustion, so that scenario can't
be forced deterministically under real churn.

Rebased onto main (dropping the already-merged #1633 commit).

Co-authored-by: Cursor <cursoragent@cursor.com>
threepointone added a commit that referenced this pull request Jun 1, 2026
…en up (#1631) (#1634)

Two paths could throw away a partial assistant message holding completed,
often non-idempotent tool results — the most valuable, least-reproducible
state in a turn:

1. Framework budget exhaustion sealed the turn (terminal status + banner)
   BEFORE the orphaned stream was persisted, so settled tool results were
   discarded and re-run on the next message. Exhaustion now persists the
   settled partial first, reusing the normal path's gating so it can't
   duplicate an already-saved partial. (This now also covers N1/#1638's
   wall-clock and no-progress exhaustion paths, not just the attempt cap.)

2. A subclass onChatRecovery returning { persist: false } silently dropped
   the settled partial. Settled work is now NEVER dropped: { persist: false }
   only suppresses persistence of a partial that has nothing settled to lose;
   a partial carrying settled tool results is persisted regardless. An app can
   no longer accidentally discard completed work, and never needs
   { persist: true } just to stay safe. A safe default beats a warning about
   an unsafe one (R1).

Adds _shouldPersistOrphanedPartial / _partialHasSettledToolResults helpers.
Applied identically to @cloudflare/think and @cloudflare/ai-chat.

Tests:
- Unit (think + ai-chat): exhaustion preserves a settled TOOL RESULT (not just
  text); { persist: false } never drops settled tool results, and is honored
  for a text-only partial. (Exhaustion test aged past N1/#1638's alarm-debounce
  so the wake counts as a genuine attempt and actually exhausts.)
- E2E (think, real SIGKILL): a recordStep loop whose onChatRecovery returns
  { persist: false, continue: false } is killed mid-turn; after recovery the
  settled tool results produced before the kill are still in the durable
  transcript (R1) and the turn does not continue. (ThinkPersistFalseE2EAgent.)

NOTE on coverage: the EXHAUSTION-with-prior-settled-work path stays unit-tested
(not e2e) on purpose — under N1's budget, settling a tool result IS forward
progress that resets the budget and prevents exhaustion, so that scenario can't
be forced deterministically under real churn.

Rebased onto main (dropping the already-merged #1633 commit).

Co-authored-by: Cursor <cursoragent@cursor.com>
threepointone added a commit that referenced this pull request Jun 1, 2026
…sted payload (#1631)

Lets products build a terminal-state policy without re-deriving anything:

- ChatRecoveryContext (onChatRecovery) gains recoveryRootRequestId — the stable
  request id for the whole continuation chain, the right key for per-incident
  budget tracking / fresh-incident detection (no re-deriving from message IDs).
- ChatRecoveryExhaustedContext (onExhausted) gains recoveryRootRequestId,
  terminalMessage (exact user-facing text), partialText/partialParts (what the
  turn produced before it was given up on), and streamId/createdAt — enough to
  render/persist a terminal banner AND emit correlated terminal telemetry
  (msSinceTurnStart, stream correlation) directly.

streamId + createdAt were added after verifying the payload against the actual
consumer (g3's _emitExhaustedRecovery): it reads both from the recovery context
for telemetry, and they already exist on ChatRecoveryContext (the Pick source),
so adding them to the exhausted context is additive and unblocks re-homing the
exhaustion handler onto onExhausted with zero re-derivation (D4).

Shared types in `agents`; wired through think + ai-chat (_exhaustChatRecovery
now receives streamId + createdAt). Test agents capture the exhausted context;
tests assert both contexts (incl. streamId + createdAt) in both packages.

Rebased onto main (dropping the merged #1633/#1634/#1635 commits); adapted the
exhausted-ctx test to N1/#1638's alarm-debounce and gave the think harness an
explicit return shape (the context's MessagePart[] over-instantiates the RPC
stub type).

Co-authored-by: Cursor <cursoragent@cursor.com>
threepointone added a commit that referenced this pull request Jun 1, 2026
…sted payload (#1631) (#1636)

Lets products build a terminal-state policy without re-deriving anything:

- ChatRecoveryContext (onChatRecovery) gains recoveryRootRequestId — the stable
  request id for the whole continuation chain, the right key for per-incident
  budget tracking / fresh-incident detection (no re-deriving from message IDs).
- ChatRecoveryExhaustedContext (onExhausted) gains recoveryRootRequestId,
  terminalMessage (exact user-facing text), partialText/partialParts (what the
  turn produced before it was given up on), and streamId/createdAt — enough to
  render/persist a terminal banner AND emit correlated terminal telemetry
  (msSinceTurnStart, stream correlation) directly.

streamId + createdAt were added after verifying the payload against the actual
consumer (g3's _emitExhaustedRecovery): it reads both from the recovery context
for telemetry, and they already exist on ChatRecoveryContext (the Pick source),
so adding them to the exhausted context is additive and unblocks re-homing the
exhaustion handler onto onExhausted with zero re-derivation (D4).

Shared types in `agents`; wired through think + ai-chat (_exhaustChatRecovery
now receives streamId + createdAt). Test agents capture the exhausted context;
tests assert both contexts (incl. streamId + createdAt) in both packages.

Rebased onto main (dropping the merged #1633/#1634/#1635 commits); adapted the
exhausted-ctx test to N1/#1638's alarm-debounce and gave the think harness an
explicit return shape (the context's MessagePart[] over-instantiates the RPC
stub type).

Co-authored-by: Cursor <cursoragent@cursor.com>
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.

recovery budget should be wall-clock-keyed-to-progress + alarm-debounced, not raw attempt count (deploy churn)

1 participant