Archived point-in-time working record for rfc-chat-recovery-foundation.md. This log was kept inline in the RFC while the
chat-recovery-foundationbranch was in flight; it was moved here at branch finalization so the RFC freezes as a point-in-time decision record (per AGENTS.md).
Running record of completed steps (newest first). Each entry links the phase, the change, and the key review findings.
-
Convergence lock-in — server-only repair scope, cross-host conformance suite, stale-RFC sweep — Follow-up to the recovery-engine convergence: cements the shared seams and pays down the one edge the deep review found.
- Server-only repair scope (refinement of A.5). The whole-transcript guard
(
!hasPendingClientInteraction()) the lock-in PR added was correct but coarse: an abandoned client orphan buried in history would suppress repairing a fresh dead-server orphan at the leaf. Replaced with a per-part skip: the sharedrepairInterruptedToolPartsgained an optionalshouldRepair(part)predicate (defaulttrue= repair all, so Think is unchanged); ai-chat passesshouldRepair = !partAwaitsClientInteraction(part, clientResolvable)so it repairs dead server orphans anywhere while leaving any part still awaiting a client (input-availableclient tool /approval-requested) verbatim. New unit test for the skip; new ai-chat test for the buried-client + leaf-server case.agents+@cloudflare/ai-chat. - Cross-host recovery conformance suite. New
agents/chat/__tests__/recovery-conformance.test.tsruns the REAL shared primitives (repairInterruptedToolParts+ thepartAwaitsClientInteraction/clientResolvableToolNamespredicate) under both host wirings side by side over a canonical scenario table (dead server orphan, pending client orphan, mixed, approval-requested, approval-responded). It pins the subset relationship — identical for server orphans; intentional divergence for client orphans (ai-chat parks/skips, Think repairs-to-proceed) — and asserts the invariant "ai-chat never repairs more than Think." This is the convergence seam where the two hosts actually meet; a literal dual-driver e2e harness was rejected because Think's recovery test agent is structurally different (Session tree, no orphan-seeding hooks) and plumbing it would add fragile surface for no extra coverage the shared-primitive suite doesn't already give. Each host's own e2e suite (ai-chatdurable-chat-recovery+continue-last-turn; Thinkthink-session) continues to cover its side end to end. - Stale-RFC sweep. Corrected the §Cutover claim that the
ChatRecoveryIncidentshape / keys / id formula are "duplicated verbatim … not yet inagents/chat" — they have lived in sharedrecovery-incident.tssince the engine landed. (Matrix cells are left as the historical record per the doc's own note; the 2026-06 re-classification block remains the authoritative status.)
- Server-only repair scope (refinement of A.5). The whole-transcript guard
(
-
Recovery-engine convergence finish — ai-chat recovers interrupted server-tool calls like Think (LANDED), plus orphan-persist core dedup — Closed the last real behavior gap between the two AI-SDK chat hosts, restoring the ai-chat-as-subset-of-Think north star after an earlier review pass had drifted toward "keep the predicate split divergent."
- The gap (bucket 1, Think better). On an interrupted server tool (its
execute()died with the evicted isolate, leaving a deadinput-availableorphan nothing will ever resolve), Think recovers the turn but ai-chat gave up: Think excludes the dead orphan from its (client-only) stability predicate AND repairs the transcript before inference, soconvertToModelMessagesdoesn't 400 withAI_MissingToolResultsError; ai-chat used the broadhasPendingInteraction()for its recovery wait and had no repair pass, so it rescheduled until the budget was spent and terminalized viaonExhausted. - A.1 — shared
repairInterruptedToolPartsprimitive (behavior-neutral). Extracted Think's_repairToolTranscriptPartsinto a new@internalagents/chat/repair-transcript.ts(plus thetoolPartHasSettledResultterminal-state check), parameterized by an overridablerepairParthook and reusing the already-sharednormalizeToolInput. Preserves theapproval-respondedpassthrough (an approved server tool waiting for its continuation is NOT abandoned). Think delegates through its existingrepairInterruptedToolParthook — pure refactor, suites unchanged. 11 unit tests.agents+thinkpatches. - A.2 — ai-chat adopts repair. Added an overridable
AIChatAgent.repairInterruptedToolParthook (default: flip tooutput-error) and a private_repairInterruptedToolsBeforeTurn()that runs the shared primitive overthis.messagesbefore re-invokingonChatMessageand persists+broadcasts via the normalpersistMessageswrite path.@cloudflare/ai-chatminor. - A.3 — narrow the recovery predicate.
waitUntilStablegained an optionalpendingInteractionpredicate; the two recovery call sites (_chatRecoveryContinue/_chatRecoveryRetry— the only callers, both recovery, no live path) pass the narrowhasPendingClientInteraction()so a dead server-tool orphan no longer blocks stability. The broad default — and the documented semantics for app overrides — are unchanged. - Hard constraint honored. Repair only ever reshapes assistant tool
parts; user messages (and their
metadata.channel, re-resolved on wake) are structurally untouched — pinned with comments at the repair seam. - A.4 — e2e. New
durable-chat-recoverytest: a dead server-toolinput-availableorphan now recovers (repairs to errored, continues, incident completes) instead of waiting-then-onExhausted, contrasting the existing CLIENT-interaction park test. Think e2e unchanged. - A.5 — repair at all pre-inference chokepoints (deep-review follow-up).
A post-implementation deep review found the recovery-only repair left a
residual gap vs Think, which repairs in
_assembleModelMessagesbefore every inference: (1) a mixed client+server orphan parks for the client, then the client replay drives_fireAutoContinuation(which callsonChatMessagedirectly, notcontinueLastTurn), so the dead server orphan reachedconvertToModelMessagesunrepaired; and (2) agents withchatRecoverydisabled never hit a recovery callback at all. ai-chat can't repair "inside" inference (the app ownsconvertToModelMessages), so_repairInterruptedToolsBeforeTurn()is now invoked at all fouronChatMessagechokepoints — live submit (chatTurnBody), auto-continuation (autoContinuationBody),_runProgrammaticChatTurn(saveMessages/retry), andcontinueLastTurn— and the two now-redundant explicit recovery-callback calls were removed (those bodies self-repair). A guard (!hasPendingClientInteraction()) restricts repair to states where every unsettled tool part is a dead server orphan, so a tool the user may still answer is never clobbered; repair is a no-op (no write/broadcast) for a healthy transcript. Twocontinue-last-turntests added (repairs a dead server orphan on a directcontinueLastTurn; leaves a pending client tool untouched). Full ai-chat suite (612) green. - C2 — orphan-persist core dedup (behavior-neutral). Extracted the
genuinely-shared skeleton of both
_persistOrphanedStreambodies (the accumulate loop +getMessage → updateMessage(merge) XOR appendMessageupsert) into a new@internalagents/chat/orphan-persist.tspersistReconstructedOrphan(chunks, { store, fallbackId, prepare, merge })— exactly two hooks. The host-specific bits stay in the wrappers: flush (Think only), fallback id,prepare(Think_strippedForPersist+null skip; ai-chat_resolveOrphanTargetId),merge(Think replace; ai-chatreconcileOrphanPartial), and broadcast (Think after via_broadcastMessages; ai-chat insidepersistMessages).agentspatch; both hosts' recovery suites green. - C1 — already enforced (verify only). Both hosts annotate
_orphanStore(): OrphanPersistStore(ai-chat:1482,think:3182); compiler-enforced, nothing to implement. - D — auto-continuation lift (doc-only).
AutoContinuationController,hasIncompleteToolBatch, and_hasArmedContinuationare already shared; the remainder of each host's auto-continuation is coupled to its own streaming driver / turn loop (Think's submission-aware turn spine vs ai-chat'sTurnQueue), so there is no further host-agnostic algorithm to lift. Deferred Tier-3 follow-up closed as no-op. - B — finding #4 second-decode (deferred, tracked). Threading the
already-decoded
RecoveryPartialinto the orphan write still collides with the vocabulary-agnostic seam (no message id; the codec usesgetPartialStreamTextwhile the hosts reconstruct viaStreamAccumulator), for negligible payoff and no behavior drift. Left tracked; it falls out naturally if a future engine-owned orphan writer subsumes the host wrappers.
- The gap (bucket 1, Think better). On an interrupted server tool (its
-
Cross-RFC breadcrumb — Channels v1 persists channel id in turn metadata (docs, no code) — The Channels RFC shipped per-channel policy (instructions / tool-narrowing /
maxTurns) as a turn-scoped_activeChannelContext, and to survive recovery it persists the channel id on the user message metadata (metadata.channel).continueLastTurn/_chatRecoveryContinuere-resolve the channel from that stamp on wake and re-apply policy — there is deliberately no serializedTurnSpec.channelContext. Implication for the recovery-engine convergence: any unified turn-metadata / persistence model must preservemetadata.channel(and the re-resolve-on-wake step) so per-channel policy keeps applying after a recovered turn. Flagged here so convergence does not drop it. -
Phase 7 — chat-shared-layer.md: recovery-engine ownership (docs, no code) — Updated
design/chat-shared-layer.md(which predated the whole chat-recovery foundation) to document the shared recovery layer. Added arecovery-engine.tsmodule section:ChatRecoveryEngineownership of the wake lifecycle + its ordering invariants (incident → exhausted-persist-before-seal #1631 → guardedonChatRecovery→ persist-gate → complete → dispatch), the two host seams (ChatRecoveryAdapter+ per-wakeChatFiberWakeHooks),pi-recoveryas the non-AI-SDK forcing function, and the four orphan-persist seams ((a) sharedStreamAccumulator, (b) hostresolveOrphanTargetId, (c) sharedreconcileOrphanPartial, (d)SessionProvider-subset upsert) with the flat-vs-tree rationale. Also corrected stale content the refactor invalidated: theapplyChunkToPartsusers list and the "accumulator used vs not" section both claimed_persistOrphanedStreamusedapplyChunkToPartsdirectly (it goes throughStreamAccumulatornow), addedreconcileOrphanPartialto the module map + reconciler section, and added a History entry linking this RFC. Anchor/cross-refs verified. No code change. -
Phase 6 — orphan-persist e2e audit (no code) — Audited whether the SIGKILL + persistent-state e2e suites cover the just-landed (a)/(b)/(c)/(d) orphan-persist behavior, and re-ran the two ai-chat e2e files that drive the refactored
_persistOrphanedStreamthrough a real process crash:chat-recovery-outcomes2/2 (partial persisted as exactly one assistant message undercontinue:false; plain-textpersist:falsedropped) andchat-recovery3/3 (retry on empty partial, continue →assistantMessages === 1merge-to-one, restart churn). Both green post-refactor. Built the orphan-persist coverage map (see the Phase-6 audit subsection): (a)/(d) and the persist gate are covered both at the workers runtime level and via real-SIGKILL e2e; (b) #1691 distinctness and (c) tool-approval dedup are covered at the workers level (durable-chat-recovery.test.ts:1096/1152/1267)- the new
reconcileOrphanPartialunit tests. One documented, accepted gap: no real-SIGKILL e2e for the (c) tool-dedup path (the ai-chat e2e worker has no client-tool/approval agent); the dedup is a runtime-independent pure function already asserted at the workers + unit layers, and the alarm-wake it would add is exercised generically by the plain-text orphan e2e — so marginal value is low, harness cost high. Think e2e not re-run: its orphan path is unchanged and it doesn't call the new helper. Phase-6 exit criteria for the converged orphan-persist behavior otherwise met.
- the new
-
Phase 5 / Tier-2 — orphan-persist (b)/(c)/(d) consolidation: named seams + shared merge primitive (LANDED) — Factored ai-chat's
_persistOrphanedStreaminto the three RFC seams now that the Session-provider spike settled their shape. (b): extractedAIChatAgent._resolveOrphanTargetId(streamId, reconstructedId, fallbackId)— the #1691 stored-id / last-assistant-fallback policy — as a named per-host method. Design correction: kept the hook on the host, not the shared engine adapter (the earlier plan floated an engine-levelresolveOrphanTargetId); hoisting the orchestration into the engine would need strip/broadcast/flush hooks that fight the flat-vs-tree split for negative clarity, and the engine boundary (whether-to-persist vs how) is already correct. (c): extracted the append-merge-with-toolCallId-dedup-and-metadata-overlay into a shared purereconcileOrphanPartial(existing, incoming)inmessage-reconciler.ts(exported fromagents/chat, 8 new unit tests). Confirmed it is not convergeable to Think's whole-message replace: ai-chat's early tool-approval persist can apply a client tool result in place that lives only in storage (never in the chunk stream), so a replace would clobber it. Think has no early-persist, so its replace is already dedup-safe and it doesn't call the helper — (c) is a shared primitive with one consumer today. (d): the store-write is now recognizably the sameSessionProvider-subset shape on both hosts (ai-chatfindIndex→map/append over the flat array;Think._upsertMessageInHistorygetMessage→update/appendover the Session tree). The two_persistOrphanedStreambodies stay separate by design — the substrate split and ai-chat's tool-result preservation are product/substrate decisions, not drift. Validation: ai-chat 687/687, agents 2067 passed / 8 skipped, typecheck 113/113,pnpm run checkclean. No changeset — pure internal refactor of@internalmethods, no public API / observable behavior change (the new export is additive). The old finding-#4 second-decode (hand the decoded partial through the codec) was not in scope and stays open. -
Phase 5 — Session-provider alignment spike: orphan-persist (b) unblocked (design, no code) — Ran the spike that gated the (b) consolidation: read the real
SessionProviderinterface (experimental/memory/session/provider.ts) to decide whether the orphan store-write can be shaped toward it without a second storage abstraction. Result: yes. (1) The store-write half needs exactly four ops and the interface already has them with the right semantics —getMessage,getLatestLeaf,appendMessage(idempotent,parentId:undefined→latest leaf),updateMessage; methods returnT | Promise<T>so sync DO-SQLite and async Postgres both fit. (2) ai-chat's flatUIMessage[]is a degenerate linearSessionProvider(findIndex / last / push / replace-by-id), so both hosts reduce to one interface — ai-chat linear, ThinkAgentSessionProvider(tree); the "neutral store interface" the Persist-orphan note deferred already exists. (3)resolveOrphanTargetIdis recovery policy, not a store method (corrects alignment point 1, which had listed it as storage): it reads the stored streammessage_id(#1691, ai-chat) orgetLatestLeaf(Think) — knowledge the store shouldn't have — so it stays a thin adapter hook that calls the provider.chat-sdk'sChatSdkStateAdapter(subagent-sharded thread/history state for the externalchatpackage) confirmed orthogonal, not conflated. Recorded the finish-line layering (reconstruct →resolveOrphanTargetId→getMessage→ merge →update/append) as the (b)/(c)/(d) target. Scope guardrails hold: "shape toward" = align signatures (aSessionProvidersubset), not migrate ai-chat onto Session storage or promote it out ofexperimental/(both still non-goals). See the Session-provider alignment spike — result note. -
Phase 5 / Tier-2 — orphan-persist step (a): ai-chat reconstruction onto the shared
StreamAccumulator(LANDED) —AIChatAgent._persistOrphanedStreamnow rebuilds the partial via the sharedStreamAccumulatorinstead of a hand-rolledapplyChunkToPartsloop + inlinestart/finish/message-metadataextraction (the drift-prone duplication step (a) targeted). Scoped deliberately narrower than "seed-then-replace dissolves (c)/(d)": a closer read showed a full seed-then-replace would change ai-chat's tool-result-merge semantics — its merge intentionally keeps an existing in-place-applied tool part rather than letting a replayedoutput-availablechunk re-advance it (index.tsmerge comment) — so on the highest-risk path the id-resolution (b, #1691) and the append-merge-with-toolCallId-dedup (c/d) are kept verbatim. Reconstruction is provably behavior-identical (the accumulator defaultscontinuation:false, so it adoptsstart.messageIdunconditionally like the old code, and merges the same metadata chunks). Validation: ai-chat durable-chat-recovery 63/63, full workers suite 609/609, typecheck 113/113,pnpm run checkclean. No changeset — pure internal refactor of an@internalmethod, no public API or observable behavior change. Adjusts the earlier RFC framing: step (a) is "use the shared reconstruction primitive"; (c)/(d) do not auto-dissolve because the append-merge carries ai-chat-specific tool-result preservation — converging them needs explicit behavior analysis + tests, deferred into the (b) consolidation. -
Phase 5 — orphan-persist (c) correction: not a bug, downstream of (b) (design, no code) — Before implementing the proposed standalone "(c) dedup" fix, a read of the actual reconstruction code reversed the finding.
applyChunkToPartsis already fully idempotent bytoolCallId(message-builder.ts:237–240, 273–297— the #1404findToolPartByCallIdguards), so the accumulator never holds duplicate tool parts andStreamAccumulator.mergeInto(which replaces with the deduped parts, not append) needs no dedup — the premise "mergeIntolacks dedup" was wrong. Think also has no early/mid-stream message persist (its persists are all at stream finalize, which a crash skips; tool-approval early-persist is ai-chat-only), so its fresh-id orphan path has nothing to duplicate against — not a gap. ai-chat's hand-rolled dedup is purely a consequence of its reconstruct-fresh-then-append-onto-same-id model (which exists because of (b)'s same-id merge + the ai-chat-only early-persist). (c) is therefore downstream of (b), not an independent bug-asymmetry; there is no standalone fix andmergeIntois left unchanged. The dedup dissolves for free when ai-chat adopts the shared seed-then-replace model in step (a). Corrected the 4-step table, open item #1, the Tier-3 bullet, the matrix note, and the bucket-2 example (which had cited (c)). Lesson captured in the litmus test: verify the asymmetry is real before propagating a "fix." Recommended next step is now the step-(a) reconstruction migration (part of the consolidation), not a standalone (c) patch. -
Phase 5 — foundation revisit through the convergence litmus test (design, no code) — Carried the new lens (shared client contract + the 3-bucket litmus test) back over all three tiers of the extraction map and the convergence matrix, not just Tier 3. Findings: (1) the "Persist-orphan boundary" responsibility split was miscalibrated — re-split so reconstruct+merge+dedup are shared and only target-id resolution + raw store write + broadcast are adapter, and stated the general seam rule (merge/reconcile is shared; only id resolution + the store write are the adapter seam). (2) Convergence-matrix "Message reconciliation: keep adapter-owned" is already false —
reconcileMessages/resolveToolMergeIdare shared and both hosts call them (Think7590/8766); reframes orphan-persist (c) as finishing that convergence. (3) Stale matrix cells (terminal delivery persist-first; reconciliation) corrected via a status + litmus-bucket note under the matrix. (4) "Reconstruct partial" was double-assigned to both adapter and codec seams — removed from the adapter list, homed on the sharedStreamAccumulator. Tier 1: the 4f-ii items (enforceRowSizeLimit, inline protocol parse) are the same "hand-rolled subset of a shared primitive" shape as orphan-persist (a) → their verify gate must diff for a latent bucket-2 fix, not just equivalence. Tier 2: item 1's handshake spine is already partly shared (ResumeHandshake), narrowing divergence to wire vocab + the idle-connect payload (the latter possibly a bucket-1 convergence, not a permanent knob); item 2 shrinks — orphan-persist (a) collapses the "applyChunkToPartsvsStreamAccumulator" codec difference into one shared reconstruction, so the orphan consolidation and the streaming-codec extraction share work and the codec seam is thinner (driver + vocabulary only). No code landed; this tightens the foundation before the orphan-persist consolidation. -
Phase 5 / Tier-2 — orphan-persist 4-step investigation (design finding, no code yet) — Pressure-tested the long-standing assumption that
_persistOrphanedStreamis "legitimately different, keep package-specific." Read both bodies (ai-chat [index.ts:1452–1543], Think [think.ts:11174–11197]) against the shared primitives and decomposed the writer into four steps: (a) chunks→parts, (b) target-id resolution, (c) tool-part dedup, (d) upsert. Finding: 3 of 4 unify; only (b) is genuinely storage-coupled. (a) Think'sStreamAccumulatoris the superset ai-chat hand-rolls a subset of → migrate ai-chat onto it. (b) ai-chat's storedmessage_id(#1691) and Think'sgetLatestLeaftree-position are each correct FOR their storage model — neither is buggy, and Think structurally cannot have #1691 → keep behind a narrowresolveOrphanTargetIdhost hook. (c) [later corrected — see the newest Progress-log entry; (c) is downstream of (b), not a standalone bug] was read here as a bug-asymmetry (ai-chat dedups bytoolCallId, Think doesn't), but on readingapplyChunkToPartsit is already idempotent, so there is no shared- primitive gap and no standalone patch. (d) mechanically equivalent (substrate only). Also reconsidered pluggable storage: a flat list is a degenerate Session tree, so a neutral store interface is feasible; not doing it is a risk/reward + product-direction call (Think subsumes ai-chat), not an architectural "can't." Recorded under "Persist-orphan boundary" (4-step verdict table) + Tier-3 bullet revision; folded into open item #1 (subsuming former finding #4 + start-id alignment). No code landed; the (c) dedup is the recommended first concrete step. -
Phase 5 / Tier-2 — give-up ordering convergence (broadcast-first) — Follow-up to the exhaustion-helper extraction below. The two chat hosts had diverged in their give-up terminalize ORDER:
AIChatAgentpersisted the durable terminal record before broadcasting the banner (persist-first), whileThinkbroadcast first. ConvergedAIChatAgentontoThink's broadcast-first ordering. Why broadcast-first is strictly better (not a toss-up): a terminal-record write can reject in the deploy/storage window a give-up runs in (#1730). A throwingterminalizepropagates, so the whole give-up re-runs on a healthy isolate and persists the record either way — so persist-first buys no durability. But under persist-first the throw fired BEFORE the (storage-free) broadcast, dropping the live banner on the failing pass; it only reappeared on the re-run, possibly a different isolate after the affected connections had gone. Broadcast-first delivers the banner on every pass (the documented at-least-once edge) and keeps the same eventual durability —Think's own code comment already argued this. Verified against the existing ai-chat give-up tests (testStableTimeoutSealTransientDeferetc.): they assert the banner VALUE + incident status, not persist-before- broadcast, andterminalBroadcastcaptures the last banner — so broadcast-first keeps them green (now the banner also lands on the failing pass). This removes the last "legitimately divergent ordering" between the hosts — both now broadcast-first; only the durable-write SET differs (Thinkalso writes a submission row). Changeset: patch for@cloudflare/ai-chat(behavior change observable only in the storage-failure mode). Tests:pnpm run check✅ (113), ai-chat 687/687 ✅ (incl. the transient/seal give-up tests). -
Phase 5 / Tier-2 — engine-owned exhaustion helper (API-ergonomics finding #3, closed) — Every host's
_exhaustChatRecoveryrepeated the samebuildChatRecoveryExhaustedContext → notifyChatRecoveryExhausted →host terminalize sequence (~30 lines). AddedrunChatRecoveryExhaustion(input, { emit, onExhausted?, onError, terminalize })toagents/chat, which owns the invariant skeleton — build the context, run the notification (emit +onExhausted-swallow viaonError), THEN call the host'sterminalize(ctx)— and guarantees notify-before-terminalize plus "a throwingonExhaustednever blocks terminal UX." Design judgment (the careful part): the RFC's literal sketch (a helper that also owns broadcast/storage) would have FORCED one terminalize order and silently converged the hosts' then-divergent ordering; instead the helper takes the host'sterminalizeclosure so each host keeps its own terminal writes. (The ordering itself was converged separately, in the entry above — a deliberate behavior change with a changeset, not a side effect of a dedup.)partialPartsstays an explicit input (not derived fromRecoveryPartial) so a foreign-vocabulary host passes[]rather than fabricating AI-SDK parts, preserving the parts-agnostic seam. Scope: all four hosts moved onto it (ai-chat,think, pi + tanstack harnesses); the harnesses also gained a_setChatRecoveringwrapper so the duplicatedsetChatRecoveringoption bag is built once. Behavior-neutral plumbing in the@internalagents/chatlayer (additive sibling-only export) — no changeset, consistent with theRecoveryPartial-refactor precedent. Added arunChatRecoveryExhaustionunit test (notify-before-terminalize order,onExhausted-swallow, sharedctx, terminalize propagation). Tests: chat unit 413 ✅, ai-chat 687 ✅, think suites ✅,pnpm run check✅ (113), typecheck 113/113 ✅. -
Phase 5 / Second harness — real Workers AI provider run (open item #1, closed) — Replaced the harness's deterministic faux model with a REAL streaming provider on the last genuinely-untested codec axis:
@tanstack/ai'schat()over@cloudflare/tanstack-ai'screateWorkersAiChat(model@cf/moonshotai/kimi-k2.7-code), bound to theAIbinding. Becausechat({ stream: true })yieldsAsyncIterable<StreamChunk>byte-identical to the faux model's output, the swap is model-only —TanStackRecoveryCodec,ws-bridge.ts, the sharedResumeHandshake, andChatRecoveryEngineare all unchanged, confirming the codec/handshake/engine seams hold against a real, non-deterministic chunk stream. Shape: a smallTurnModelseam (src/model.ts) with two impls —FauxTanStackModel(default) andcreateWorkersAiModel(src/workers-ai-model.ts); the provider is chosen per-turn via the/startbody (provider: "workers-ai") and stored durably so a cold-wake recovery re-runs the SAME provider. Continuation under non-determinism (the decision taken): rather than relax to a regenerate, the recovered turn genuinely CONTINUES — the survived partial is re-fed to the model as an assistant-prefill + a "resume from where you stopped" nudge (_buildModelMessages), and the merge folds whatever it streams onto the prefix. The engine's continuation INVARIANT (recoveredVia === "continue",prefixChars > 0,generatedChars > 0,prefix + continuation === final) holds by construction even when the model doesn't resume verbatim. Finding (e2e timing, not a seam leak): a real model's time-to-first-token means the orphaned fiber row exists well before anyTEXT_MESSAGE_CONTENTflushes, so killing onfiberRows > 0alone races the TTFT and reconstructs an EMPTY partial (→ aretry). Added abufferedCharsstatus field (chars reconstructable from the live stream buffer) and gated the SIGKILL on it, so the survived prefix is non-empty — proving the reconstruction works on real chunks (the first run also confirmed text accumulation: the regenerate produced 7309 chars). Default preserved: the faux model remains the hard default; the new run lives in aRUN_WORKERS_AI_E2E-gated e2e (e2e/workers-ai.test.ts,describe.skipIf) that is skipped in CI, sincewrangler dev'sAIbinding (remote: true) proxies to real Workers AI and needs network + Cloudflare auth. Tests:tanstack-recoveryunit 31 ✅, faux e2e 4/4 ✅, gated real e2e 1/1 ✅ (local, against the live binding),pnpm run check✅ (113 projects). Deps/config: added@cloudflare/tanstack-aito the harnesspackage.json(+ its transitive@openrouter/sdkbuild skipped inpnpm-workspace.yaml, unused on the binding path) and theAIbinding towrangler.jsonc/env.d.ts. No published-package source changed → no changeset. This closes open item #1; the next open items are API-ergonomics finding #3 (engine- owned exhaustion helper) and #4 (hand the decoded partial topersistOrphanedStream). -
Phase 5 / Tier-2 follow-up — progress-bump timing convergence — Closed the deferred, correctness-flagged divergence in how the two hosts credited the recovery no-progress counter. Before:
AIChatAgentcredited only on chunk-type milestones (isProgressChunk: started segments + settled tools), so a long single content segment that emits only deltas bumped exactly once (attext-start);Thinkcredited on its flush cadence (first content, then every 10 chunks, plus settled tools), so it kept crediting mid-segment. Why it matters:evaluateChatRecoveryIncidentcompares the counter attempt-to-attempt (currentProgress > prevProgressresetslastProgressAt), so a single long segment spanning ≥2 crashes with no intervening milestone reads as "no progress" underAIChatAgentand can false-fireno_progress_timeout(300s) while content is genuinely streaming.Thinkwas immune;AIChatAgentwas not. Fix: one shared, host-agnostic rule —shouldCreditStreamProgress({@link recovery-codec}) — that both hosts now call at chunk-store time. A milestone (isProgressChunk) credits unconditionally; mid-segment streaming deltas (new codecisStreamingContentChunk:text-delta/reasoning-delta/tool-input-delta) credit at most once perStreamProgressCreditThrottlewindow (per-isolate, time-based 5s — mirrors the proven N9AgentToolStreamProgressThrottle, far finer than the 300s budget).Think's bump is now decoupled from its flush decision (flush stays for durability) and routed through the same rule. Safety argument: the unified rule is never coarser than either host's prior cadence, and over-crediting (e.g. crediting a buffered-but-not-yet-flushed chunk) only biases toward keeping recovery alive — so the change can only delay/avoid a false no-progress timeout, never hasten give-up. Tests:recovery-codec.test.tspinsisStreamingContentChunkandshouldCreditStreamProgress(milestone-always, delta-throttled, the long delta-only-segment gap); ai-chat durable-recovery (63) + think-session (193) workers suites green. Changeset: patch foragents+@cloudflare/ai-chat+@cloudflare/think. Still deferred from Tier-2: moving start-id alignment onto the codec; the full streaming-driver merge (Tier-3). -
Phase 5 / Second harness — Route 2 reframed and deprioritized (doc-only) — Questioning what "front
AIChatAgentwith a TanStack client" even means surfaced that the original Route 2 conflated two independent layers ofAIChatAgent's wire. (1) The handshake + frame envelope (cf_agent_stream_resuming/resume_ack/replay/resume_none/chat_recovering- the
{ type, id, body, done, replay? }response envelope) is transport-agnostic and Route 1 ALREADY proved a@tanstack/aiclient speaks it over the REAL sharedResumeHandshakevia a thin frame-router (ws-bridge.ts), zeroagentschange. (2) The chunk payloads insideresponse.bodyARE AI-SDK-specific (AIChatAgentstreams AI SDK UIMessage SSE parts; a TanStack client consumes AG-UIStreamChunks). So actually fronting the realAIChatAgentreduces to a client-side AI-SDK-SSE → AG-UI chunk translator — a codec-translation exercise, NOT the handshake test it was billed as — and that axis is already proven from both directions (our codec reconstructs AG-UI,AIChatAgent's reconstructs AI-SDK, both feed the same agnosticRecoveryPartialseam). A TanStack-nativeAIChatAgent(server emits AG-UI) is a much larger separate effort and is essentially what the engine-direct harness already prototypes. Decision: drop Route 2 as a recovery-validation deliverable; the only genuinely untested axis left is the real Workers AI provider run. Updated the "Build route" item, the body "Still open", and this log accordingly. Doc-only, no code change.
- the
-
Phase 5 / Second harness — making the engine seam genuinely AI-SDK-agnostic (
RecoveryPartialrefactor) — Review of the tool-partsentry below caught that its "vocabulary-agnostic" claim was overstated at the type level: the engine seamRecoveryPartialwas typed{ text; parts: MessagePart[] }, whereMessagePart = UIMessage["parts"][number]— i.e. the AI SDK's UI-message part type. The settled-tool gate was runtime duck-typed (it readtype/output/stateoffRecord<string, unknown>, which is why the harness e2e passed), but to even typecheck, the foreign AG-UI codec had to fabricate AI-SDK parts (… as unknown as MessagePart). So the engine was text-agnostic + protocol-agnostic but parts-vocabulary-coupled to AI SDK, and the foreign codec was "converting to AI SDK format" — the exact smell the genericity harnesses exist to surface. Fix (the codec owns the vocabulary; the engine owns nothing about parts):RecoveryPartialis now{ text: string; parts: unknown[]; hasSettledToolResults: boolean }. The engine's never-drop clause reads the precomputedpartial.hasSettledToolResultsboolean and no longer imports a part type at all (recovery-engine.tsdropped itsMessagePartimport). ThepartialHasSettledToolResultspredicate moved out of the engine intorecovery-codec.tsas the AI SDK codec's helper (AISDKRecoveryCodec.toRecoveryPartialcomputes the boolean from realUIMessageparts; its return stays concretely typedMessagePart[]so AI-SDK hosts keep their typed parts, and is still assignable to the agnostic seam). Foreign codecs compute the same boolean from their own chunks: the TanStack codec now returns AG-UI-nativeTanStackToolParts and decides settledness viahasOutput— zeroMessagePartfabrication, zero AI-SDK coupling; pi returnsfalse. AI-SDK hosts (AIChatAgent,Think) re-assertMessagePart[]with a single cast at the user-facing exhausted-/recovery-context edge (legitimate: those hosts genuinely own the AI SDK vocabulary), and their pre-stream classify helpers widened toparts: unknown[](they only readparts.length). Files: publishedagents(recovery-engine.ts,recovery-codec.ts,chat/index.tsexport repoint),ai-chat/index.ts+think.ts(edge casts +_getPartialStreamTextboolean), both harness codecs/agents, andrecovery-engine.test.ts(the gate tests now sethasSettledToolResultson the partial — proving the engine consumes the boolean, not a part shape). Net: the engine seam is now wire-vocabulary-agnostic by type, not just at runtime; the codec is the single owner of both parts reconstruction AND the settled-tool determination. Behavior-neutral (the AI SDK path computes the identical boolean it used to),agents/chatis@internal, so no changeset. Tests: engine unit (gate suite) ✅,tanstack-codecunit 20 (now assertshasSettledToolResultsdirectly, parts in AG-UI-native shape) ✅,tanstack-recoverye2e 4/4 ✅,pi-recoverye2e 1/1 ✅,pnpm run check✅ (113 projects). Still open: a real Workers AI provider run is the only genuinely untested axis; Route 2 (frontAIChatAgentwith a TanStack client) is deprioritized — Route 1 proved the handshake is transport-agnostic, so Route 2 reduces to a redundant client-side AI-SDK-SSE → AG-UI chunk translation (see the reframed "Build route" item above for the full reasoning). -
Phase 5 / Second harness — tool-
partscodec path + settled-tool persist gate (foreign vocabulary) — Closed the last codec gap both genericity harnesses left open: until now the pi fixture AND the TanStack harness were text-only (parts: []), so the engine's shared settled-tool persist gate (partialHasSettledToolResults) had only ever been exercised through the AI SDK adapter. Extended the TanStack harness to reconstruct toolpartsfrom a FOREIGN tool vocabulary and prove the gate end-to-end. Files (all inexperimental/tanstack-recovery/, zeroagentschange):tanstack-codec.ts—toRecoveryPartialnow rebuilds the AG-UITOOL_CALL_START → ARGS → END → RESULTsub-protocol into the AI-SDKUIMessagetool-part shape (type: "tool-<name>",state,input,output), so a tool whoseRESULTflushed reads as settled and one torn before it reads as unsettled;faux-model.ts—setNextTurnToolCallsettles a scripted tool before the text body (so a mid-text-tail SIGKILL leaves a partial carrying a settled result);tanstack-agent.ts— a durableinvokeOnChatRecoverywake hook returning the configured{ persist }policy + apartialHadSettledToolobservable;server.ts/starttakeswithTool/persist. Core finding: the settled-tool persist gate is vocabulary-agnostic — it keyed off the AG-UI-reconstructed parts byte-identically to AI-SDK ones, with the SAME shared predicate and no engine change. The codec — not the engine — owns the chunk→parts contract, exactly as Tier-2 claimed. (Superseded / corrected by the entry above: this was true only at runtime — theRecoveryPartial.partsseam was still TYPED as AI-SDKMessagePart[], so this codec had to fabricate AI-SDK parts to typecheck. The follow-up refactor made the seam agnostic by type too.) Proof: two SIGKILL e2es sharing onepersist: falsepolicy and differing ONLY in whether the turn settled a tool: the tool turn's partial SURVIVES the{ persist: false }drop (gate override →recoveredVia === "continue",partialHadSettledTool === true,prefix + suffix === total) while the text-only turn's partial is DROPPED (recoveredVia === "retry",prefixChars === 0) — the divergent outcome isolates the gate. Tests:tanstack-codecunit now 20 (settled/unsettled/torn tool reconstruction +partialHasSettledToolResultsover AG-UI parts, imported live in node) ✅;tanstack-recoverye2e 4/4 ✅;pnpm run check✅ (113 projects, no changeset — fixture only). Still open: a real Workers AI provider run (the documented one-line swap) and Route 2 (frontAIChatAgentitself with a TanStack client). -
Phase 5 / Second harness (TanStack AI client + shared handshake, engine-direct) — Built
experimental/tanstack-recovery/(sibling topi-recovery): aTanStackAgentDurable Object driving the SAMEChatRecoveryEngineAND the SAMEResumeHandshakeasAIChatAgent/Think, but with a foreign@tanstack/aiWebSocket client (SubscribeConnectionAdapter) and a foreign chunk vocabulary (AG-UIStreamChunks). This stresses the two axes the pi fixture left untouched (resume handshake against a foreign client transport; streaming codec against a foreign vocabulary). Files:faux-model.ts(deterministic slow AG-UI stream; one-line swap to@cloudflare/tanstack-aidocumented),tanstack-codec.ts(TanStackRecoveryCodec implements ChatRecoveryCodec; concatenatesTEXT_MESSAGE_CONTENTdeltas into{ text, parts:[] }; AG-UIisProgressChunklist) + unit test,tanstack-agent.ts(engine adapter + wake hooks mirroringpi-agent.ts, PLUS aResumeHandshakeinstance wired overonConnect/onMessagewith aResumeHandshakeHost),ws-bridge.ts(the Approach-Acf_agent_* <-> AG-UIclient translation),ws-adapter.ts/client.tsx(realuseChatdemo),server.ts(WS viarouteAgentRequest- HTTP
/start//status), and a SIGKILL e2e with a headless bridge client. Core finding: the sharedResumeHandshake(andResumableStream.replayChunks) are frame-vocabulary-coupled but protocol-agnostic — onlyresponseMessageTypeis injectable, yet the notify → ACK → replay protocol (incl. #1733 double-send, #1645 terminal-via-resume) drove the foreign client through a thin client bridge (one frame-router, ~6 wire constants) with zero change to publishedagents. Approach B (make the resuming/none/response vocabulary injectable onResumeHandshakeHost, defaults = exactcf_agent_*bytes so the golden gate stays green) is therefore deferred as optional — revisit only if a second foreign client appears. Still open: the tool-partscodec path (text-only here, like pi — since closed; see the newest progress-log entry), a real Workers AI provider run (the documented swap), and Route 2 (frontAIChatAgentitself with a TanStack client). Dependency:@tanstack/ai-client/-reactrequire@tanstack/ai@0.32, so the repo was bumped0.28 → 0.32(agents+codemodedevDeps); peer ranges + public API unchanged, all 113 projects typecheck → no changeset (and no engine source change → none for the harness). Tests:pnpm run check✅ (113 projects);tanstack-codecunit (14) ✅;tanstack-recoverye2e ✅ (deterministic mid-stream reconnect handshake — foreign client observesSTREAM_RESUMING→ ACK → replay → completed turn; and SIGKILL mid-stream →recoveredVia === "continue"withprefixChars + generatedChars === total).
- HTTP
-
Phase 5 / Tier-2 (resume-handshake + streaming-codec seams extracted into
agents/chat; one behavior-visible convergence → one changeset) — Deduped the byte-parallel Tier-2 seams the@cloudflare/ai-chatand@cloudflare/thinkhosts hand-maintained in lockstep, in risk-ordered slices (commit038e6d23). T2-1: formalizedChatRecoveryCodec(toRecoveryPartial) +AISDKRecoveryCodecinrecovery-codec.ts, madePiRecoveryCodecconform, and routed both hosts'_getPartialStreamTextthrough the shared singleton (zero behavior). T2-3a: collapsed the adapterresolveRecoveryStreamIdand the wake-hookresolveRecoveryStreaminto oneChatRecoveryAdapter.resolveRecoveryStream; the give-up path now reads.streamIdand resolves the newest durable row (ORDER BY created_at DESC LIMIT 1) instead of an in-memory first-match.find()— identical for single-attempt turns, more correct across recovery attempts. Behavior-visible →recovery-stream-resolution-newest-rowchangeset (@cloudflare/ai-chat+@cloudflare/thinkpatch). T2-3b: gave the no-op hooks (isAwaitingClientInteraction,invokeOnChatRecovery,onShouldKeepRecoveringError) engine-side defaults (false/{}/ no-op) so a minimal adapter shrinks, and dropped pi's redundantcontinueFromPartialdetail. T2-2a/T2-2b: extractedresume-handshake.ts(the proactiveSTREAM_RESUMINGnotify, the REQUEST decision tree, the ACK decision tree, and the #1645/#1575 terminal-replay path) behind a host-ownedResumeHandshakeHostseam (pendingResumeConnections+ the continuationawaitingConnectionsstay host-owned, passed in, because they couple to the out-of-scope streaming loop); wired ai-chat then think to delegate, deleting both hand-maintained copies. T2-4: moved the progress-chunk-type predicate onto the codec (isProgressChunk); ai-chat consults it at its existing bump site (zero timing change), think's flush-gated bump is untouched. Key review findings: (1) the golden handshake fixture (resume-handshake-frames.ts) was self-referential — the test asserted only the builder functions, never the real driver — so a driver-level test (resume-handshake.test.ts) now constructs the actualResumeHandshakeagainst a fake host and asserts every branch's framestoEqualthe golden builders (host-agnostic, so it is also the byte-identity gate for think, whose native browser reconnect has no e2e); (2) cross-package constant equivalence (cf_agent_use_chat_responseetc.) confirmed, so byte-identity is enforced not assumed; (3) the lazy_resumeHandshake()getter's capturedResumableStream/ContinuationState/pending-set references are stable per-instance (assigned once, never reassigned).recovery-codec.test.tspins theisProgressChunklist;recovery-engine.test.tsadds minimal-adapter cases proving the T2-3b defaults; think's #1575 helper now exercises the shared driver. Deferred (explicitly): converging the progress-bump timing (ai-chat per-type vs think per-flush — correctness-critical for the no-progress budget) and moving start-id alignment onto the codec; the full streaming-driver merge (Tier-3). (Progress-bump timing convergence has since landed — see the progress-log entry below; start-id alignment + Tier-3 remain deferred.) The unrelated, pre-existing expected-REDreattach-budget.test.tsgate (wall-clock re-attach budget; partially addressed by #1670) wasit.skip-ed to keep the manual think-e2e suite green. Forcing function (next): the planned TanStack AI harness (engine-direct,examples/) will validate the extracted handshake/codec against a foreign client transport + real Workers AI model and drive any residual seam corrections. Tests:pnpm run check✅ (112 projects); agents workers 2004 + 16 new codec ✅, ai-chat workers 687 ✅, think workers ✅; agents chat unit (golden frames + driver gate + minimal-adapter) ✅; ai-chat recovery / recovering-status / outcomes / exhaustion e2e ✅; think stall + messenger recovery e2e ✅; pi SIGKILL e2e ✅. -
Phase 5 / P5-1b (pi adapter upgraded to
stream_continuation; seam-difference correction; Flue analysis; no package behavior change, no changeset) — Upgraded theexperimental/pi-recovery/fixture so the pi adapter takes the same continue-from-partial path as the AI SDK adapter rather than full-regenerate. Motivation: an analysis of Flue (requested to sanity-check the recorded seam difference) showed Flue is itself a pi-based harness — itssession.ts/submission-state.tsimport@earendil-works/pi-ai— and it does far more than regenerate: it persists streaming deltas via aStreamChunkWriterkeyedsubmissionId:turnId:attemptId, reconstructs the interrupted partial inrecoverInterruptedStream, and continues from it (stream_continuation); it also preserves completed tool results across a mid-batch interruption (tool_results_partial— synthesize interrupted-markers for the unresolved calls, never re-execute the settled ones), all under a leased submission execution store (attempt_id/lease_expires_at/recovery_requested_at). That made it clear the prior P5-1 note ("pi has no mid-assistant resume, so it regenerates") described a limitation of the first text-only codec, not of pi. Change: (1) the codec already reconstructs{ text, message }from bufferedmessage_updateevents — nowdecodePartial's clonable partialAssistantMessageis used as a merge target; (2)PiAgentflushes every delta durably (so a SIGKILL leaves a non-empty, recoverable partial), preserves the reconstructed partial as apartial-flagged transcript entry inpersistOrphanedStream(gated onstreamStillActive, mirroring the AI SDK adapter), classifiescontinuewhen a partial survived, and on the scheduled_chatRecoveryContinueregenerates only the suffix after the survived prefix (faux model primed withfull.slice(prefix.length)), merging it back onto the partial entry to land the identical full reply. A preserved partial is excluded from pi's model context (_piMessages()) so the user stays the leaf and pi GENERATES the suffix; the merge then folds it on. Full regenerate stays as theretryfallback for the no-partial case (crash before the first delta flushed). Genericity proof strengthened: the engine'scontinuepath +persistOrphanedStream+ the never-drop persist clause are now exercised by a non-AI-SDK consumer with no engine-side change — the codec owns the wire-vocabulary and the merge, the engine owns the lifecycle. e2e upgraded (e2e/recovery.test.ts): after the SIGKILL + restart it now asserts continuation, not regeneration —recoveredVia === "continue",partialPrefixChars > 0,0 < recoveryGeneratedChars < total, andpartialPrefixChars + recoveryGeneratedChars === total(prefix preserved, only the remainder generated). Tests: pi SIGKILL continuation e2e 1/1 ✅; package typecheck ✅. No changeset (nopackages/runtime behavior change; the fixture isexperimental/and unpublished; the onlyagentschange in scope —SnapshotMessage— shipped in P5-1). -
Phase 5 / P5-1 (genericity proof — REAL pi adapter on the shared engine; no package behavior change, no changeset) — Built
experimental/pi-recovery/, a runnable Worker that drives the real@earendil-works/pi-agent-coreAgent(real loop, realcontinue(), realMessage[]/AgentEventvocabulary) on the SAMEChatRecoveryEngineasAIChatAgent/Think. Uses the real published package, not a synthetic stand-in — an initial hand-rolledtext/endvocabulary was scrapped because validating the engine against a strawman shaped to fit it is a circular proof. Workers-compat (the load-bearing risk): the realAgentvalue pulls@earendil-works/pi-ai, whose barrel eagerly registers every provider (@smithy/node-http-handler, proxy-agents, Anthropic/AWS/Google/ Mistral/OpenAI SDKs). Inference said "won't bundle"; the empirical test said it does —wrangler deploy --dry-runbundles at 841 KiB gzip and theAgentconstructs + runs inworkerdundernodejs_compat(esbuild tree-shakes, andcanvasis only a pi-ai devDependency so it never enters the runtime tree). Deterministic streaming uses pi-ai's built-inregisterFauxProvider(tokensPerSecondlow) so the turn streams through pi's REAL stream path with no LLM/network — interruptible mid-flight. As-built seam wiring:PiAgent(a baseAgentDO) persists its transcript topi_messages, buffers pi'sAgentEventstream into the sharedResumableStream, and reconstructs partials viaPiRecoveryCodecover pi's realmessage_update/message_endevents (text_deltaaccumulation) — feeding the engine the identical{ text, parts }RecoveryPartialthe AI SDK codec does, so the engine never sees the wire vocabulary. Recorded Tier-2 seam difference (exit criterion #4): a text-only pi turn has no settled tool results and no mid-assistant resume, so recovery regenerates the unanswered user turn via pi's realcontinue()rather than merging the orphaned partial (the AI SDK adapter merges). Concretely the wake hooks setclassifyRecoveredTurn → "retry"andshouldPersistOrphanedPartial → false; the engine supported this with no engine-side change, and thecreateChatFiberSnapshotinput was already generalized offUIMessagetoSnapshotMessage(prior slice) so the snapshot builder stayed wire-agnostic — the two together are the genericity proof. e2e (the P5-1 exit criterion): a realwrangler devSIGKILL crash-mid-stream test (e2e/recovery.test.ts) — start a turn, confirm it is in-flight (orphaned fiber row, no committed assistant), SIGKILL mid-stream, restart on the same persist dir, and assert the shared engine regenerates the turn (assistant commits, fiber row reclaimed). Tooling note: the pi deps tripped pnpm'sverifyDepsBeforeRunbuild-script gate (blocking all repo commands); resolved by pinning@google/genai/protobufjstoallowBuilds: false(the faux path never invokes them). The shared Tier-2 extraction (lifting the resume handshake + streaming codec intoagents/chatbehind adapters, then folding corrections back into the AI SDK adapter) is the remaining Phase-5 work; P5-1 establishes the proof and the second-consumer harness that extraction will be driven against. Tests: pi SIGKILL e2e 1/1 ✅;pnpm run check✅ (112 projects); agents workers 1996 ✅, ai-chat ✅, Think ✅ (theSnapshotMessagewidening is type-only — all three consumeagents/chatunchanged at runtime). No changeset (nopackages/runtime behavior change; the fixture isexperimental/and unpublished). -
Auto-continuation convergence (post-Slice-4f — adopt Think's event-driven barrier in
AIChatAgent; user-visible,@cloudflare/ai-chatminor changeset) — Replaced ai-chat's in-turn, 60s-polling parallel-tool barrier (#1649) with Think's event-driven, no-timeout, stream-gated barrier (#1650). As-built mapping (Think → ai-chat):_scheduleAutoContinuation(coalesce-timer arm + pending create/update; if already-runningpastCoalesce, defer),_rearmPendingAutoContinuationForBatch(re-arm on a non-autoContinuesibling result that may complete the batch — never creates a pending),_fireAutoContinuationWhenStable(the barrier: returns early onpastCoalesce/_continuationBarrierActive/_streamingTurnActive; fast-path fires when no apply is in flight and_hasIncompleteToolBatch()is false; otherwise drains in-flight applies underkeepAliveWhileand re-checks synchronously infinallyso a sibling-armed coalesce macrotask can't double-fire),_drainInteractionApplies(tail-chasing drain bounded by real apply activity),_onStreamingTurnFinalized(clears the stream gate + re-arms — the SSE-loop finalize hook), and_fireAutoContinuation(cancels the still-armed timer, then synchronously calls_runExclusiveChatTurnso the queue is registered before any idle observer can resolve). DroppedAUTO_CONTINUATION_PENDING_TOOL_TIMEOUT_MS, the in-turn poll, and_awaitPendingInteractionBarrier. New fields:_continuationTimer,_continuationBarrierActive,_streamingTurnActive(all cleared inresetTurnState);AUTO_CONTINUATION_COALESCE_MSset to 50ms to match Think. SSE-loop finalize hook: ai-chat streams via the SSE reader, not Think'stoUIMessageStream()loop, so the stream-active gate is set after_startStreamand cleared via_onStreamingTurnFinalized()in an outerfinallyin_reply(package-local; carries a Phase-5 note since the Tier-2 codec extraction will touch the same region). Deferred/coalesce reconciliation: ai-chat's_continuationdeferred machinery is kept but theprerequisitefield is gone (the event-driven barrier subsumes "wait for a prior thing");_activateDeferredAutoContinuationnow routes the activated continuation back through_fireAutoContinuationWhenStable(so a freshly-activated continuation re-checks completeness instead of firing blind). Idle/stable awareness (the one place ai-chat goes beyond Think): moving the barrier out of the turn opened a window where the turn queue is momentarily empty but a debounced continuation is armed. Added_hasArmedContinuation()(pending set, notpastCoalesce, and_continuationTimer !== null || _continuationBarrierActive) and taught bothwaitForIdle()(loop until the armed decision resolves) andwaitUntilStable()(don't report stable while armed) about it. This is strictly more correct — recovery/idle-eviction must not fire mid-continuation — and matcheswaitUntilStable's docstring ("no queued continuation turns"); a parked continuation (incomplete batch) is reported viahasPendingInteraction(), and a running one (pastCoalesce) via the turn queue, so neither is double-counted. Deep review: double-fire guard holds (noawaitbetween the drainfinally's flag clear and the fire/return decision); the stream gate prevents firing against a batch the model is still streaming (a fast client tool can resolve before its slower siblings are even emitted); a true orphan parks budget-free and re-arms when its sibling lands. e2e coverage: added a deterministic workers-pool test (parks a parallel batch when a sibling never arrives —hasPendingstays true,activeRequestIdnull,getChatMessageCallCount0,hasPendingInteractiontrue — then fires exactly once when the missing sibling lands); the deploy/crash mid-park case is covered by the existing recovery PARK tests (durable-chat-recovery.test.ts"PARKS a continuation/retry… while a CLIENT interaction is pending" + "client tool result after park resumes the turn"), because a parked auto-continuation leaves the same on-disk signature as a HITL park (aninput-availableorphan) → recovery parksskipped/awaiting_client_interactionbudget-free rather than terminalizing. Tests:pnpm run check✅ (111 projects); agents workers 1996 ✅, ai-chat workers 687 ✅ (incl. the new park test; the four existing auto-continuation timing tests required the idle/stable awareness above and now pass unchanged in intent), Think workers 686 ✅ for parity; ai-chat SIGKILL e2e 10/10 ✅; Think chat-recovery e2e 5/5 ✅ (Think source byte-unchanged this slice). One changeset (@cloudflare/ai-chatminor — no-timeout park behavior change). -
Slice 4f-ii(b) (Phase 4 —
parseProtocolMessagemigration; classification-only, no changeset) — Migrated ai-chat'sonMessageoff its inlineJSON.parse+data.type === MessageType.Xswitch and onto the sharedparseProtocolMessage(which Think already uses), dispatching on the typedChatProtocolEventdiscriminants (event.type === "chat-request" | "clear" | "messages" | "cancel" | "stream-resume-request" | "stream-resume-ack" | "tool-result" | "tool-approval"). Classification-only: all eight handler bodies are byte-preserved (data.→event.); themessagesevent still callsautoTransformMessages+persistMessages(ai-chat persists the client snapshot — explicitly NOT converged onto Think's no-op). Verify-first / behavior-preservation review: (1) the wire strings in ai-chat'sMessageTypeenum andagents'CHAT_MESSAGE_TYPESare byte-identical for all eight incoming types (same client talks to both packages), so the parser recognizes exactly the set the inline switch did and routes each to the same body; (2) the inline switch's first guard wasUSE_CHAT_REQUEST && init.method === "POST", so a non-POST use-chat request fell through to the consumer'sonMessage— preserved by gating the delegate on!(event.type === "chat-request" && event.init.method !== "POST")(only the POST branch enters the handler; everything else, including a parser-null non-JSON/unknown frame, still falls to_onMessage); (3) non-JSON and JSON-without-typeboth yieldparseProtocolMessage(...) === null→_onMessage, matching the old try/catch + no-typefall-through; (4) the parser is marginally more robust (defaults a missinginitto{}rather than throwing ondata.init.method, and a missingtoolNameto"") — strictly safer for malformed frames, no change for real traffic. One type fix: the parser typesclientTools[].parametersasunknown(vs ai-chat'sClientToolSchemaJSONSchema7), so the auto-continuation call site now castsclientTools as ClientToolSchema[] | undefined, mirroring the existing cast on the_lastClientToolsassignment two lines up. Removed the now-unusedtype IncomingMessageimport. Tests:pnpm run check✅ (111 projects); ai-chat workers 686 ✅; ai-chat real-wrangler devSIGKILL e2e 10/10 ✅ (the dispatch-path gate); Think workers 686 ✅ for parity. Think and theagentspackage are byte-unchanged this slice (Think already routed through the pre-existingparseProtocolMessage), so the Think SIGKILL e2e cannot regress and was not re-run. No changeset. -
Slice 4f-ii(a) (Phase 4 —
enforceRowSizeLimitconvergence; user-visible, changeset on both packages) — Ran the verify-first gate on ai-chat's_enforceRowSizeLimitvs the sharedenforceRowSizeLimit(Think's). Not a byte-identical lift — they had drifted in two independent, observable ways, so this was treated as a convergence (not a 4f-i leaf lift), with the correct behavior decided and written up under the 4f-ii bucket. (1) Tool-output compaction shape: ai-chat replaced oversized tool outputs with a flat english summary string ("…too large to persist… Preview: …"), discarding shape; Think used the structured, shape-preservingtruncateToolOutput. Decided structured wins (a model can keep reasoning about a shape-preserving truncation; the flat string is strictly lossier) — ai-chat now usestruncateToolOutputand its summary string is gone. (2) Compaction annotations + warnings: ai-chat annotatedmetadata.compactedToolOutputs/compactedTextPartsandconsole.warned; Think did neither. Decided annotate + warn on both (additive metadata lets a client tell a row was compacted) — Think now emits them too. Implemented by extending the sharedenforceRowSizeLimitto own both the structured compaction and the annotations, plus an optionalwarnhook (EnforceRowSizeLimitOptions) so each package keeps its own log prefix ([AIChatAgent]/[Think]). Both call sites are now thin bindings: ai-chat's_enforceRowSizeLimitand a new Think_rowSafehelper (folds in thesanitizeMessageit always pairs with, dedups three identical call sites + the submission-serializer). Deep review: truncation thresholds matched already (both compact tool outputs > 1KB, both truncate text parts oldest→newest until they fit, both use the same 1.8MBROW_MAX_BYTESbyte-length guard incl. multibyte UTF-8); the only value-level change is ai-chat's tool-output text (summary string → structured marker) and Think's newly-present annotations; non-assistant (user/system) messages still fall straight to text truncation; metadata is merged (spread over existing), never clobbered; the engine/recovery, hibernation/wake order, terminal-before-seal, and settled tool results are untouched (this is a pure pre-storage serialization step). Tests: ai-chatrow-size-guard.test.tsassertions that pinned the old summary string were repointed at the structured... [truncated N chars]marker (thecompactedToolOutputsmetadata assertion was already correct); Think's row-size tests were already structure-shaped and needed no change.pnpm run check✅ (111 projects); agents workers 1996 ✅, ai-chat workers 686 ✅, Think workers 686 ✅; ai-chat SIGKILL e2e 10/10 ✅; Think SIGKILL e2e 11 files / 26 tests ✅ with the expected-REDreattach-budgetgate (unrelated wall-clock budget) left untouched. Two changesets (@cloudflare/ai-chatminor — structured tool-output compaction;@cloudflare/thinkminor — compaction annotations/warnings). -
Slice 4f-i (Phase 4 — byte-verified pure leaf lifts; zero behavior, no changeset) — Ran the verify-first gate at execution time on all eight 4f-i items (line numbers from 2026-06 had drifted; re-diffed by method name) and confirmed each byte-equivalent modulo comments:
sendIfOpen/isWebSocketClosedSendError(identical in both packages and a third copy incontinuation-state.ts);_getPartialStreamText(one-word comment diff); the client-interaction predicates (_partAwaitsClientInteraction/_toolPartName/_clientResolvableToolNames, docblock-only diff);_hasIncompleteToolBatch(identical incl. comment); the terminal KV trio (_recordChatTerminal/_clearChatTerminal/_pendingChatTerminal, identical); the stream-cleanup pair (_ensureStreamCleanupScheduled/_cleanupStreamBuffers, one extra comment clause in Think); and_setChatRecovering+ the recovering-frame builder (identical apart from the wire-type enum + broadcast wrapper, exactly as predicted). Lifted them intoagents/chat: a newconnection.ts(sendIfOpen/isWebSocketClosedSendError- a
ChatConnectionminimal type — and dedupedcontinuation-state.ts's copy, a bonus third-copy removal);getPartialStreamTextinmessage-builder.ts(over the resumable-stream chunk reader);hasIncompleteToolBatch+ the three client predicates intool-state.ts;STREAM_CLEANUP_DELAY_SECONDS+cleanupStreamBuffersinresumable-stream.ts; and the terminal trio +buildChatRecoveringFrame+setChatRecoveringinrecovery-incident.ts(storage-glue home, same precedent as 4e). Both packages are now thin per-package bindings; the per-package divergence (recovering wire-type enumCF_AGENT_CHAT_RECOVERINGvsMSG_CHAT_RECOVERING, and the_broadcastChatMessage/_broadcastChatwrapper) is threaded as params, and the_broadcastChatwrappers stayed package-local as planned. The duplicatedCHAT_RECOVERING_KEY/CHAT_LAST_TERMINAL_KEY/CHAT_RECOVERING_FLAG_TTL_MSlocal constants were deleted outright (no remaining direct references once the helpers absorbed them). Deep review (zero-behavior confirmation): storage keys + values unchanged (cutover-safe; the shared constants are the same strings); wake/ hibernation ordering unchanged (bindings issue the same storage ops in the same order); the stream-cleanup re-arm stays non-idempotent (rearm passes{ idempotent: false }, invariant documented on the shared fn); the recovering set/clear stays idempotent-on-active-existing; terminal-before-seal and settled tool results untouched; observability/recovering-frame payload shape identical;setChatRecoveringnow uses a single injectednowfor both the staleness check and the storedat(was twoDate.now()calls microseconds apart — not observable, and matches the engine's injected-clock seam). Tests:pnpm run check✅ (sherif / exports / oxfmt / oxlint / typecheck 111); agents workers 1996 ✅, ai-chat workers 686 ✅, Think workers 52 + react 2 ✅; ai-chat real-wrangler devSIGKILL e2e 10/10 ✅; Thinkchat-recovery+stall-recoverySIGKILL e2e 6/6 ✅. The expected-REDreattach-budgete2e (unrelated wall-clock budget) was left untouched. Internal@internalseam, zero behavior change → no changeset.
- a
-
Confidence review of the go-forward plan (code-grounded; docs only) — Before handing the plan to a fresh session, pressure-tested the new Slice 4f and auto-continuation decision against the actual code (not just the explore summaries). Verified solid:
_hasIncompleteToolBatchis byte-identical across both packages (comment included);CHAT_RECOVERING_FLAG_TTL_MS/STREAM_CLEANUP_DELAY_SECONDSand the recovering/terminal keys match the sharedrecovery-incident.tsvalues (so the constant dedup is a no-op on keys + timing, no migration); the client-interaction predicates (_partAwaitsClientInteraction/_toolPartName/_clientResolvableToolNames) are byte-identical with the broad-vs-client-only asymmetry living in the wrappers — so "lift leaves, keep wrappers" is correct. Five corrections folded in: (1) the auto-continuation convergence was badly mis-scoped — it is not a near-trivial follow-on to 4f but a substantialAIChatAgentrearchitecture (ai-chat's barrier runs inside the exclusive turn and its own docblock atindex.ts:2437–2441says that is why it needs no double-fire guard; moving to Think's event-driven model requires taking the barrier out of the turn, adding a_continuationBarrierActive-style guard, adding an SSE-loop stream-finalize re-arm hook that has no current analogue, and reconciling ai-chat's_continuationcoalesce/deferred machinery) — rewrote the decision to spell this out and added a deploy-mid-park e2e requirement (a parked continuation must re-arm on recovery, not exhaust/false-terminalize); (2)enforceRowSizeLimitis not a zero-behavior lift (ai-chat adds compaction metadata + warnings and may differ in truncation) → moved to a new 4f-ii bucket with a verify-first gate and likely changeset; (3)parseProtocolMessagemigration clarified as classification-only — ai-chat must keep its distinct handlers, esp.CF_AGENT_CHAT_MESSAGES(ai-chat persists; Think no-ops) — also moved to 4f-ii; (4) baked a verify-byte-equivalence-first gate into all of 4f rather than asserting equivalence in prose (line numbers drift); (5) minor completeness — added request-context persist/restore to Tier 3, noted the_broadcastChatwrappers stay package-local (threaded as the broadcast-fn param), and noted the soft coupling between the auto-continuation SSE-loop hook and the Tier-2 codec region. Net: 4f is now split into safe 4f-i (pure leaf lifts) and behavior-sensitive 4f-ii, and the auto-continuation convergence is correctly framed as the largest single piece of go-forward work. No code; plan only. -
Design review + plan update (pre-Phase-5 chat-layer extraction map; docs only) — Before starting Phase 5, ran a four-surface design review (message persistence; stream lifecycle + broadcast; inbound request / connection handling; tool / HITL / terminal) comparing
AIChatAgentandThinkmethod-by-method for parallel machinery NOT already shared. Recorded the result as the new "Chat-layer extraction map" section with three tiers. Tier 1 — safe leaf dedup, captured as new Slice 4f: the duplicatedCHAT_*/STREAM_CLEANUP_*constants,sendIfOpen/isWebSocketClosedSendError, the terminal KV trio (_recordChatTerminal/_clearChatTerminal/_pendingChatTerminal),_setChatRecovering+ recovering frame,_getPartialStreamText, the stream-cleanup pair,_hasIncompleteToolBatch, the client-interaction predicates, ai-chat's localenforceRowSizeLimitreimpl, and ai-chat's non-use of the sharedparseProtocolMessage. Tier 2 — structural seams the pi adapter should DRIVE during Phase 5 (the resume / reconnect handshake and the streaming-loop codec); extracting them before a non-AI-SDK consumer would re-bakeUIMessageassumptions, so they are sequenced into Phase 5, not before. Tier 3 — keep-package-specific (storage model, Think submissions / codemode / media / repair, ai-chat persisted-cache / migration / early-approval-persist,_persistOrphanedStreamid-merge semantics, boot ordering). Also locked the auto-continuation convergence decision (new behavior decision + convergence-matrix row): adopt Think's event-driven, no-timeout, stream-gated parallel-tool barrier (#1650) inAIChatAgent, dropping its in-turn 60s force-continue (#1649) — a semver-minor, changeset-bearing behavior change, sequenced after Slice 4f (which lands the shared_hasIncompleteToolBatchit depends on) and independent of Phase 5. Recommended sequencing: Slice 4f → auto-continuation convergence → Phase 5 (pi drives Tier 2). No code; plan only. (Detail lives in the design-review sub-reports; this RFC is the durable record.) -
Slice 4e (Phase 4 — lift the residual leaf duplication the confidence pass found) — Acted on the confidence-pass finding by lifting the byte-identical leaf host-I/O helpers into shared
agents/chatfree functions, leaving each package a thin binding. Added torecovery-incident.ts:sweepStaleChatRecoveryIncidents(storage, now)(owns list-by-prefix + TTL select- the batched
KV_DELETE_MAX_KEYSdelete loop),readChatRecoveryProgress(storage)/bumpChatRecoveryProgress(storage)(the durable monotonic counter), and the N9 throttle asAgentToolStreamProgressThrottle+ the sharedAGENT_TOOL_STREAM_PROGRESS_BUMP_THROTTLE_MSconstant. The storage params are typedPick<DurableObjectStorage, …>sothis.ctx.storagepasses with no cast and the helpers stay unit-testable with a fake. BothAIChatAgentandThinkdropped their duplicated_sweepStaleChatRecoveryIncidents(hook now points straight at the shared fn), turned_chatRecoveryProgressMarker/_bumpChatRecoveryProgressinto one-line bindings, replaced the in-memory_lastAgentToolStreamProgressAtfield + inline throttle withnew AgentToolStreamProgressThrottle(), and deleted their local duplicateCHAT_RECOVERY_PROGRESS_KEY,AGENT_TOOL_STREAM_PROGRESS_BUMP_THROTTLE_MS, andKV_DELETE_MAX_KEYSconstants. Per the confidence-pass call,_resolveRecoveryStreamIdwas deliberately LEFT package-local (lifting it would feedResumableStreaminto the engine for ~6 lines — the hook-bloat inversion the 4d-2 fallback warned against). Also extended the@internalbarrel comment inchat/index.tsto cover therecovery-engine/stall-watchdogblocks, not justrecovery-incident. Zero behavior change — the throttle gate is identical (a fresh isolate's first chunk still credits because productionnow≫ the window; a unit test pins exactly that). Tests: 9 newrecovery-incidentunit tests (sweep prefix-scoping + no-op + 128-batching; progress read/increment; throttle credit/throttle windows); fullpnpm run checkgreen; agents / ai-chat / think suites green; localwrangler devSIGKILL e2e — ai-chat 10/10, thinkchat-recovery+stall-recoverygreen. Only e2e red remains the documented expected-REDreattach-budgetgate (unrelated wall-clock budget; untouched). Internal@internalseam, zero behavior change → no changeset.
- the batched
-
Phase 4 confidence pass (exit-criteria audit + reviewer checklist; docs only) — Before advancing to Phase 5, audited both packages for residual duplicated recovery logic and walked the release reviewer checklist. Exit criteria met: every recovery orchestration engine now routes through
ChatRecoveryEngine— incident begin/update + exhaustion-notification core, the give-up spine (exhaustRecoveryGiveUp, 4d-1), the schedule triplet (4b), the stable-timeout reschedule (4c), the wake frame (handleChatFiberRecovery, 4d-2), non-chat fiber dispatch (3c), the stall watchdog (3a/3b), the sharedpartialHasSettledToolResults(4d-2), and the shared incident type + key/sweep selection helpers (4a). Both_beginChatRecoveryIncident/_updateChatRecoveryIncidentare one-line engine delegations in both packages;_exhaustChatRecoveryuses the sharedbuildChatRecoveryExhaustedContext+notifyChatRecoveryExhaustedcore with only package-specific terminal/broadcast ORDERING left local (ai-chat persist-first, Think broadcast-first — a deliberate, documented divergence). Checklist: pass — the 4d-2 commit changed no public (non-_) hook signatures (verified by diff); defaults flow from the sharedDEFAULT_CHAT_RECOVERY_*constants; the engine module is@internaland not exported from theagentsroot (only re-barrelled through theagents/chatentry both consumers already import); schedule callback names (_chatRecoveryContinue/_chatRecoveryRetry) are stable; the recovery event payload is built identically via theemitRecoveryEventadapter hook; settled-tool-results are preserved by the byte-equivalent shared helper; HITL turns stay budget-free viaisAwaitingClientInteraction(hasPendingInteractionin Think); stable-timeout reschedules stay non-idempotent viachatRecoverySchedulePolicy. Residual finding (bounded, optional — a possible "Slice 4e"): a small cluster of byte-identical leaf host-I/O accessors is still duplicated across both packages —_chatRecoveryProgressMarker/_bumpChatRecoveryProgress(the durable progress counter), the N9 throttle-credit pair (_lastAgentToolStreamProgressAt+_onAgentToolStreamProgress, the only duplicated policy),_sweepStaleChatRecoveryIncidents(sweep glue around the sharedselectStaleIncidentKeys), and_resolveRecoveryStreamId(resumable-stream metadata lookup). These are leaf accessors, not orchestration engines, so they do NOT violate the exit criteria. The cleanest lift would be the sweep (the engine already owns get/put/delete incident hooks; alistIncidentshook lets it own the loop) and the progress-counter + N9 throttle (a sharedagents/chathelper);_resolveRecoveryStreamIdis deliberately LEFT package-local — lifting it would feedResumableStreamaccessors into the engine for ~6 lines, the exact hook-bloat inversion the 4d-2 fallback warned against. Cosmetic nit recorded: the@internalbarrel comment inchat/index.tssits above only therecovery-incidentexport block, not therecovery-engine/stall-watchdogblocks. No code, no tests — audit only. -
Slice 4d-2 (Phase 4 — lift the wake FRAME into the engine; the genericity seam) — Implemented the reviewed seam. Added
ChatRecoveryEngine.handleChatFiberRecovery(ctx, wake)owning the wake lifecycle (chat-fiber gate → requestId parse → snapshot unwrap → stream/partial resolution → recovery-kind classification →beginIncident→ exhausted branch →onChatRecovery→ persist + complete → decision →catch → updateIncident("failed") → rethrow), with the package-specific decision living behind a method-scopedChatFiberWakeHooks<TClassify>object passed as the second arg (NOT bolted ontoChatRecoveryAdapter, keeping its five give-up-spine test fakes focused). Lifted the byte-identical_partialHasSettledToolResultsinto one shared purepartialHasSettledToolResults(parts)inagents/chat; both packages dropped their private copy (real dedup, zero behavior change).ThinkandAIChatAgenteach collapsed_handleInternalFiberRecoveryto a one-line delegation and implemented the hooks as private methods —Thinkkeeps its submission lifecycle + session-leaf +_handleRecoveryCallbackErrorinsidedispatchRecoveredTurn;AIChatAgentis leaf-only and returnsstreamStatus: undefined(terminal-stream handling stays absent, per the "substrate capabilities are optional" decision — reading status would be a behavior change). Verified: fullpnpm run check(sherif + exports + oxfmt + oxlint + typecheck) green;agents1989 passed,@cloudflare/ai-chat686 passed,thinkfull suite chain green; new engine unit tests forhandleChatFiberRecovery+partialHasSettledToolResults; localwrangler devSIGKILL e2e — ai-chat 10/10, think 26 passed + 4 skipped. The only e2e red isreattach-budget.test.ts, the documented expected-RED regression gate for the unrelated wall-clock re-attach-budget bug (manualthink-e2eproject, not the CI gate) — untouched by this slice. -
Slice 4d-2 design (Phase 4 — seam design + decision record; docs only, no code) — Before touching the wake path (the highest-risk surface in this RFC), recorded the seam design and the load-bearing decision it rests on. Decision: substrate capabilities are optional, not shared requirements (new subsection under Genericity) —
Think's submission layer and Session-tree leaf are product substrates, not recovery primitives, soAIChatAgentdoes NOT grow a submission layer to converge the wake bodies; instead the wake-recovery DECISION is a package-owned seam (classifyRecoveredTurn+dispatchRecoveredTurn) that each host implements on its own substrate, while the engine owns only the wake FRAME. This is the inversion-prevention rule (abstraction must not dictate the product) and the pi-genericity guarantee (pi has no submissions either). Rewrote the 4d-2 slice bullet into a concrete seam: enginehandleChatFiberRecovery(ctx, wake)lifecycle (gate → parse → unwrap → stream/partial → classify → beginIncident → exhausted-branch → onChatRecovery → persist+complete → decision → catch→failed) + a namedChatFiberWakeHooks<TClassify>surface, with explicit convergence-onto-Think items (sharedpartialHasSettledToolResults,streamStatusread,onChatRecoveryctx builder) and a recorded fallback (helper-extraction-only if the hook surface hurts engine readability). Added name-reconciliation notes to rfc-think-turns.md and rfc-think-actions.md pointing their assumedThinkRecoveryAdapter/classifyRecoveredTurn/resolveStreamForRecoveryseam at the realChatRecoveryAdapter. No code, no tests, no changeset — design gate only; implementation is blocked on review (see "what I need from you"). -
Slice 4d-1 (Phase 4 — lift the give-up spine; the part deferred from 4c) — Reading both
_handleInternalFiberRecoverybodies in full first re-shaped 4d (recorded in the slice plan): the bodies are ~70% structurally similar but the similar part is mostly control flow, while the meaty logic (stream-status tracking, recovery-kind detection, persist gates, stream-completion API, and the retry/continue/skip decision) has legitimately DIVERGED — even_partialHasSettledToolResultshas drifted between the packages. So 4d split: 4d-1 lifts the genuinely-shared give-up spine here; 4d-2 (the wake-frame collapse for Phase 5 genericity) is gated behind a seam-design review.Think._exhaustRecoveryGiveUpandAIChatAgent._exhaustRecoveryAfterStableTimeoutwere ~80% identical: resolve config → key fromdata.incidentId→ best-effort readstored(tolerate failure → synthesize) →stored.status==="exhausted"re-entry guard → build the exhausted incident → resolve streamId + partial →_exhaustChatRecovery(terminalize) BEFORE the best-effort seal write. Lifted intoChatRecoveryEngine.exhaustRecoveryGiveUp({ callback, data, reason })behind 5 adapter hooks (exhaustChatRecovery,resolveRecoveryStreamId,getPartialStreamText,activeChatRecoveryRootRequestId,onGiveUpBookkeepingError); each package method is now a one-line delegation. Byte-equivalence review: the unified root-id chain (originalRequestId ?? recoveredRequestId ?? activeRoot ?? stored.root ?? stored.requestId ?? "") reproduces Think verbatim and collapses to AIChat's chain because AIChat's payload type has norecoveredRequestId(alwaysundefined→ skipped);reasonis the only behavioral parameter (Thinkstable_timeout|recovery_error, AIChat alwaysstable_timeout); synthesizedattempt/maxAttempts=config.maxAttempts,recoveryKindfrom the callback,firstSeenAt/lastAttemptAt=adapter.now()(=Date.now()),crypto.randomUUID()id;createdAtpassed asincident.firstSeenAt; both read and seal stay best-effort with the original[Think]/[AIChatAgent]log prefixes viaonGiveUpBookkeepingError. The terminalize-BEFORE-seal ordering (#1730/#1645 — a transient terminal write must re-run the WHOLE give-up, not be no-op'd by an armed re-entry guard) is preserved and pinned by a test. The give-up's terminalize + stream/partial hooks are exactly the surface 4d-2 will reuse, so 4d-1 de-risks 4d-2. Added 9 engine unit tests (terminalize→seal order + reason threading, re-entry guard, read-fail synth, seal-fail tolerance, root-id precedence ×2, no-incidentId uuid synth/no-seal, empty-stream partial skip) and back-filled the 5 hooks into the four pre-existing fake adapters. Tests: engine unit 42 ✅ (+9); typecheck 111 ✅;check✅; ai-chat workers 686 ✅ and Think recovery workers 285 ✅ (fiber/submissions/stream + think-session + messengers; benign SQLite-alarm harness log only); Think remote-Workers-AI give-up e2e (stall-recovery+chat-recovery) 6/6 ✅; ai-chat real-wrangler devSIGKILL give-up e2e (chat-recovery-exhaustion+chat-recovery+chat-recovering-status) 9/9 ✅. No changeset (internal@internalseam, zero behavior change). -
Slice 4c (Phase 4 — centralize the stable-timeout reschedule; re-scoped) —
_rescheduleRecoveryAfterStableTimeoutwas byte-identical between the packages (100% dup): read the incident → if under the attempt cap, bumpattempt, persiststatus:"scheduled"+reason:"stable_timeout_retry", and issue a NON-idempotent DELAYED schedule (it runs inside the executing one-shot row thatalarm()deletes on return, so an idempotent reschedule would dedup onto the doomed row and never fire). Lifted intoChatRecoveryEngine.rescheduleAfterStableTimeout({ incidentId, callback, data, fallbackMaxAttempts }); each package method is now a one-line delegation. Generalized theChatRecoveryAdapter.scheduleRecoveryhook (from 4b) to carrydelaySeconds— the initial triplet passes0, the reschedule passesCHAT_RECOVERY_STABLE_RETRY_DELAY_SECONDS— so a single schedule seam serves both. Bonus dedup: both packages also kept a privateCHAT_RECOVERY_STABLE_RETRY_ DELAY_SECONDS = 3that shadowed the canonical agents/chat constant; removed both, the engine uses the shared one (byte-identical value). Byte-equivalence review:adapter.getIncident(key)returnsctx.storage.get(key) ?? null(same!incidentshort-circuit, incl. the missing-id case → no key → false); the attempt cap usesfallbackMaxAttempts === maxAttempts; the put payload is field-for-field identical;adapter.now() === Date.now(); and the hook issues the same delay (3s) + non-idempotent policy. The reschedule deliberately bypassesevaluateChatRecoveryIncident(coarse same-turn retry, not a fresh interruption) andupdateIncident(noscheduledevent / recovering-flag churn) — unchanged from the originals. Re-scope: the give-up seal (_exhaustRecoveryGiveUp/_exhaustRecoveryAfterStableTimeout, ~80% dup) was deferred from 4c to 4d — its shared spine interleaves package-specific terminalization + stream/partial reads behind #1730/#1645 exactly-once invariants, needing ~5 adapter hooks that are exactly 4d's terminalize + stream surface; building them once (in 4d) avoids inconsistent seams. Added 5 engine unit tests (attempt bump + delayed non-idempotent enqueue, missing id, no record, budget spent, maxAttempts fallback). Tests: engine unit 34 ✅ (+5); typecheck 111 ✅;check✅; Think workers 686 ✅ and ai-chat workers 686 ✅ (clean, no flake); ai-chat real-wrangler devSIGKILL e2e 10/10 ✅; Think remote-Workers-AI recovery e2e (chat-recovery+stall-recovery) 6/6 ✅. No changeset (internal@internalseam, zero behavior). -
Slice 4b (Phase 4 — centralize the schedule-a-recovery triplet, behavior-preserving) — The
updateIncident("scheduled")+_emit("chat:recovery:scheduled")+schedule(0, callback, data, chatRecoverySchedulePolicy("initial"))block appeared at 7 call sites (Think: stall + 2 fiber; AIChat: stall + 3 fiber). Collapsed into one engine methodChatRecoveryEngine.scheduleRecovery({ incident, recoveryKind, callback, data, reason? })driving transition → emit → enqueue in that order, behind a newChatRecoveryAdapter.scheduleRecovery(callback, data, reason)hook (each package:schedule(0, callback, data, chatRecoverySchedulePolicy(reason))). WidenedChatRecoveryIncidentEvent["type"]to include"chat:recovery:scheduled"so the emit flows through the existingemitRecoveryEventmapping (byte-identical payload). Byte-equivalence review: (1) the engine preserves the exact order; (2)incident.requestIdis what every call site emitted — the budget evaluation rewritesincident.requestId = identity.requestIdon each attempt (recovery-incident.ts), so reading it off the incident matches; (3)recoveryKindis passed EXPLICITLY (not derived), because AIChat's lost-partial branch opens acontinueincident but schedules + reports aretry— each call site passes the same literal/variable it emitted before; (4) the only structural reorder is Think's stall path, where the purerecoveredRequestId = _hasRunningSubmission(...)read now precedes the engine call — it reads the submissions store, independent of the incident-status / recovering-flag thatupdateIncidentmutates, so its value is unchanged; (5) the stable-timeout reschedule (reason:"stable_timeout_retry", non-idempotent, directstorage.put) is deliberately untouched — that is Slice 4c. Added 4 engine unit tests pinning the order, the explicit-recoveryKindoverride, the defaultinitialreason, and verbatim payload/reason forwarding. Tests: engine unit 29 ✅ (+4); repo typecheck 111 ✅;check(sherif/exports/oxfmt/oxlint) ✅; Think workers 686 ✅ and ai-chat workers 686 ✅ (the one transient Think failure was the known SQLite-alarm-timing pool race — clean on rerun, and the scheduling-heavyfiber+submissionssuites passed twice, 53 each); ai-chat real-wrangler devSIGKILL recovery e2e 10/10 ✅ (same engine path, offline-safe). Both real-edge paths validated: after re-auth, the Think remote-Workers-AI recovery e2e (chat-recovery+stall-recovery) ran green 6/6 ✅ (157s) — the routedengine.scheduleRecoverycontinuation/retry path exercised end-to-end against real Workers AI with simulated eviction/recovery. (An earlier attempt on stale credentials failed at "establish remote session due to an authentication issue";wrangler logincleared it.) No changeset (internal@internalseam, zero behavior). -
Slice 4a (Phase 4 start — shared types + key/sweep helpers, zero behavior) — The mechanical band of the dedup map: both packages re-declared the
ChatRecoveryIncidenttype +ChatRecoveryKind, a localCHAT_RECOVERY_INCIDENT_KEY_PREFIX, a_chatRecoveryIncidentKeymethod (100% dup), and an inline stale-key loop inside_sweepStaleChatRecoveryIncidents, all of which already exist canonically inagents/chat(recovery-incident.ts). Replaced the local copies with the shared symbols in boththink.tsandai-chat/src/index.ts: importtype ChatRecoveryIncident,type ChatRecoveryKind,chatRecoveryIncidentKey,selectStaleIncidentKeys,CHAT_RECOVERY_INCIDENT_KEY_PREFIX; delete the local type/kind/prefix; route the two stable-timeout call sites throughchatRecoveryIncidentKey(...); and collapse the sweep's loop toselectStaleIncidentKeys(entries, now). Zero behavior: the canonical type is byte-identical to both local copies; the prefix string matches ("cf:chat-recovery:incident:"); and the sweep TTL the shared helper applies (CHAT_RECOVERY_INCIDENT_TTL_MS = 60*60*1000) is identical to the local constant it replaced (verified before deleting the now -unused local). Both packages' localCHAT_RECOVERY_INCIDENT_TTL_MSconsts removed. Tests: repo typecheck 111 ✅; full Think workers 686 ✅ and ai-chat workers 608 ✅ (unchanged counts); ai-chat real-wrangler devSIGKILL recovery e2e re-run green (offline-safe — no remote AI binding). No changeset (internal@internalseam, zero behavior). The Think real-edge e2e stays gated on the Phase 6 merge gate (its Workers AI binding needs a stable remote session, not available on the current connection). -
Phase 3 confidence pass (deep review + real e2e) — Before starting Phase 4, re-verified 3a/3b/3c with a fresh review and the real-
wrangler devsuites. Review findings: (1)_routeStallToBoundedRecoveryinAIChatAgentis structurally byte-equivalent to Think's (clean_completeStream+done:trueonscheduled;_markStreamErroronexhausted;abortedstatus so the turn is not terminalised) — confirmed by reading both side by side. (2) The continuation re-anchor id is safe: the catch passestargetAssistantId = message.id, and the tool-approval early-persist writes undersanitized.id === message.id(sanitize preserves id), soearlyPersistedId === message.idand the continuation's leaf-check cannot mis-skip. (3) Coverage gap found + closed: no test exercised a healthy stream with the watchdog armed (timeout > 0, non-stalling) — the guardedpull()must pass healthy streams through unchanged and clear its timer on completion. Added an ai-chat integration test for exactly that. (4) Noted (not a bug): the watchdog measures inter-chunk gaps; it is opt-in (default0) and the budget keeps HITL/awaiting-interaction turns budget-free, so a spurious trip cannot wrongly exhaust — documented as a tuning consideration. E2e/tests run: ai-chat localwrangler dev+ SIGKILL recovery e2e — 5 files / 10 tests green (the bounded-recovery machinery the stall routes into works on a real isolate); sharedagents/chatstall-watchdog- recovery-engine unit suites — 29 green (3a primitive + 3c engine seam); Think
messengerssuite — 27 green (3c dispatch through the new seam); full Think workers suite — 686 green (3a/3c no regression); full ai-chat workers suite — 608 green (3b + the new healthy-passthrough test, no regression). Blocked (environment, not code): the Think real-wrangler deve2e suite binds Workers AI with"remote": true, so it needs livewranglerauth; creds are expired in this environment and it cannot run headless. The shared stall-watchdog primitive (3a) it would exercise is identical to the one Think already shipped and is unit-covered; deferring the Think real-edge + deployed e2e to the Phase 6 merge gate (run once re-authenticated). A dedicated ai-chat real-wrangler stall e2e was assessed and judged low marginal value over the deterministic integration coverage above — left optional.
- recovery-engine unit suites — 29 green (3a primitive + 3c engine seam); Think
-
Slice 3a (Phase 3 start — shared stall-watchdog primitive) — First Phase 3 step. The incident lifecycle is already shared for
Think(2b/2c/2e), so Phase 3 is the deeperThink-only surface; per a recovery-surface map (stall watchdog, durable submissions, messenger/workflow fiber ordering, tool rollback, agent-tool child-run reconcile), the stall watchdog is the #1 convergence-matrix item and the natural foundation. ExtractedThink's_iterateWithStallWatchdog+ChatStreamStalledErrorverbatim into the sharedpackages/agents/src/chat/stall-watchdog.tsand re-exported them (@internal) fromagents/chat. Key enabler: the watchdog generator never referencedthis, so it lifts to a free function with no seam needed —Thinknowimports both, its two stream-loop call sites drop thethis.prefix, and the twoinstanceof ChatStreamStalledErrorread-loop catches are unaffected because the thrown error is the very same imported class (soinstanceofstill holds). Review findings: (1) byte-identical body + error-class shape (isChatStreamStall, name) → zero behavior change; (2) theonStallclosures stay inline atThink's call sites (they capturethis), so the package-specific abort/emit stays package-owned — only the generic race/timeout/cancel mechanics moved; (3) additive barrel export, soai-chat(which doesn't consume it yet) is untouched — confirmed by typecheck +check:exports. Tests: 4 Layer-2 unit cases at the shared layer (disabled-passthrough, fast passthrough, stall→throw+onStall-once, consumer-break→source-cancel);Think686 + e2e suites still green (existing stall coverage preserved). Repo typecheck 111;pnpm run checkclean. No changeset (zero behavior change;@internalexport). Slice 3b wiresAIChatAgentonto this primitive (the behavior change + changeset). -
Slice 3b (converge
AIChatAgentonto the stall watchdog) — WiredAIChatAgent's_streamSSEReplyread loop through the sharediterateWithStallWatchdogbehind a new opt-inchatStreamStallTimeoutMsfield (default0= off, matchingThink;0keeps the rawreader.read()path untouched). The watchdog wraps the response-body reader in anAsyncIterable, and on stall cancels the reader + throwsChatStreamStalledError, which_reply's catch routes via a new_routeStallToBoundedRecovery: open/reuse the incident under the turn's recovery identity (id==_activeRequestIdduring a live turn), then either schedule a_chatRecoveryContinue(close the stream cleanly, reportaborted) or — when the budget is spent — deliver the same terminal UX as deploy-recovery exhaustion. With recovery disabled the route returns"disabled"and the stall falls through to the generic terminal stream error (the watchdog's "kill the spinner" guarantee). Key correctness fix found via the integration test: the partial must be persisted from the in-memorymessage(the unconditional post-stream persistence block), NOT reconstructed via_persistOrphanedStream— on a live stall the stored-chunk buffer can lag the in-memory parts, so the orphan reconstructor came back empty and the user's partial would have been lost (the exact #1626 complaint). Routing the partial through the normalmessage.idpersistence makes the scheduled continuation re-anchor correctly viatargetAssistantId. Decision: kept the watchdog opt-in in both packages (converge on the mechanism, not a forced default timeout). Tests: 2 new Layer-3 integration cases indurable-chat-recovery.test.ts(a hanging-SSEonChatMessagemode +driveStallingTurnForTesthelper) — stall→continue-incident+partial-persisted, and timeout-0→watchdog-disabled — stable across repeated runs (the delay-0 continuation does not auto-fire in the pool, so the durable incident, not the transient schedule row, is the stable assertion).ai-chat685 green (was 683), repo typecheck 111, lint + oxfmt clean. Changeset added (@cloudflare/ai-chatminor). No e2e: the watchdog is opt-in and a real hung-provider stall is impractical to provoke on the edge; the existing deploy-eviction e2e already covers the shared continuation machinery the stall routes into. -
Slice 3c (shared non-chat fiber dispatch seam) — Before writing more code, a deep map of the remaining Think recovery surface (via a thorough explore pass) reframed Phase 3: of the five remaining surfaces, only the non-chat fiber dispatch is genuinely additive seam work — durable submissions, agent-tool child-run reconcile, and resume-ACK orphan persist are ALREADY correctly adapter-owned and invoked at the right points, so their convergence is Phase 4 deduplication, not new seams (forcing seams now adds indirection without removing duplication, and touches the riskiest wake path for no payoff). The map also corrected a doc/reality gap:
tryHandleNonChatFiberRecoveryexisted only in this RFC — Think actually called_messengerRuntime.handleFiberRecoverydirectly. Slice 3c makes that seam real: a new optionaltryHandleNonChatFiberRecovery(ctx)adapter hook +engine.handleNonChatFiber, with Think routing its messenger dispatch through the engine andAIChatAgentcalling the same seam as a structural no-op (omits the hook → every fiber stays a chat candidate). The engine now owns the ordering invariant (non-chat dispatch before the chat-fiber gate); the behavior stays adapter-owned. Behavior is byte-equivalent: previouslyif (await _messengerRuntime?.handleFiberRecovery(ctx)), nowif (await engine.handleNonChatFiber(ctx))which is(await adapter.tryHandleNonChatFiberRecovery?.(ctx)) ?? false— same truthiness, sameundefined→skip when no messenger runtime (child facet).FiberRecoveryContextimportsimport typefrom../index(same package, erased — no cycle). Tests: 3 Layer-2 fake-adapter cases (consume→true, decline→false, omitted→false); agentschatproject 25 green (was 22), Think 686 + e2e green, ai-chat 685 green, repo typecheck 111,pnpm run checkclean. No changeset (zero behavior change;@internalseam). Decision (user-confirmed): do 3c now, move surfaces 3/4/5 into Phase 4 dedup. -
Slice 2e (incident-update transitions behind the engine) — The transition twin of slice 2a:
ChatRecoveryEngine.updateIncident(incidentId, status, reason?)now owns the incident state-machine transitions both packages duplicated near byte-for-byte (completed→ delete the record; other states → persist; emit thecompleted/skipped/failedlifecycle event; drive the #1620 "recovering…" status — set onscheduled, cleared on every terminal). Two new adapter hooks carry the package-owned I/O:deleteIncident(key)andsetRecovering(active, requestId?)(the latter delegates to each package's existing_setChatRecovering, so its staleness/idempotency/broadcast logic stays package-owned).ChatRecoveryIncidentEventwidened to the five recovery event types with an optionalreason;emitRecoveryEventforwards the cause forskipped/failed. Both_updateChatRecoveryIncidentmethods are now thin adapter bindings (~50 lines deleted from each package). Review findings: (1) key derivation verified byte-identical (chatRecoveryIncidentKey==_chatRecoveryIncidentKey), so the engine computing the key itself is safe; (2)getIncidentnormalizesundefined→nulland the engine's truthy guard handles both, matching the oldstorage.get<T>undefined check; (3)_emit's payload is an untypedRecord, so widening the event union carries no per-type payload risk; (4)_chatRecoveryIncidentKeystays referenced by the resume-handshake paths in both packages — not dead code; (5)deleteIncidentdiscardsstorage.delete'sPromise<boolean>to satisfy thePromise<void>hook (the one typecheck catch, fixed). Tests: 6 newupdateIncidentfake-adapter cases (scheduled→set+persist+no-event, completed→delete+emit+clear, failed/skipped→persist+emit-with-cause+clear, and both no-op guards) — agentschatproject 22 green. Validation: ai-chat 683 + think (workers + react) suites green; repo typecheck 111;pnpm run checkclean (sherif/exports/oxfmt/oxlint). Zero behavior change. -
Smoke test (slice 2d, manual) + deferred follow-up — Verified recovery + recovering-on-connect end-to-end in
examples/ai-chat(which neededchatRecovery = trueadded —AIChatAgentdefaults itfalse; the example is a minimal showcase). Killingwrangler devmid-story then refreshing now shows the "recovering…" status on reconnect and the turn resumes from its persisted partial. Deferred follow-up (pre-existing, NOT introduced here; user opted to track, not fix now): on the recovery continue path with a reasoning model, if the model emits NEW reasoning after a partial text, the live stream briefly renders that reasoning as a second block under the content, then it "jumps" back on top when the final persisted message replaces the live stream. Root cause: the continuation merge inAIChatAgent(index.ts, thecontinuationReasoningResumedbranch, ~L5660-5708) fully suppressestext-start(so text merges seamlessly) but can only skip the server apply forreasoning-startwhile still forwarding it to the client — AI SDK v6 requires areasoning-startbefore anyreasoning-delta, and the client's active part at that moment is the text, not the earlier reasoning block. So the client appends a new reasoning part (under content) while the server merges into the top reasoning part (continue-last-turn.test.ts"should merge reasoning into existing reasoning part during continuation" pins the merge-to-top). The two representations are irreconcilable mid-stream in the v6 protocol. The clean fix is to stop merging later-than-text reasoning (chronological order, live == final, matches the repo's "render parts in array order" guidance) and update that test; it self-corrects on finish today, so it is cosmetic. Tracked here as a recovery-UX follow-up, independent of the engine convergence slices. -
Slice 2d (recovering-on-connect convergence — first behavior change) —
AIChatAgentnow replays the live "recovering…" status on connect, matching@cloudflare/think. Before this, ai-chat only broadcastcf_agent_chat_recoveringlive, so a client that connected during the gap between a scheduled continuation and its first chunk saw nothing and looked frozen (its own code said so: "the live 'recovering…' signal is still not replayed on connect"). Implementation: a new private_buildRecoveringConnectFrame()reads the durablecf:chat:recoveringrecord (skipping stale ones past the flag TTL) and returns the frame, whichonConnectsends on the no-active-stream branch only — an actively-streaming continuation still getsSTREAM_RESUMING, so the two signals never collide and the client never double-renders. This mirrors Think's_buildIdleConnectMessagesrecovering replay exactly. Review findings: (1) terminal/recovering are mutually exclusive in storage (every terminal clears recovering), so a reconnecting client never gets both; (2) the client was already wired for it —react.tsxisRecoveringhandles the frame whenever it arrives and its doc already said the status is "replayed on connect" (for Think), so no client change was needed; (3) unlikeSTREAM_RESUMING(which needs the client's resume-protocol readiness and is therefore re-driven by a client-initiated request), the recovering frame is a fire-and-forget status the client reflects directly into state, so a direct on-connect send is the right channel; (4) the extra storage read happens only on idle connects, symmetric with Think. Tests: deterministic unit coverage viagetRecoveringConnectFrameForTest(durable-chat-recovery.test.ts) asserting the frame appears once scheduled and disappears on terminal; thechat-recovering-statuslocal e2e doc/comment was corrected (it previously documented the OLD "not replayed on connect" behavior) and now opportunistically observes the on-connect replay over a real socket (opportunistic because the no-active-stream replay window is timing-bound). Validation: 683 ai-chat unit tests green; full local e2e suite 10/10 green (no regression in the hot connect path); deployed real-edge e2e green (self-cleaning). Repo typecheck (111) + oxlint clean. Minor changeset shipped (user-visible client state change). -
Phase 2 (deployed recovery e2e — real-edge proof) — Built the user-requested DEPLOYED recovery e2e (
packages/ai-chat/src/e2e-tests/):wrangler.deployed.jsonc(uniquely-named Worker) +deployed-recovery.test.tsvitest.deployed.config.ts+ atest:e2e:deployedscript. Unlike the local SIGKILL suites (which prove the recovery state machine in workerd), this deploys a real Worker, forces a real Durable Object eviction the way production does — awrangler deploymid-turn — then asserts recovery fires on Cloudflare's edge, and ALWAYS deletes the Worker in teardown (verified: the script left no resource behind). Double-gated so it never runs in normal CI: its own config (not intest/test:e2e) + aRUN_DEPLOYED_E2E=1body gate. Three real-edge findings the run surfaced (each fixed): (1) a redeploy takes ~18s to go live — far longer than a finite mock turn (~10s) — so a finite turn COMPLETES before the eviction lands and leaves nothing to recover; fixed by addingChatHangingRecoveryAgentwhose turn hangs forever, guaranteeing an in-flight fiber at eviction (no timing race). (2) Back-to-back deploys occasionally hit a transient deploy-API error;deploy()now retries with backoff so the slow deploy→evict→recover cycle isn't restarted wholesale. (3) A freshly-created*.workers.devroute drops the first WS handshakes during cold start/propagation, and the unguardedsendChatMessagerejected the whole test; the turn-start is now a resilient send+check poll. The#1620recovering-flag is only logged (not asserted) on the edge — it is timing-sensitive and already asserted deterministically by the localchat-recovering-statussuite. Validated green twice againstspai@cloudflare.com(~70-76s/run); typecheck (111) clean; the new DO class + migrationv6are additive (localwrangler.jsoncdry-run validates). This is the validation vehicle for the behavior-changing slice 2d.
-
Phase 2 (slice 2c — shared exhaustion-notification core) — Re-scoped from the original "move terminal/exhaust sealing behind the engine". Side-by-side reading of both
_exhaustChatRecoverymethods showed the head (buildChatRecoveryExhaustedContext→ emitchat:recovery:exhausted→ runonExhaustedwith the throw-swallow) is byte-identical (only the log prefix differs), but the tail — the terminal-record / banner-broadcast / submission writes and their ordering — is an intentional, documented divergence:@cloudflare/ai-chatpersists-then-broadcasts (#1645 reconnect reliability) whileThinkbroadcasts-then-persists (banner resilience under a failing storage write), and Think additionally marks the submission interrupted. Forcing the whole method behind the engine would either flatten that divergence or push apersist-first | broadcast-firstknob (plus terminal/submission I/O) through the seam — a leaky, UIMessage-shaped adapter exactly of the kind Phase 5 warns about. So slice 2c extracts only the genuinely-shared core: the purebuildChatRecoveryExhaustedContext(field map +reason/recoveryRootRequestIdfallbacks) andnotifyChatRecoveryExhausted(emit →onExhaustedswallow →onErrorreport). Both packages call these two helpers at the top of_exhaustChatRecovery, then keep their own divergent terminal I/O in their own order. The "throwingonExhaustednever blocks terminal UX" invariant now lives in one tested place. Removed the now-unusedChatRecoveryExhaustedContexttype-import from both packages (kept the public re-export). Added Layer-2 tests pinning the field map, both fallbacks, emit-before-hook order, the swallow +onErrorpath, and the no-hook path. Review: behavior byte-identical — the divergent tail is untouched (ai-chat keeps its trailing_setChatRecovering(false), Think keeps broadcast-first +_markRecoveredSubmissionInterrupted); only the shared head moved. Gates: chat unit (296, +9), ai-chat workers (682), Think workers (686), typecheck (111), oxlint — all clean. -
Phase 2 (slice 2b — Think incident-begin) — Wired
Think._beginChatRecoveryIncidentto the sameChatRecoveryEngine, with its hibernation guard implemented as the adapter'sensureInteractionStateLoadedhook (the rationale comment moved verbatim onto the hook). Removed the now-dead_chatRecoveryIncidentId+ theevaluateChatRecoveryIncident/chatRecoveryIncidentIdimports. Both packages are now symmetric for incident-begin (the only divergence is the predicate —hasPendingInteractionvshasPendingClientInteraction— and the presence of the hook). Review: engine order (get → ensureInteractionStateLoaded → readProgress → predicate) is byte-identical to the old inline order (get → guard → progress → hasPendingInteraction);_restoreClientTools/hasPendingInteractionkeep their other callers; key derivation unchanged. Gates: Think workers (686) pass; typecheck (111) and oxlint clean. -
Phase 2 (slice 2a — incident-begin orchestration) — Added
ChatRecoveryEngine+ChatRecoveryAdaptertorecovery-engine.ts: the engine owns the begin-incident sequence (resolve config → derive key → sweep stale → read existing → rehydrate interaction state → read progress → budget eval → persist → emit) and its two ordering invariants (sweep-before-read; interaction-state-before-predicate). WiredAIChatAgent._beginChatRecoveryIncidentto a cached engine over an inline adapter; removed the now-dead_chatRecoveryIncidentIdandevaluateChatRecoveryIncidentimport (engine derives id/key via the pure fns). Added a Layer-2 fake-adapter test pinning the sequence + both invariants + the optional-hook-absent (ai-chat) shape. Review: orchestration is byte-identical — the purechatRecoveryIncidentKeymatches the removed private method character-for-character (incl.encodeURIComponent); ai-chat omitsensureInteractionStateLoadedso that step is a no-op; the cached engine is safe because the adapter arrows capturethisandthis.ctx/storage are stable per DO instance, whileresolveConfigstill runs per-incident. Gates: ai-chat workers (604) + engine unit (10) pass; typecheck (111) and oxlint clean. Think binding deferred to slice 2b. -
Phase 2 (cleanup — shared callback union) — Replaced the six inline
"_chatRecoveryContinue" | "_chatRecoveryRetry"unions in the recovery helper signatures (AIChatAgent ×2, Think ×4) with the sharedChatRecoveryScheduleCallbacktype, giving the slice-1 export a real consumer. Pure type-alias substitution; typecheck (111) + oxlint clean. -
Phase 2 (slice 1 — scheduling-idempotency policy) — Introduced the first engine-seam file
packages/agents/src/chat/recovery-engine.tswithchatRecoverySchedulePolicy(reason), the single source of truth for theschedule()idempotency flag ("initial"→ idempotent, deploy-storm dedup;"stable_timeout_retry"→ non-idempotent, survives the executing one-shot row's deletion). Re-exported@internalfrom theagents/chatbarrel. Wired all eight recovery schedule sites —AIChatAgent(3 initial + 1 reschedule) andThink(3 initial + 1 reschedule) — to source the flag from the policy; per-site rationale comments now point to the policy. Added the deferred Layer-2 seam test (__tests__/recovery-engine.test.ts): pins both reasons directly and through a fake scheduler exercised the way the packages callschedule(). Review: byte-identical behavior (policy returns the same literal each site used); the four remaining{ idempotent }literals are confirmed non-recovery subsystems (stream-buffer cleanup, scheduled tasks, submission drain) and intentionally left alone — folding them in would be a false coupling. Gates: ai-chat workers (604) + think workers (686) + shared engine unit (34) pass; typecheck (111) and oxlint clean. This closes the Phase 0 "direct{ idempotent }flag assertion" deferral. -
Phase 0 breadth (audit, no new tests) — Audited the existing
ai-chatandthinksuites against the Phase 0 breadth items (schedule idempotency, terminal-before-seal, callback-error coverage, recovering replay) instead of reflexively adding tests. Finding: the high-risk invariants are already characterized symmetrically in both packages, so adding more would duplicate (the cadence warns against over-testing). Recorded the invariant→test map as the verified Phase 2 safety net (see "Phase 0 breadth audit") and resolved the checklist: idempotency + terminal-before-seal + callback-errors = already-covered (cited); adapter-contract + direct{ idempotent }flag assertion = deferred to the Phase 2 engine/adapter seam; ai-chat recovering-on-connect hydration = confirmed asymmetry vs Think, deferred to Phase 2 as an intentional convergence (changeset). Decision rationale (why this over Phase 2): converging behavior before the safety net is verified contradicts the cadence; the audit makes the net explicit so Phase 2 lands against a known-good base. Docs-only; no code/test changes. -
Phase 1 (incident-math wiring complete) — Wired
Thinkto the shared engine the same way asAIChatAgent:_resolveChatRecoveryConfig,_chatRecoveryIncidentId, and the_beginChatRecoveryIncidentbudget computation now delegate to the shared functions, and the six now-unused default constants were removed. Both packages now share one incident state machine. Review: Think's package-specific seams are preserved — the_restoreClientTools()hibernation guard still runs BEFORE the engine readshasPendingInteraction()(the guard statement executes before theevaluateChatRecoveryIncidentargument object is built), the predicate is Think'shasPendingInteraction()(server-tool orphans excluded), and the error log keeps the[Think]prefix. Gates: Think workers suite (686) passes; typecheck (111) and oxlint clean. (Note:vitest runwithout a per-suite-cconfig wrongly picks up the React/CLI/e2e suites and fails in the wrong runner — usepnpm run test:workersfor the recovery path.) -
Phase 1 (in progress) — Wired
AIChatAgentto the shared engine._resolveChatRecoveryConfig,_chatRecoveryIncidentId, and the budget computation inside_beginChatRecoveryIncidentnow delegate toresolveChatRecoveryConfig/chatRecoveryIncidentId/evaluateChatRecoveryIncident(re-exported@internalfrom theagents/chatbarrel).AIChatAgentkeeps only the storage I/O, sweep, progress read, pending-interaction predicate, and event emission around the engine call. Removed six now-unused local default constants (defaults live in the engine). Review: persisted incident JSON is byte-identical (verified field-by-field), the sweep-before-read ordering invariant is preserved, theshouldKeepRecoveringctx and[AIChatAgent]-prefixed error log are preserved, and event payloads/order are unchanged. Gates: fullai-chatsuite (682) + chat unit suite (280) pass; typecheck (111) and oxlint clean. Deferred: the localChatRecoveryIncident/ChatRecoveryKindtypes and the remaining recovery constants stay duplicated until Phase 4; Think wiring is the next step. -
Phase 0 (in progress) — Extracted the byte-identical incident-budget state machine into a pure, storage-free module (
packages/agents/src/chat/recovery-incident.ts:evaluateChatRecoveryIncident,resolveChatRecoveryConfig,chatRecoveryIncidentId,selectStaleIncidentKeys, plus theChatRecoveryIncidenttype and persisted storage-key/budget constants). Added Layer-1 unit tests (__tests__/recovery-incident.test.ts) and the golden cutover round-trip gate (__tests__/recovery-cutover-fixtures.ts+recovery-cutover.test.ts). Both packages still run their inline copies (zero behavior change). Review finding: a pre-cutover incident persisted withoutlastProgressAtis bounded byfirstSeenAt, so a long-orphaned turn can seal onno_progress_timeoutimmediately on the cutover wake — existing behavior, now explicit and tested.
Before moving code, add or tighten tests around existing behavior. The goal is to make current semantics executable.
Work:
- Add shared incident state-machine tests with fake adapters.
(
packages/agents/src/chat/__tests__/recovery-incident.test.ts, backed by the extracted pureevaluateChatRecoveryIncident.) - Add golden cutover fixtures and a round-trip gate.
(
__tests__/recovery-cutover-fixtures.ts+recovery-cutover.test.ts.) - [→] Add package adapter contract tests. Deferred to Phase 2 — these test the engine↔adapter seam (fake scheduler/storage/clock), which does not exist yet. The current package behavior they would assert is already covered behaviorally (see the coverage map below); the seam-level versions are written when the seam lands. Building the seam now just to test it would be Phase 2 work mislabeled as Phase 0.
- Add missing
AIChatAgenttests for recovery callback errors if Think already has stronger coverage. Already symmetric —onChatRecoverythrow (durable-chat-recovery.test.ts"marks the incident failed when onChatRecovery throws"),onExhaustedthrow ("still delivers terminal UX when onExhausted throws"), andshouldKeepRecoveringthrow (sharedrecovery-incident.test.ts). No gap to close. - [→] Add
AIChatAgenttests for reconnect recovering replay if the RFC chooses to converge on Think's better UX. Deferred to Phase 2 (convergence). Confirmed asymmetry: Think hydrates the live "recovering…" status on a mid-recovery (re)connect (think-session.test.ts"broadcasts + hydrates a 'recovering…' status");AIChatAgentbroadcasts it live but does NOT replay it on connect — its own code says so (ai-chat/src/index.ts_setChatRecovering: "the live 'recovering…' signal is still not replayed on connect — only the terminal outcome is"). A characterization test asserting on-connect hydration would assert behavior ai-chat does not yet have, so it ships WITH the convergence in Phase 2 (changeset), tracked as an intentional behavior change. - Add tests proving schedule idempotency/non-idempotency invariants.
Already symmetric — non-idempotent stable-timeout reschedule is pinned
by the 2-row tests in both packages (
durable-chat-recovery.test.ts"reschedules a continuation that times out… (NEW row, 2 total)" and the retry twin;think-session.test.tsequivalents). Initial-schedule storm-dedup is pinned by the fiber-row-deletion "double recovery" tests ("should not double-recover when _checkRunFibers runs from both onStart and alarm" + Think equivalent). The only thing not directly asserted is the{ idempotent }flag VALUE per scheduling reason — that becomes a precise fake-scheduler assertion at the Phase 2 seam (see coverage map). - Add tests proving terminal-before-seal behavior. Already symmetric —
#1730defer-on-transient tests in both packages ("defers a give-up whose terminal write hits a platform transient instead of half-sealing, then seals fully on the re-run") plus the seal-write-best-effort and terminal-replay-on-reconnect (#1645) tests. No gap to close.
See the Phase 0 breadth audit below for the full invariant→test map.
Exit criteria:
- The desired shared behavior is described by failing or passing tests before extraction begins.
- Known intentional behavior changes are identified and tracked.
Goal of this audit: before converging behavior in Phase 2/3, confirm the high-risk invariants are already executable as tests in BOTH packages, so any convergence regression fails loudly. Finding: they are. This codebase is already densely and symmetrically tested for the recovery surface; the right move is to treat the existing suites as the Phase 2 safety net (do not regress them) rather than add redundant tests. Map below (ai-chat / think test homes):
| Invariant | Guarding tests (both packages) |
|---|---|
| Non-idempotent stable-timeout reschedule (fresh row, not dedup onto the executing one-shot) | durable-chat-recovery.test.ts "reschedules a continuation that times out…" + retry twin; think-session.test.ts continue/retry reschedule tests |
| Initial-schedule storm-dedup (re-detection does not re-run/duplicate) | durable-chat-recovery.test.ts "should not double-recover when _checkRunFibers runs from both onStart and alarm"; Think equivalent (primary mechanism = orphaned fiber-row deletion after handling) |
HITL park: no reschedule, no budget spent, incident parked skipped |
durable-chat-recovery.test.ts "PARKS a continuation/retry…"; Think PARK tests |
| Terminal-before-seal: terminal durably recorded/delivered before the incident is sealed; a transient on the terminal write defers (does not half-seal) | #1730 "defers a give-up whose terminal write hits a platform transient…" in both packages |
| Seal write is best-effort after terminal delivery (no re-deliver if only the seal fails) | durable-chat-recovery.test.ts "does not defer/replay… when the post-terminal incident seal write fails" |
Terminal replay on reconnect (#1645); cleared when a later turn supersedes |
durable-chat-recovery.test.ts terminal-reconnect/cleared/aborted/error tests; Think cf:chat:last-terminal tests |
Recovering-flag set on schedule, cleared on terminal (#1620) |
durable-chat-recovery.test.ts "tracks a durable 'recovering…' record…"; think-session.test.ts "broadcasts + hydrates a 'recovering…' status…" |
| Callback hooks throwing do not wedge the turn | onChatRecovery/onExhausted throw tests (ai-chat); shouldKeepRecovering throw (shared engine unit test) |
Confirmed gaps, both deferred (NOT Phase 0 current-behavior pins):
- Recovering-status on-connect hydration — Think hydrates on (re)connect; ai-chat does not (live broadcast only). Phase 2 convergence + changeset.
- Direct
{ idempotent }flag-value assertion per scheduling reason — today only the row-count EFFECT is asserted. When Phase 2 routes scheduling throughadapter.schedule(delay, cb, data, { idempotent }), add a fake-scheduler unit test assertingidempotent: truefor the initial schedule andidempotent: falsefor the stable-timeout reschedule. This is the most valuable seam-level test and is the first Layer-2 test to write in Phase 2.
Add internal files under packages/agents/src/chat, names illustrative:
recovery-engine.tsrecovery-adapter.tsrecovery-codec.tsai-sdk-recovery-codec.ts
Keep them internal unless build constraints require barrel exports.
Work:
- Move config resolution and incident math into pure functions first.
(
resolveChatRecoveryConfig,chatRecoveryIncidentId,evaluateChatRecoveryIncident,selectStaleIncidentKeysinrecovery-incident.ts.) - Move storage key constants and incident helpers into the engine module.
- Keep existing
AIChatAgentandThinkprivate methods as callers initially. (Both packages'_beginChatRecoveryIncident/_resolveChatRecoveryConfig/_chatRecoveryIncidentIdnow delegate to the shared engine.) - Add fake-adapter unit tests. (
recovery-incident.test.ts.)
Exit criteria:
- No behavior changes.
- Shared unit tests cover incident budget behavior.
Move AIChatAgent recovery orchestration behind the shared engine.
Sliced for safety (this is the riskiest phase — see the working cadence):
- Slice 1 — scheduling-idempotency policy.
recovery-engine.ts·chatRecoverySchedulePolicyis the single source of truth for theschedule()idempotency flag; both packages' eight recovery schedule sites source it; Layer-2 fake-scheduler test pins both reasons. Zero behavior change. (Lands the Phase 0 "direct flag assertion" deferral.) - Cleanup — shared callback union. The six inline
"_chatRecoveryContinue" | "_chatRecoveryRetry"unions now use the sharedChatRecoveryScheduleCallbacktype (gives the slice-1 export a consumer). - Slice 2a — incident-begin orchestration.
ChatRecoveryEngine+ChatRecoveryAdapterown the begin sequence (sweep → read → budget eval → persist → emit) and its two ordering invariants;AIChatAgentis now a thin adapter binding. OptionalensureInteractionStateLoadedhook reserves Think's client-tool rehydration. Layer-2 fake-adapter test pins the sequence. Zero behavior change (byte-identical orchestration). Think binding = slice 2b (next). - Slice 2b — Think incident-begin.
Think._beginChatRecoveryIncidentnow delegates to the same engine; its hibernation guard is theensureInteractionStateLoadedhook (verbatim, same position). Both packages are now symmetric for incident-begin. Zero behavior change. - Slice 2c — shared exhaustion-notification core (re-scoped from "move
terminal/exhaust sealing behind the engine"). Only the byte-identical head
moved:
buildChatRecoveryExhaustedContext(pure field map) +notifyChatRecoveryExhausted(emit →onExhausted-swallow →onError). The terminal-record / banner / submission writes stay package-owned because their ordering is an intentional divergence (ai-chat persist-first #1645; Think broadcast-first), which the engine must not flatten. Layer-2 tests pin the field map, both fallbacks, and the swallow invariant. Zero behavior change. - Slice 2d — recovering-on-connect convergence for
AIChatAgent(behavior change + changeset + e2e). First behavior-changing slice.AIChatAgent.onConnectnow replays the live "recovering…" status on connect via_buildRecoveringConnectFrame(no-active-stream branch only, so an actively-streaming continuation still getsSTREAM_RESUMING), mirroring Think's_buildIdleConnectMessages. Stale records (older than the flag TTL) are skipped; terminal outcomes still clear it. Client already handled the frame (react.tsxisRecovering). Deterministic guarantee:durable-chat-recoveryunit test (getRecoveringConnectFrameForTest); real-socket exercise:chat-recovering-statuse2e (opportunistic, since the replay window is timing-bound). Minor changeset shipped. - Slice 2e — incident-update transitions.
recovery-engine.ts·ChatRecoveryEngine.updateIncident(the transition twin ofbeginIncident) now owns the delete-on-completed / persist / event-emit / #1620-flag state-machine both packages duplicated; two new adapter hooks (deleteIncident,setRecovering) carry the package-owned I/O.ChatRecoveryIncidentEventwidened to the five recovery event types + optionalreason. Both_updateChatRecoveryIncidentare thin bindings now (~50 lines deleted each). Layer-2 fake-adapter test pins all transitions + both no-op guards. Zero behavior change.
Work:
- Implement
AIChatAgentRecoveryAdapter. - Keep
_chatRecoveryContinueand_chatRecoveryRetrycallback names stable. - Keep public hooks and config types unchanged.
- Preserve AI SDK message reconciliation.
- Preserve terminal replay through
WebSocketChatTransport. - Add the chosen better behaviors where tests require them.
Exit criteria:
- Existing ai-chat recovery tests pass.
- New adapter contract tests pass.
- E2E reconnect and terminal replay pass.
Move Think recovery orchestration behind the same shared engine. The incident
lifecycle (begin / update / exhaustion-notification) already routes through the
engine for Think (slices 2b/2c/2e); Phase 3 is the deeper, Think-heavy
surface. Sliced like Phase 2 — lowest-risk extraction first, behavior change
behind a changeset second:
- Slice 3a — shared stall-watchdog primitive (zero behavior change).
Extracted
Think's_iterateWithStallWatchdog+ChatStreamStalledErrorverbatim intopackages/agents/src/chat/stall-watchdog.ts(the function never touchedthis, so it lifts cleanly to a free function).Thinkimports both fromagents/chat; the two call sites and the twoinstanceof ChatStreamStalledErrorread-loop catches are unchanged in behavior (the thrown error is the same imported class, soinstanceofstill holds). Layer-2 unit test added (disabled-passthrough, fast-source passthrough, stall→throw+onStall, consumer-break→source-cancel). This is the shared foundation slice 3b wiresAIChatAgentonto. - Slice 3b — converge
AIChatAgentonto the stall watchdog (behavior change + changeset). WiredAIChatAgent's SSE stream loop throughiterateWithStallWatchdogbehind a new opt-inchatStreamStallTimeoutMsfield (default0= off, matchingThink). When a stall fires andchatRecoveryis enabled,_routeStallToBoundedRecoveryopens/reuses the incident under the turn's recovery identity and schedules a_chatRecoveryContinue(or delivers terminal UX once the budget is spent); with recovery off the stall stays a terminal stream error. Decision: kept the watchdog opt-in (off by default) in BOTH packages rather than defaulting it on with recovery — convergence on the mechanism, not a forced timeout value users must tune above their slowest legitimate inter-chunk gap. Changeset added (@cloudflare/ai-chatminor). - Slice 3c — shared non-chat fiber dispatch seam (zero behavior change).
Added the optional
tryHandleNonChatFiberRecovery(ctx)adapter hook +ChatRecoveryEngine.handleNonChatFiber(ctx).Thinknow routes its messenger/workflow reply-fiber dispatch (_messengerRuntime.handleFiberRecovery) through the engine seam at the top of_handleInternalFiberRecoveryinstead of calling the runtime directly;AIChatAgentcalls the same seam (a structural no-op — it omits the hook, so every fiber stays a chat candidate). The engine now owns the ordering invariant (non-chat dispatch BEFORE the chat-fiber gate); the behavior stays adapter-owned. 3 Layer-2 fake-adapter tests (hook consumes →true; hook declines →false; hook omitted →false).FiberRecoveryContextis importedimport typefrom../index(same package, erased — no cycle). This is the structural precondition for a future shared fiber-recovery dispatch skeleton. - (Deferred to Phase 4 — these are already correctly adapter-owned; the
remaining value is deduplication, not new seams.) The deep surface map
(Phase 3 mid-point) confirmed durable submissions, agent-tool child-run
reconcile, and resume-ACK orphan persist are each already package-private
and invoked at the right points (submission drain in onStart; reconcile in
the recovery-callback
finally; orphan persist on the reconnect-ACK path). Forcing them behind engine seams now would add indirection without removing duplication, so they move to Phase 4 where the engine grows the fiber-recovery dispatch skeleton and the duplicated bodies collapse:- Durable submission lifecycle hooks (engine provides;
AIChatAgentno-ops) — preserved today, deduped in Phase 4. - Agent-tool child-run reconcile after recovery completes — preserved
today (both call it in the callback
finally), deduped in Phase 4. - Resume-ACK orphan persist sharing the adapter orphan writer with fiber recovery — preserved today, unified in Phase 4.
- Tool rollback — preserved (Think-owned), no seam needed.
- Session persistence — preserved (Think-owned), no seam needed.
- Durable submission lifecycle hooks (engine provides;
Exit criteria:
- Existing Think recovery tests pass. ✅ (686 + e2e green after 3a/3b/3c)
- Durable submissions still recover correctly. ✅ (unchanged; covered by suite)
- Stall recovery still works. ✅ (3a/3b; existing Think stall coverage preserved)
- Tool rollback tests still pass. ✅ (unchanged)
Once both packages are wired through the engine:
- Remove duplicated incident state-machine code.
- Remove copied helpers that are now owned by the engine.
- Keep thin package methods only where they are public hooks, scheduled callback entry points, or adapter behavior.
- Update comments that currently say helpers mirror the other package.
The Phase 4 surface was mapped precisely before any code change. The duplication falls into four bands, ordered here from mechanical/safe to behavioral/risky so each slice ships with its own review + e2e gate before the next begins:
-
Slice 4a — shared types + pure key/sweep helpers (mechanical, zero behavior). Both packages re-declare the
ChatRecoveryIncidenttype (think.ts:623–675,index.ts:111–157) when a canonical one exists inagents/chat/recovery-incident.ts; both re-implement_chatRecoveryIncidentKey(100% dup ofchatRecoveryIncidentKey) and_sweepStaleChatRecoveryIncidents(inlinesselectStaleIncidentKeys). Replace local copies with the shared symbols. Pure type/identity/selection — no control-flow change. -
Slice 4b — centralize the "schedule a recovery callback" triplet. ✅ DONE. The block
updateIncident("scheduled")+_emit("chat:recovery:scheduled")+schedule(callback, …, chatRecoverySchedulePolicy("initial"))appeared at 7 call sites (AIChat: stall + 3 fiber; Think: stall + 2 fiber). Added one engine method (engine.scheduleRecovery({ incident, recoveryKind, callback, data, reason? })) that owns the transition + emit + idempotent schedule behind a newChatRecoveryAdapter.scheduleRecoveryhook, and routed every call site through it.recoveryKindis explicit (AIChat's lost-partial branch reportsretryover acontinueincident). Behavior-preserving; collapses the most-copied block first so the later body-collapse is smaller. See the Slice 4b progress-log entry. -
Slice 4c — move the stable-timeout RESCHEDULE behind the engine. ✅ DONE (re-scoped).
_rescheduleRecoveryAfterStableTimeoutis byte-identical between the packages (100% dup): read incident → if under the attempt cap, bumpattempt, setstatus:"scheduled"+reason:"stable_timeout_retry", and issue a non-idempotent delayed schedule. Lifted intoengine.rescheduleAfterStableTimeout, with theChatRecoveryAdapter.scheduleRecoveryhook generalized to carry adelaySeconds(the 4b triplet now passes0; the reschedule passesCHAT_RECOVERY_STABLE_RETRY_DELAY_SECONDS). Re-scope note: the original 4c also bundled the give-up seal (_exhaustRecoveryGiveUp/_exhaustRecoveryAfterStableTimeout, ~80% dup). That spine interleaves package-specific terminalization (_exhaustChatRecovery), stream-id resolution, and partial-text reads behind subtle exactly-once/ordering invariants (#1730/#1645). Lifting it needs ~5 new adapter hooks that are exactly the terminalize + stream surface Slice 4d already builds for the body-collapse — building them twice risks inconsistent seams — so the give-up spine moves to 4d (below), where those hooks live. Same judgment as the Phase 3 re-scope: defer an entangled item to where its seam naturally belongs. -
Slice 4d — the wake + terminal-UX paths (the big one), split in two after a full read of both bodies. Reading the two
_handleInternalFiberRecoverybodies in full (think.ts ~265 lines; index.ts ~245 lines) changed the picture: they are ~70% structurally similar, but the similar part is mostly control flow (chat gate, requestId parse, snapshot unwrap, begin-incident, exhausted→exhaust, onChatRecovery invocation, catch→failed) while the meaty logic has legitimately DIVERGED — streamStatus tracking (Think) vs not (AIChat); different recovery-kind detection helpers; different persist gates; different stream-completion APIs; and especially the retry/continue/skip decision (Think's submission lifecycle + terminal-record + session-leaf vs AIChat's flat-messages leaf + lost-partial third branch). Even the_partialHasSettledToolResultshelper has drifted (Think delegates to_toolPartHasSettledResult; AIChat inlines a different state check). So 4d splits:-
Slice 4d-1 — lift the give-up spine (the part deferred from 4c). ✅ DONE.
_exhaustRecoveryGiveUp/_exhaustRecoveryAfterStableTimeoutare ~80% identical: resolve config → key fromdata.incidentId→ best-effort readstored(tolerate failure, synthesize) → re-entry guard (stored.status==="exhausted"⇒ return) → build the exhausted incident → resolve streamId + partial →_exhaustChatRecovery(terminalize) → best-effort seal write. Lift this spine intoChatRecoveryEngine.exhaustRecoveryGiveUp({ callback, data, reason })behind hooksexhaustChatRecovery,resolveRecoveryStreamId,getPartialStreamText,activeChatRecoveryRootRequestId, andonGiveUpBookkeepingError. Divergences parameterize cleanly: Think passesreason(stable_timeout|recovery_error) and its root-id chain includesrecoveredRequestId; AIChat hardcodesstable_timeoutand never setsrecoveredRequestId(so a unified chain —originalRequestId ?? recoveredRequestId ?? activeRoot ?? stored.root ?? stored.requestId ?? ""— collapses identically there). Moderate risk; its hooks are exactly what 4d-2 needs, so this lands first and de-risks 4d-2. -
Slice 4d-2 — lift the wake FRAME, keep the decision a package-owned seam (the genericity seam). Owns the Phase 5 goal: a third (pi) adapter must drive deploy/crash recovery through the SAME engine, so the wake dispatch frame has to live in the engine, not be re-implemented per package. The value here is genericity, not dedup — the bulk of the logic stays package-owned in the decision hook; what the engine gains is a single, reusable wake lifecycle.
Engine frame:
ChatRecoveryEngine.handleChatFiberRecovery(ctx, wake), called by each package's_handleInternalFiberRecoveryright afterhandleNonChatFiber. Lifecycle: chat-fiber gate, requestId parse, snapshot unwrap, stream/partial resolution, recovery-kind classification,beginIncident, exhausted branch (persist-gate +exhaustChatRecovery),onChatRecoveryinvocation, persist + complete, decision, thencatch → updateIncident("failed") → rethrow. The engine owns the control flow, incident lifecycle, and exactly-once ordering invariants; it does NOT own the decision.Hook surface: cohesive hooks named for responsibility, not micro-hooks. They live on a SEPARATE
ChatFiberWakeHooks<TClassify>object passed as the second arg tohandleChatFiberRecovery(ctx, wake), NOT bolted onto the baseChatRecoveryAdapter— that keeps the incident/give-up adapter (and its five existing unit-test fakes) focused, and the classification detail is a method-scoped genericTClassifyinferred fromwake(no class-level generic, noany/unknowncasts). The hooks:chatFiberPrefix()— the fiber-name prefix that gates the chat path.unwrapRecoverySnapshot(ctx)— returns{ snapshot, recoveryData }.resolveRecoveryStream(requestId)— returns{ streamId, streamStillActive, streamStatus? }.classifyRecoveredTurn(input)— returns{ recoveryKind, detail: TClassify }.invokeOnChatRecovery(...)— builds the package'sonChatRecoveryctx and calls the user hook.shouldPersistOrphanedPartial(...)— the base persist gate; the engine owns the sharedoptions.persist !== false || partialHasSettledToolResultsclause.persistOrphanedStream(streamId)andcompleteRecoveredStream(streamId).dispatchRecoveredTurn({ ..., detail: TClassify })— the decision hook; the whole retry/continue/skip body. Think runs its submission lifecycle + terminal-record + session-leaf logic and_handleRecoveryCallbackError; AIChat runs its flat-messages leaf + lost-partial branch; pi runs its own.
partialHasSettledToolResults, reconstructed-from-give-up, andexhaustChatRecoverystay on the base adapter (shared with the give-up spine). The classify/dispatch pair is named to line up with the Turns/Actions RFCs'classifyRecoveredTurnseam: theirrecovery-continue/recovery-retryre-entry runs throughdispatchRecoveredTurn, which for Think will eventually delegate to_admitTurn.Converge only where the difference is provably incidental (refined after the full code read): the drifted
_partialHasSettledToolResultsis behaviorally identical across the packages (AIChat inlines exactly what Think's_toolPartHasSettledResultchecks:output/resultpresent, or state inoutput-available/output-error/output-denied), so it lifts to one shared purepartialHasSettledToolResults(parts)inagents/chat— both packages drop their private copy, a real dedup with zero behavior change. NOT converged:streamStatus/ terminal-stream handling stays absent for AIChat (itsresolveRecoveryStreamreturnsstreamStatus: undefined, so the terminal branches are dead there) — making AIChat read status would be a behavior change, the exact inversion the "substrate capabilities are optional" decision forbids. The submission-coupled / Session-leaf parts are NOT converged — see that decision under Genericity.Highest risk (the wake path; #1631/#1691/#1645 + submission-completion correctness) — its own e2e gate, and the seam above is reviewed before the wake path is touched. Success criterion: the engine frame reads as a linear lifecycle and each hook is cohesive; AIChat's
dispatchRecoveredTurnis leaf-only (no submission ops); pi can implement every hook. Fallback (recorded either way): if the hook surface still makes the engine LESS readable than two self-contained methods, land only the shared pure-helper extraction (partialHasSettledToolResults, theonChatRecoveryctx builder) plus a documented decision to keep the per-package decision bodies.Locked decisions (2026-06 review, before implementation):
- Ambition: attempt the full frame-lift; drop to helper-extraction-only ONLY if the hook surface measurably hurts engine readability.
- Hook names: align with the Think Turns/Actions RFCs —
classifyRecoveredTurn(recovery-kind) +dispatchRecoveredTurn(the retry/continue/skip decision);resolveRecoveryStreamfor stream/status. So_admitTurn(Turns RFC) and the action ledger (Actions RFC) attach to these exact names; the only correction to those RFCs is the adapter type (ChatRecoveryAdapter, notThinkRecoveryAdapter). - Gate: does not start until this seam is human-reviewed (in progress).
- Phase 5 pi: validated via an internal
experimental/fixture scaffolded from the pi shape in "Genericity and future harnesses" — sequenced after 4d-2.
-
-
Slice 4f — lift the remaining duplicated chat leaf machinery. The 2026-06 extraction map (see "Chat-layer extraction map") found a cluster of near-identical leaf helpers still copy-maintained across both packages, outside the recovery orchestration engine. Two sub-groups with different risk profiles — keep them separate so the safe lifts are not held back by the behavior-sensitive ones.
Verify-first gate (applies to every item). Do NOT lift on the strength of the prose below — diff the two implementations at execution time first. If they are byte-equivalent (modulo comments), lift as a pure/glue helper with zero behavior change. If they have drifted, STOP and treat it as a convergence: pick the correct behavior, write it down, and ship a changeset. This is the discipline that made 4d-2/4e safe; line numbers below are as of 2026-06 and will drift, so trust the names and re-diff.
4f-i — byte-verified pure leaf lifts (zero behavior, no changeset). ✅ DONE. Lift each into
agents/chatwith a thin per-package binding (free functions takingPick<DurableObjectStorage, …>or a small param, exactly like 4e'ssweepStaleChatRecoveryIncidents/readChatRecoveryProgress). Items marked ✔verified were diffed during the 2026-06 confidence review and confirmed byte-identical; the rest were re-diffed at execution time (verify-first gate) and all eight passed. Landed inagents/chat: newconnection.ts(sendIfOpen/isWebSocketClosedSendError— also dedupedcontinuation-state's third copy);message-builder.getPartialStreamText;tool-state.{hasIncompleteToolBatch,partAwaitsClientInteraction,clientResolvableToolNames,toolPartName};resumable-stream.{STREAM_CLEANUP_DELAY_SECONDS,cleanupStreamBuffers};recovery-incident.{recordChatTerminal,clearChatTerminal,pendingChatTerminal,buildChatRecoveringFrame,setChatRecovering}. Both packages are now thin bindings; the recovering set/clear and recovering frame thread their wire-type enum + broadcast wrapper as params (the only per-package divergence). See the Slice 4f-i progress-log entry.- Duplicated constants ✔verified.
CHAT_RECOVERING_KEY,CHAT_LAST_TERMINAL_KEY,CHAT_RECOVERING_FLAG_TTL_MSare already exported fromrecovery-incident.ts(109,115,166) and re-barrelled bychat/index.ts, yet ai-chat still redefines them locally (index.ts:162,169,178) and Think likewise (think.ts:679,685+ terminal/recovering keys). Confirmed all values match the shared copies (CHAT_RECOVERING_FLAG_TTL_MS = 15 * 60 * 1000andSTREAM_CLEANUP_DELAY_SECONDS = 10 * 60are identical in both packages), so importing them is a no-op on storage keys and timing — no migration risk. Delete the local copies and import the shared ones (same fix as 4e'sCHAT_RECOVERY_PROGRESS_KEY).STREAM_CLEANUP_DELAY_SECONDS(ai-chat192, think699) was not yet in the shared barrel — now exported fromresumable-stream.ts. ✅ In practice both packages no longer reference theCHAT_*keys directly at all (every use went through a lifted helper), so the local key constants were simply deleted rather than re-imported. sendIfOpen/isWebSocketClosedSendError(ai-chat199–214, think239–254) — byte-identical WS send guard. Lift to a sharedagents/chathelper.- Terminal KV trio
_recordChatTerminal/_clearChatTerminal/_pendingChatTerminal(ai-chat3845–3866, think11131–11155) — byte-identical, sameCHAT_LAST_TERMINAL_KEY. Lift as free fns over a storagePick; leave each package a one-line binding. _hasIncompleteToolBatch✔verified (ai-chat2481–2513, think10971–11003) — confirmed byte-identical (comment included). Lift to a shared pure fn. This is the one piece the auto-continuation convergence consumes — ship it here so that slice can build on it. (It is a prerequisite for the convergence, not the bulk of it — see that behavior decision.)- Client-interaction predicates ✔verified
_partAwaitsClientInteraction/_clientResolvableToolNames/_toolPartName(ai-chat2230–2265, think9367–9418) — confirmed byte-identical (only docblock prose differs). Lift to shared pure fns. Note_clientResolvableToolNamesreadsthis._lastClientTools, so the shared fn takes the client-tool list (or the resolvedSet) as a param. Keep each package's higher-levelhasPendingInteraction/hasPendingClientInteractionwrappers local — the broad-vs-client-only asymmetry lives in the wrappers (both already call the identical leaf), so it is Tier-3 package surface. _getPartialStreamText(ai-chat4646–4671, think10707–10732) — structurally identical; already wired through the engine'sgetPartialStreamTexthook. Lift to a shared fn over the resumable-stream chunk reader.- Stream-cleanup pair
_ensureStreamCleanupScheduled/_cleanupStreamBuffers(ai-chat1446–1474, think11368–11394) — near byte-identical alarm scheduling- buffer cleanup (#1706). Lift with the schedule / clear primitives as params.
- Recovering-flag
_setChatRecovering(ai-chat3967–3997, think11224–11255) + the recovering-frame builder (_buildRecoveringConnectFrameai-chat3938–3957; the recovering slice of Think's_buildIdleConnectMessages11283–11296) — near byte-identical; differ only in the broadcast frame's message-type enum (CF_AGENT_CHAT_RECOVERINGvsMSG_CHAT_RECOVERING). Lift with the message type + broadcast fn passed as params. The_broadcastChat/_broadcastChatMessagewrappers themselves stay package-local (they are theexclude+_pendingResumeConnectionsmerge) and are threaded in as the broadcast-fn param — do not try to lift them in 4f.
4f-ii — behavior-sensitive convergences (own review + likely changeset; NOT in the zero-behavior bucket). These look like dedup but are not pure lifts; each is a convergence that can change observable output and so gets its own slice, review, and (where output changes) a changeset.
-
ai-chat's local
enforceRowSizeLimit(_enforceRowSizeLimit) vs the sharedenforceRowSizeLimit(agents/chat/sanitize.ts, which Think uses). ✅ DONE (Slice 4f-ii(a)). The verify-first diff confirmed this was not a byte-identical lift: the two had drifted in two independent ways and we converged each onto the better behavior (changeset on both packages). (a) Tool-output compaction shape — ai-chat replaced an oversized tool output with a flat english summary string ("This tool output was too large to persist… Preview: …"), discarding shape, while the shared fn (Think) used the structured, shape-preservingtruncateToolOutput; structured wins (a model can keep reasoning about a shape-preserving truncation, and a flat string is strictly lossier), so ai-chat now usestruncateToolOutputand its old summary string is gone (user-visible persisted-row change → ai-chat changeset). (b) Compaction annotations + warnings — ai-chat annotatedmetadata.compactedToolOutputs/compactedTextPartsandconsole.warned while Think did neither; annotate + warn on both (the annotations let a client tell a stored message was compacted, and are cheap/additive), so Think now emits them too (additive metadata + a warn → Think changeset). Implemented by extending the sharedenforceRowSizeLimitto own both the structured compaction and the annotations, plus an optionalwarnhook so each package keeps its own log prefix ([AIChatAgent]/[Think]); both packages are now thin bindings (ai-chat's_enforceRowSizeLimit; Think's_rowSafe). ai-chat'srow-size-guard.test.tsassertions that pinned the old summary string were repointed at the structured... [truncated N chars]marker; Think's row-size tests were already structure-shaped and unchanged. See the Slice 4f-ii(a) progress-log entry. -
ai-chat's inline protocol parse vs the shared
parseProtocolMessage(agents/chat/parse-protocol.ts). ✅ DONE (Slice 4f-ii(b)). ai-chat'sonMessagewrapper now classifies viaparseProtocolMessageand dispatches on the typedevent.typediscriminants instead of inlineJSON.parse+data.type === MessageType.Xchecks; the eight handler bodies are kept verbatim (data.→event.), in particular themessagesevent still persists the client snapshot (Think no-ops it). Verified behavior-preserving: the wire strings in ai-chat'sMessageTypeandagents'CHAT_MESSAGE_TYPESare byte-identical for all eight incoming types, so the parser recognizes exactly what the inline switch did; the non-POSTuse-chat-requestfall-through to the consumer'sonMessageis preserved by gating the delegate on!(event.type === "chat-request" && event.init.method !== "POST")(equivalently: only the POST branch enters the handler). No changeset — internal dispatch refactor, no user-visible behavior change for well-formed traffic (the parser is in fact slightly more robust for malformed frames: it defaults a missinginitto{}rather than throwing, and defaults a missingtoolNameto""). See the Slice 4f-ii(b) progress-log entry.
4f-i items are independently revertible; land them as small commits (or one tightly-scoped slice) with the 4e verification bar —
pnpm run check, agents / ai-chat / think suites, local SIGKILL e2e for both packages — zero behavior change, no changeset. 4f-ii items each get a dedicated slice with the verify-first gate above. 4f does not depend on 4d-2; it can run any time after 4e. - Duplicated constants ✔verified.
Each slice: deep review for edge cases, run the local + (where auth permits) real e2e suites, commit with a detailed message. Slice 4d does not start until 4a–4c are green.
Exit criteria:
- Code search shows no duplicated
_beginChatRecoveryIncidentstyle engines in both packages. agents/chatowns the recovery policy.AIChatAgentandThinkown package behavior.
Build an internal pi adapter (and a small pi codec) that runs on the shared engine. This is the genericity proof, not a product.
Phase 5 is also the forcing function for the Tier-2 extractions in the
"Chat-layer extraction map" — the resume/reconnect handshake and the streaming-loop
codec. Drive those extractions through the pi adapter rather than before it: pi's
non-AI-SDK wire vocabulary and transcript shape are exactly what prove the seams are
not UIMessage-coupled. (Run Slice 4f and the auto-continuation convergence first;
they are independent of Phase 5 and shrink the surface pi has to reason about.)
Work:
- Add an internal pi fixture under
experimental/(no published package). - Implement a
PiRecoveryAdapterandPiRecoveryCodecover pi'sAgentMessage[]transcript and agent-event stream. - Run the shared engine unit suite and a recovery subset against the pi adapter.
- As pi forces the seams, extract the Tier-2 subsystems into
agents/chat: the resume handshake (notify → REQUEST → ACK → replay, terminal replay, recovering frame) behind a wire-vocabulary + idle-payload adapter, and the streaming codec (chunk apply, start-id alignment, progress gating) behindChatRecoveryCodec/PiRecoveryCodec. Fold the corrections back into the AI SDK adapter and codec. - Record any place where the engine or adapter leaked a
UIMessage-shaped assumption, and fix the seam.
Exit criteria:
- The pi adapter recovers a deploy/crash mid-stream through the same engine.
- No engine change was required that is specific to
UIMessage. - Any seam corrections are folded back into the AI SDK adapter and codec.
- If a Tier-2 seam could not be cleanly shared for pi, the reason is recorded (it signals the seam shape is wrong) rather than forced.
Status — DONE. P5-1 (pi proof + harness), the Tier-2 extraction (resume handshake +
streaming codec into agents/chat, commit 038e6d23), the second TanStack/AG-UI harness
(foreign client transport + foreign chunk vocabulary), the tool-parts codec path, and the
RecoveryPartial agnostic-seam refactor have all landed — see the Progress log. The historical
detail below is preserved as the original P5-1 record; "Still open" at the end of this section is
superseded by the Progress log's current open items (a real Workers AI provider run; the deferred
Tier-2 start-id alignment onto the codec — the progress-bump timing convergence has since
landed). The
experimental/pi-recovery/ fixture drives the real @earendil-works/pi-agent-core
Agent (verified to bundle + run in workerd; deterministic via pi-ai's
registerFauxProvider) on the shared engine, and a real wrangler dev SIGKILL
crash-mid-stream e2e passes with no UIMessage assumption (the snapshot builder was
generalized off UIMessage to SnapshotMessage). The pi adapter takes the same
stream_continuation path as the AI SDK adapter: the codec reconstructs the partial
from the durable buffer, the engine PRESERVES it
(shouldPersistOrphanedPartial → streamStillActive, classifyRecoveredTurn → "continue"), and the recovered turn regenerates only the remaining suffix through pi's
real continue(), merging it onto the survived prefix. The e2e asserts continuation,
not regeneration: recoveredVia === "continue", prefixChars > 0, 0 < generatedChars < total, and prefixChars + generatedChars === total. Full regenerate
(classifyRecoveredTurn → "retry") remains only as the fallback when no partial
survived. Seam-difference correction: the earlier "pi can only regenerate; it has no
mid-assistant resume" note was an artifact of the first text-only codec, not a pi
constraint. An analysis of Flue — itself a
pi-based harness (its session.ts/submission-state.ts import @earendil-works/pi-ai)
— confirms the richer path is the norm on this substrate: Flue persists streaming
deltas (StreamChunkWriter), reconstructs the partial (recoverInterruptedStream), and
continues from it (stream_continuation), plus preserves completed tool results across
a mid-batch interruption (tool_results_partial), under a leased submission execution
store. The shared engine supported pi's continuation with no engine-side change.
(Originally "still open: lifting the resume handshake + streaming codec into agents/chat" —
since DONE in the Tier-2 extraction, commit 038e6d23; see the Progress log entry.)
Scoping conclusion — the engine is message-generic but substrate-coupled (and that
is correct). An inverse analysis (could Flue drop its in-house recovery and adopt this
engine?) sharpened where the genericity actually lives. The half that ports is the
message model: RecoveryPartial = { text, parts } + SnapshotMessage + the codec
seam carry pi's (or any) transcript with no UIMessage leak — the thing P5-1 set out to
prove. The half that does not port is, deliberately, the substrate:
- Trigger. The engine is entered through
handleChatFiberRecovery(ctx: FiberRecoveryContext, …)— the Agents-SDK fiber-recovery wake (cf_agents_runs,_runFiberWithStashWrapper,wrap/unwrapChatFiberSnapshot). A host without fibers (Flue uses a lease/queue + recover-on-start drain) would have to synthesize aFiberRecoveryContextand bypass the stash machinery — adopting the orchestration but not the trigger. - Scheduler.
chatRecoverySchedulePolicyencodes DO-alarm semantics (the non-idempotent stable-timeout reschedule exists becausealarm()deletes the executing one-shot row only after the callback returns). That invariant does not map to a Node/Postgres scheduler. - Persistence.
ResumableStreamis DO-SQLite (this.sql) and the adapter storage isctx.storage; a multi-backend host (Flue's@flue/postgres) would reimplement the buffer. - Lease layer. A multi-owner execution store (Flue's
owner_id/lease_expires_at/attempt_count) sits below this engine; the engine assumes a single-DO actor and idempotentschedule(). They are complementary layers, not substitutes.
So the engine is correctly factored as "message-agnostic recovery for the Agents-SDK fiber + DO-alarm substrate," not a general-purpose durable-recovery library — matching the "substrate capabilities are optional" stance. The reusable kernel shared with a pi-based host like Flue is the codec + partial-reconstruction + continue-vs-regenerate concept, which both already implement and both inherit from the pi substrate rather than from each other.
API-ergonomics findings (from building the pi adapter) — direct Tier-2 input. Writing
a third, from-scratch, non-AI-SDK adapter surfaced where the ChatRecoveryAdapter /
ChatFiberWakeHooks seam is mis-cut. The signal is strong because the same boilerplate is
duplicated identically across all three hosts (ai-chat, think, and the pi
fixture), not just the fixture — so these are seam-shape corrections, not fixture quirks.
They should drive the Tier-2 extraction (lifting the resume handshake + streaming codec
into agents/chat). Ranked by payoff:
- Move the progress-bump PREDICATE onto the codec (not a blind fold into the chunk
path). Every host hand-wires
bumpChatRecoveryProgressinto its stream loop (ai-chat_maybeBumpRecoveryProgress;thinkat the_shouldFlushRecoverableChunkdecision; the pi fixture inline in_onPiEvent) and each carries a comment re-explaining the invariant. The naive fix — haveResumableStream.flush/storeChunkbump on every durable chunk — is wrong: the bump is deliberately gated by a host/codec-specific "genuinely new produced content" predicate, NOT "any chunk." ai-chat bumps only ontext-start/reasoning-start/ settled tool input-output chunk types (never pertext-delta); think bumps only when_shouldFlushRecoverableChunkelects to flush; pi (coarse events) bumps per event. Folding it intoResumableStreamblind would write storage per token and silently change ai-chat's semantics. The real simplification is to put the predicate on the codec (e.g.ChatRecoveryCodec.isProgressChunk(chunk)) and bump when storing a chunk the codec marks as progress — which belongs WITH the Tier-2 codec extraction, not as a standalone change. (Verified 2026-06: the bump is chunk-type-gated in ai-chat and flush-gated in think, so this is behavior-sensitive.) - Collapse stream resolution to one seam (a verify-first convergence, not a no-op).
ChatRecoveryAdapter.resolveRecoveryStreamId(requestId): string(give-up path) andChatFiberWakeHooks.resolveRecoveryStream(requestId): ResolvedRecoveryStream(wake path) both resolve a turn's stream id, and every host implements both. They have drifted: the give-up_resolveRecoveryStreamIduses an in-memorygetAllStreamMetadata().find(...)(first match), while the wake_resolve*RecoveryStreamusesSELECT … WHERE request_id = ? ORDER BY created_at DESC LIMIT 1(newest row) and additionally computesstreamStillActive(+streamStatusfor think). Collapsing to one method —ChatRecoveryAdapter.resolveRecoveryStream(requestId): ResolvedRecoveryStream, used by both the wake path and the give-up path (which reads.streamId) — is the right shape, but it inherently converges the give-up lookup from.find()onto the newest-row query. Identical in the common case (one stream per request id); differs only when multiple stream rows share a request id (across recovery attempts), where newest-row is the more correct "whatever partial the turn produced." Treat as a verify-first slice (4f-ii-style), likely with a changeset, not a pure dedup. - Provide an engine-owned exhaustion helper. The give-up choreography
(
buildChatRecoveryExhaustedContext→notifyChatRecoveryExhausted→recordChatTerminal→setChatRecovering(false)) is hand-assembled identically in each host (~30 lines). A singlerunChatRecoveryExhaustion(incident, config, partial, …, { emit, broadcast, storage })would absorb it, leaving the host to supply only the emit/broadcast primitives. The duplicatedsetChatRecoveringoption bag ({ storage, messageType, broadcast, now }, constructed in two places per host) folds into the same helper. (Since closed —runChatRecoveryExhaustion(input, { emit, onExhausted?, onError, terminalize })lands in commitb62241e9. The one correction to the sketch above: the helper takes the host'sterminalizeclosure rather than raw{ broadcast, storage }, because owning those directly would force a single terminal-write ORDER and erase the hosts' then-divergent broadcast/persist ordering. That ordering was instead converged deliberately (ai-chat → broadcast-first, commit66e7a790, with a changeset) as a separate slice. ThesetChatRecoveringoption-bag dedup landed too, via a host-side_setChatRecoveringwrapper. See the two newest Progress-log entries.) - Hand the decoded partial back to
persistOrphanedStream. The engine already decodes the buffer for classification (getPartialStreamText), then the host decodes the same buffer again to preserve the partial message. Passing the decodedRecoveryPartial(or a codec handle) intopersistOrphanedStreamremoves the second decode. - Drop the redundant classify
detailwhen it mirrorsrecoveryKind. The pi adapter threads{ continueFromPartial }that is identical torecoveryKind === "continue", thendispatchRecoveredTurnre-derives it and re-maps to a callback name. Either letrecoveryKinddrive the callback selection, or stop requiring a customTClassifyfor the common continue-vs-retry case. - Make trivially-no-op hooks optional with engine-side defaults. A host without
client tools / HITL / lifecycle callbacks still must implement
isAwaitingClientInteraction,invokeOnChatRecovery,onShouldKeepRecoveringError, and friends (the pi fixture's are all() => false/async () => ({})/ aconsole.error). The "smallest real adapter" the fixture is meant to measure is still ~15 methods; optional hooks would shrink it.
Two correctness caveats the fixture also exposed, to record honestly:
- The codec's tool-
partspath is unproven on a non-AI-SDK substrate. A pi text turn always yieldsparts: [], so the settled-tool persist gate was only ever exercised through the AI SDK adapter. A turn carrying tool calls would be needed to prove the settled-tool seam end-to-end offUIMessage. _(Since closed — not in pi, but in the TanStack harness: it reconstructs AG-UITOOL_CALL_\*chunks into its own AG-UI-native tool parts and computeshasSettledToolResultsitself — no AI-SDKMessagePartfabrication — and proves the engine's gate keeps a settled-tool partial under{ persist: false }. The follow-upRecoveryPartialrefactor then made the engine seam agnostic by type, not just at runtime. See the two newest progress-log entries.)_ - The fixture's continuation is simulated, not pi-native.
_resumeRecoveredTurnrecomputes the full reply (replyFor) and primes the faux model withfull.slice(prefix.length); a real picontinue()would resume generation from the partial assistant message held in context. So P5-1b proves the engine's preserve/continue/merge plumbing is message-generic — not that pi natively prefilled. The claim should be stated at that scope.
Extend the existing local e2e suites (Layer 4) so the SIGKILL + persistent-state harnesses cover the converged behavior. This is the merge gate. Live deploy smoke (Layer 5) is an optional follow-up that can run nightly once fixtures stabilize.
Exit criteria:
- Extended SIGKILL e2e mid-stream recovery passes for
AIChatAgentandThink. - Terminal replay works after crash/disconnect in local e2e.
- Repeated crash churn over progressing work does not false-terminalize.
- Optional: live deploy smoke passes for any PR that changes engine behavior.
Audited whether the existing SIGKILL + persistent-state e2e suites cover the converged orphan-persist behavior (the (a)/(b)/(c)/(d) seams just landed). Coverage map for the orphan-persist path:
| Behavior | Workers-runtime test (durable-chat-recovery.test.ts) |
Real-wrangler dev SIGKILL e2e |
|---|---|---|
(a) reconstruct partial via StreamAccumulator |
✅ (implicit in all orphan cases) | ✅ chat-recovery (partialText > 0), chat-recovery-outcomes |
| (b) #1691 new-turn NOT merged into prior assistant | ✅ :1096, :1346 |
❌ (workers-only) |
| (b) continuation merges onto last assistant | ✅ :1152 |
chat-recovery continue test asserts assistantMessages === 1 |
| (c) early tool-approval persist → recovery dedup | ✅ :1267 (one tc-dup part) + reconcileOrphanPartial unit tests |
❌ (no client-tool/approval agent in the e2e worker) |
| (d) upsert → single durable message | ✅ | ✅ chat-recovery-outcomes (assistantMessages === 1) |
persist gate (persist:false drops plain text) |
✅ | ✅ chat-recovery-outcomes test 2 |
Re-ran the two ai-chat e2e files that drive the refactored _persistOrphanedStream
through a real process crash, post-refactor: chat-recovery-outcomes 2/2,
chat-recovery 3/3 (incl. the continue-path merge-to-one). Green. Think's
e2e was not re-run: this refactor touched only ai-chat's orphan path plus an
additive shared export — Think doesn't call reconcileOrphanPartial and its
_persistOrphanedStream is unchanged, so its prior-green parity is unaffected.
One documented gap, assessed acceptable (not a blocker): the (c)
tool-approval-early-persist → recovery dedup has no real-SIGKILL e2e — the
ai-chat e2e worker has no client-tool/approval agent. It is covered (i) at the
workers runtime level by durable-chat-recovery.test.ts:1267 (asserts a single
tc-dup part after an early persist + recovery replay), and (ii) by the
reconcileOrphanPartial unit tests. The dedup logic is runtime-independent (a
pure function over parts), so the only thing a real-SIGKILL e2e would add over
the workers test is the alarm-driven wake — which IS exercised generically for
the plain-text orphan path. Marginal value is low; harness cost (a HITL-approval
agent + crash-mid-approval flow) is high. Recommendation: leave it to the
workers + unit layer; add a tool-dedup SIGKILL e2e only if a regression ever
surfaces there. Phase-6 exit criteria for the converged orphan-persist behavior
are otherwise met.