Matt
90d · built 2026-09-08
Performance
What Matt shipped in the selected window, measured in ETV, and how it compares with the 90 days before it.
Effective capacity
+2580.0engineers
delivers like 2581.0 (2581.0x pre-AI)
Output (ETV)
77.4ETV
+211.8% vs 24.8 prior
Features share
34.7%
−14.6 pp vs prior window
Fixes share
8.8%
+2.9 pp vs prior window
Work mix
34.7% Features12.9% Maintenance33.5% Tests10.2% Docs8.8% Fixes
77 commits over 90 days, ending 2026-09-08.
Where this dev ranks
Percentile against the global top-100 leaderboard (all-time totals).
- By commits
- Top 96 %
- By Features share
- Top 40 %
Daily performance
Daily ETV, stacked by Features, Maintenance, Tests, Docs and Fixes.
Repository spread
Where this developer's commits land. Concentrated work (top1 > 80%) vs polymath spread (top1 < 30%).
| Repo | Commits | ETV |
|---|---|---|
| agents | 68 | 79.1 |
| cloudflare-docs | 8 | 2.2 |
Most impactful commits
Top 10 by ETV in the last 90 days.
- 8.4ETVfeat(agents): move sessions into a Lifecycle capability (#2196) * feat(agents): add sessions lifecycle capability * feat(agents): add sessions context and compaction * feat(agents): add bounded session media eviction * feat(agents): support host session media policies * feat(think): replatform sessions onto lifecycle capability * feat(ai-chat): persist messages with sessions capability * refactor(agents): replace experimental memory sessions * feat(sessions): own streamed attachment storage * feat(sessions): offload losslessly, bound hydration memory, split out context Sessions stored large content by truncating it and budgeted hydration by stored bytes, so a pointer row costing 8 MiB of memory was charged the ~100 bytes it occupies on disk. It also owned prompt assembly, which is not conversation storage. Storage. Every table is now WITHOUT ROWID with a composite key and no secondary index, ordered by a per-session `seq`, so a text append bills one row instead of two. The attachment reference table is (session_id, message_id, hash) and nothing else. An unchanged update writes nothing at all: no row, no FTS churn, no reference rewrite, no event. Offload. Media leaves the row at a size threshold wherever it appears, including `data:` URLs nested in tool output. Everything else, prose included, is offloaded largest-first only when the row cannot hold it, and a row that still does not fit raises SessionMessageTooLargeError. Nothing is truncated, and offloaded content reconstructs byte for byte. The aged-row maintenance pass applies that same policy, so a drained legacy row ends up exactly as if it had been written today. Memory. getRecentHistory charges each row its stored bytes plus, when reconstructing inline, the attachment bytes it re-inflates. That is the difference between a budget that bounds disk and one that bounds the isolate. Think and AIChatAgent both default to 32 MiB. Context. Blocks, frozen prompts, and the skill and search providers move to `agents/context`. Think declares them through a new configureContext() hook and reaches them through `this.context`; configureSession() keeps compaction and search. The Session handle stores messages and knows nothing about prompts. Hosts. AIChatAgent no longer loads the transcript in its constructor: the legacy lift and one bounded hydration run at start, the live array mirrors the change feed, and get-messages streams. Think reads pointers on its per-tool-result scan and no longer reads Sessions tables with raw SQL. Deleted: the synchronous storage aperture, the lossy eviction mode, sanitizeToolPairs, the token-counter plumbing, the per-field option thunks, and a duplicate copy of the sanitize helpers. Measured on a deployed worker with a real R2 bucket (examples/next/sessions-slam, 33 scenarios): one billed row per append, 1.6 MB and 5 MB text parts and a 3 MB tool output offloaded rather than cut, inline hydration stopping at 31.11 MiB against a 32 MiB budget, and 61.79 MiB of history streamed out of a 128 MiB isolate. Numbers are recorded in design/sessions.md. Claude-Session: https://claude.ai/code/session_01MXpoKqJrUFTqgmDrehDti4 * fix(agent-think): move its context block onto configureContext agent-think lives outside packages/ and examples/, so it was missed when the context system moved out of the Session handle. Its identity block is now declared through configureContext() and refreshed through this.context. Claude-Session: https://claude.ai/code/session_01MXpoKqJrUFTqgmDrehDti4 * fix(sessions): drop lifted legacy tables instead of keeping tombstones Renaming each lifted table to `*__lifted_v1` left every upgraded object storing its conversation history twice. A Durable Object tops out at 10 GB and gets uncomfortable well before that, so a 5 GB history plus its copy has nowhere to go. The copy already needs that space transiently, which is exactly why it must not be kept. Each source is now verified against its destination row by row, comparing the payload rather than just the key, and dropped only when every row arrived intact. A table that fails verification is left in place with a `session:migration:incomplete` event, so a partial lift keeps the only copy of its rows instead of destroying it. `assistant_sessions` and `assistant_fts` carry nothing the new schema needs, the registry being derived and the index rebuilt, so they are dropped outright. Think lifts `assistant_config` into its own table and now drops it too. AIChatAgent drops `cf_ai_chat_agent_messages` once every readable row has a copy, and its lift no longer reads the whole table into the isolate: order is read as ids alone, then bodies are fetched in windows bounded by rows and bytes, so a large transcript never lands in memory at once. Verified against real deployed storage by seeding a Think agent and an AIChatAgent on the pre-Sessions SDK and redeploying the identical worker built against this branch over the same objects. Think carried 5 rows and 67,184 bytes across with its branch topology and ids intact, AIChatAgent 4 rows and 53,765 bytes including a 53 KB inline image, both byte-identical, with no legacy or tombstone table left behind. Claude-Session: https://claude.ai/code/session_01MXpoKqJrUFTqgmDrehDti4 * refactor(sessions): one extraction rule, and drop the unused skill providers Storage classified payloads by content type: media left the row at a 32 KiB threshold, everything else only to keep the row under its budget. Reading a PDF through pi settles that this is backwards. A document arrives as plain tool-output text with no media type, so the rule optimised the small case and ignored the large one. Deduplication, the other argument for it, does not depend on type either. Extraction into the attachment tables does not make the database smaller: chunk rows live in the same Durable Object, inside the same 10 GB. Only R2 reclaims space. And billing counts rows written, not bytes, so rewriting a 500 KB row costs the same single row as a tiny one while extracting it costs four. So there is now one rule and no content types in it. A payload is extracted when a bucket is configured and it reaches `r2ThresholdBytes`, which is the only extraction that reclaims anything, or when the row cannot otherwise hold it, largest first, into chunks. `inlineThresholdBytes` is gone and the R2 threshold is the single number. The maintenance pass returns immediately without a bucket, because inline is then the correct resting place. Separately, the skill-provider path is deleted: `R2SkillProvider`, `isSkillProvider`, the load and unload skill state on `ContextBlocks`, the `load_context` and `unload_context` tools, and the helpers that replayed that state from the transcript. Nothing ever registered a provider with a `load()` method. Think shipped its own Agent Skills through `agents/skills` before this path had a user and registers a plain readonly catalog block, so the tools never appeared and the history scan always returned immediately. A dead prompt line telling the model to use context-loading tools went with it. `agents/context` drops from 1,387 lines to about 880. Think's media eviction is untouched. It decides what the model sees, which is a different question from where bytes live. Claude-Session: https://claude.ai/code/session_01MXpoKqJrUFTqgmDrehDti4 * refactor(sessions): drop the R2 tier and the aged-row maintenance pass Sessions is a message store, not a file store. A message can reference a file without being one, and hosts that handle files already have somewhere to put them: Think's Workspace spills to R2 at 1,500,000 bytes, the same threshold the session tier used, so the two were doing one job twice. With R2 gone there is no reason to extract a payload eagerly at all, because chunk rows live in the same Durable Object and never reclaim a byte. The rule is now one sentence with no configuration: a payload stays in its message row until the row cannot hold it, and then the largest payloads are chunked out until it fits. That takes the aged-row maintenance pass with it. Its only remaining job was draining rows into R2, so the pass, its scheduler, its backlog chaining, the `offload_candidate_bytes` column and the four core methods that stamped and read it are all gone. Think never used it: it disables the pass whenever its own media eviction is on and drives that from a truncated hydration read. Also removed: the R2 bucket port, key construction and cleanup, the declared-versus-unknown-length streaming split and `FixedLengthStream`, the `backend` and `r2_key` blob columns, and the in-memory bucket fakes three packages carried to observe a tier that no longer exists. `SessionsAttachmentOptions` goes from seven fields to three. Think's media eviction is untouched in behavior. It decides what the model sees, which is a different question from where bytes live, and it is what moves bytes to the Workspace. The sessions-slam example is deleted along with the measured table it fed, so the docs no longer quote numbers nothing can reproduce. Found in passing: ai-chat's test worker was inserting into a column renamed some time ago, which surfaced as seven swallowed unhandled rejections in a passing run. Adds design/context.md, recording that prompt assembly and history shaping belong in agents/context while retention stays with the host that owns the file store, and that shaping tool output at the boundary is the missing third piece (cloudflare/agents#2201). Claude-Session: https://claude.ai/code/session_01MXpoKqJrUFTqgmDrehDti4 * feat(sessions): keep attachments out of the message, add intake shaping Media declared with a non-text media type and carried inline is stored separately, addressed by SHA-256, and inlined again on read. The message keeps a pointer and its mediaType, so a round trip is exact and a row stays small however large its payloads are. Read with { attachments: "pointer" } to see references instead. The rule is typed rather than sized. An image is extracted at any size and text is never extracted at any size, so a message's stored shape never depends on how large an image happened to be. Row chunking stays as the independent size backstop for prose: the two never interact, because media leaves before the row is measured. This is not the layer that was removed. That one extracted only when a row was over budget, which made it a rescue mechanism competing with chunking and one that could fail with nothing to extract. 775 lines replace 1,477. Payload lifetime is derived from reference rows, taken from the stored message rather than from what a given write extracted, so a pointer-mode read written back keeps its payload alive. SessionRowStat.bytes charges each message for what it points at, at inlined size, so a byte budget still bounds real hydrated memory. Cost, measured: a 200 KB image bills four rows and a 2 MiB image five, against one when inlined. Text messages are unchanged at one row. Also adds agents/context intake shaping. shapeMessage/shapeHistory cap oversized tool results with a continuation hint and drop host-named duplicate fields, on the read path so storage stays lossless. The limits are a function of one message, so a shaped prefix stays byte-identical across turns and prompt caching holds — which is why this landed here and sliding history truncation stayed with the hosts. Claude-Session: https://claude.ai/code/session_01MXpoKqJrUFTqgmDrehDti4 * docs(context): rule out image downscaling in intake shaping Pi resizes images on the way into context. We deliberately do not, and this records why rather than leaving it as an open question: downscaling re-encodes a user's own bytes, which is a lossy transform of content nobody asked us to change, and it is the one kind of shaping a host cannot undo afterwards. Text caps and duplicate-field dropping stay. Claude-Session: https://claude.ai/code/session_01MXpoKqJrUFTqgmDrehDti4 * refactor(context): move intake shaping to its own PR Intake shaping is a separate concern from keeping attachments out of the message, and reviewing them together obscures both. It lands on a branch stacked on this one; nothing here depends on it. Claude-Session: https://claude.ai/code/session_01MXpoKqJrUFTqgmDrehDti4 * fix(sessions,ai-chat): make the read budget a real bound, stop migrations losing rows Three review findings, all real. The hydration budget was not a bound. `getRecentHistory` admitted rows whenever fewer than `minRecentMessages` had been taken, whatever they weighed, so a window of media-heavy messages hydrated far past the limit that was supposed to cap it. A floor that ignores size is not a floor under a budget, it is a hole in one. The parameter is gone from core, handle, Think and AIChat; the budget is a hard ceiling that always returns at least the newest message. Think's window can therefore be shorter than MODEL_RECENT_WINDOW when messages are unusually large. That is the intended trade: with a 32 MB budget it only ever binds on media, and 32 MB of text is far past what any model could read anyway. AIChat's legacy lift could delete history. It dropped the source table when `imported + skipped === order.length` — but a skipped row is one that could NOT be parsed or imported, so accounting for it and migrating it are not the same thing, and any malformed row was destroyed. It now drops only when every row actually landed, and says what it kept. Sessions stamped its schema version even when `migrateLegacy` reported an incomplete copy, so the lift never retried and the rows it left behind stayed unreachable. `migrateLegacy` now reports completeness and the version is stamped only on success. Both lifts are idempotent, so retrying costs reads and nothing else. Also resolves the conflict with main in rfc-think-multi-session.md, taking main's text, which records the same supersession plus the replacement RFC. Claude-Session: https://claude.ai/code/session_01MXpoKqJrUFTqgmDrehDti4 * chore: format changeset Claude-Session: https://claude.ai/code/session_01MXpoKqJrUFTqgmDrehDti4 * fix(sessions): derive byte totals when a write touches attachments `pathRowStats` charges a message for the payloads it points at, because that is what a read materializes. The incremental stats cache did not: it added the serialized POINTER json, so `stats().totalContentBytes` depended on whether the cache happened to be warm. Invalidate instead of tracking. Replicating the base64 charge in append and update would put the formula in three places and let them drift, which is how the two disagreed in the first place. A media write now drops the cache and the next `stats()` derives it — one recursive CTE read against writes that cost ~1000x more. Text writes, the hot path, keep the incremental path untouched. Narrow in practice: Think and pi read `totalContentBytes` from `getRecentHistory`, which derives from `pathRowStats` on every call and never from this cache, so no shipped behavior was wrong. But an API that returns a different number depending on cache warmth is a trap, and the regression test added here is the first thing that would have hit it. Claude-Session: https://claude.ai/code/session_01MXpoKqJrUFTqgmDrehDti4 * refactor(sessions): align the surface with what hosts actually use Two findings from auditing Think against the Sessions API. `SessionStats` loses `totalContentBytes` and `pathLength`. Nothing read either — Think and pi take their byte total from `getRecentHistory`, which derives it from `pathRowStats` on every call and never from this cache. Both fields also measured the ACTIVE BRANCH only, excluding other branches, other sessions in the object, and the attachment tables, so as a "how big is this session" signal they answered a different question than the one anyone would ask them. A real size signal against the 10 GB ceiling has to sum the tables and deserves its own function. What remains is the token estimate that gates auto-compaction, which is the only field with a reader and the reason the cache exists at all. That also removes the cache invalidation added a commit ago: it existed solely to keep `totalContentBytes` honest once row stats began charging attachment bytes. Notably the field that diverged was the unused one — `tokenEstimate` is stamped from the message BEFORE extraction, so it always counted the payload and the compaction trigger was never wrong. `appendMessage` now returns the same inlined message whether it inserted or found a duplicate. It previously returned `getMessageRaw` on the duplicate and not-inserted paths, so `AppendResult.message` and the change feed carried `attachment:sha256:` pointers on some appends and inline content on others — one call, two shapes, depending on whether the row already existed. Think compensated for that with `_messageForCache`, which serialized every incoming message and substring-searched it for the pointer prefix before deciding whether to re-read, on the streaming hot path. Fixing the contract deletes the helper and its three call sites. AIChat's similarly named helper stays: it does v4 to v5 transformation, which is genuinely its own concern. The remaining duplication — both hosts reimplementing the change-feed cache mirror — is filed as #2205 rather than attempted here. Claude-Session: https://claude.ai/code/session_01MXpoKqJrUFTqgmDrehDti4 * fix(sessions): guarantee every emitted message is inlined Finishing a fix I got half right. The previous commit made `appendMessage`'s duplicate paths inline but left the inserted path returning `prepared.message` — the caller's own object. So a caller that reads with `attachments: "pointer"` and writes the result back got pointer form on the insert and inline form on the retry: the same inconsistency as before, mirrored. It also invalidated the assumption the previous commit relied on to delete Think's `_messageForCache`. That helper existed because the feed could carry pointers; removing it was only safe if Sessions guarantees it never does. For pointer-form writes, it did not, and the pointers would have reached Think's live cache and then a model request. `appendMessage` and `updateMessage` now pass what they return and emit through `core.inlineMessage()`, so the guarantee holds for every write regardless of what the caller supplied. It is a no-op by reference when there are no pointers, so the ordinary write pays a walk and nothing else. The invariant now has one choke point instead of being maintained per branch, which is what went wrong twice here: each fix made another call site consistent rather than stating the contract in one place. Both tests fail without the change: insert-then-duplicate returning identical shapes with nothing pointer-shaped reaching a subscriber, and the same for updates. Claude-Session: https://claude.ai/code/session_01MXpoKqJrUFTqgmDrehDti4 * fix(sessions): skip inlining when an update has no target `updateMessage` resolved attachments before checking the outcome, so a write against a row that no longer exists materialized every referenced payload and then returned null — megabytes loaded to be discarded, on a write guaranteed to fail. That path is also the one place a pointer legitimately cannot resolve: if the row is gone its payloads may have been collected with it. Bailing on `missing` before inlining settles both. Also drops `MAX_BOUND_PARAMS` and `buildInClauseStrings`, which arrived with main for the row-size-limit path this branch deletes. Claude-Session: https://claude.ai/code/session_01MXpoKqJrUFTqgmDrehDti4 * feat(think): keep the 0.17 session API working on top of Sessions Add ThinkSession, a forwarding wrapper around the agents/sessions handle, so a subclass written against Think 0.17 keeps compiling and running: configureSession() still accepts the withContext()/withCachedPrompt() chain, the context accessors on this.session forward to this.context, and appendMessage/getHistory/getRecentHistory accept their positional arguments. Context blocks load during onStart again so the synchronous accessors answer after start. MediaEvictionConfig.externalizeToWorkspace is accepted and ignored, and WorkspaceLike.writeFileBytes is optional; a workspace without it disables media eviction and skills projection with a one-time warning. Rewrite the think, agents and ai-chat changesets as upgrade guides that state the one-way storage migration and its rollback loss, drop the shell changeset (no shell change in this PR), and add an "Upgrading from 0.17" section to the Think docs. Claude-Session: https://claude.ai/code/session_01PbR74FnDvmhMyzGxKUGXEu * refactor(sessions): ship the surface the hosts use, and derive the rest Build the FTS index on the first search() instead of by option. Think enabled searchIndexing but never searched, so every append billed a second row for an index nothing read; now an object pays for the index only once something calls search(), with the existing SQL backfill. Removes the searchIndexing option and SessionSearchDisabledError. Remove what no host uses: fork(), listSessions(), appendMany(), stats() and its maintained cache, the pointer-mode read option, the dead rawMessagesByStats/describe helpers, SessionSerializationError, and the constants and estimators the index re-exported. Merge the leaf and seq caches into one per-session tail read once per object lifetime; the token estimate that gates compactAfter is derived from content-free rows on each call. Collapse appendMessage's three duplicate paths into one: core.append returns the stored row whether or not it inserted. Dispatch the append event before auto-compaction runs, and report a throwing change-feed listener through session:error instead of rejecting a write that already committed. AIChat retention counts stored rows rather than the hydrated window, and persistMessages skips a message whose JSON matches the mirrored array before Sessions would decode and hash its media to find out. ContextBlocks shares one block loader, keeps toSystemPrompt and friends private, and exposes freezeSystemPrompt/refreshSystemPrompt as the prompt surface. Claude-Session: https://claude.ai/code/session_01PbR74FnDvmhMyzGxKUGXEu * docs(design): bring the sessions design record up to the shipped surface Claude-Session: https://claude.ai/code/session_01PbR74FnDvmhMyzGxKUGXEu * fix(think): keep skill projection off by default; report path-cap truncation skillWorkspace defaults to false so an upgrade writes nothing into a user's Workspace: 0.17 loaded skills from their sources only, and this release keeps doing that unless a subclass opts in with {}. getRecentHistory reports truncated: true when the branch is deeper than the 10,000-row path cap, which hides older rows exactly as the byte budget does. Found while lifting a 1.14 GB, 494k-message legacy transcript: the read returned the newest 10,001 rows and claimed the whole path fit. Claude-Session: https://claude.ai/code/session_01PbR74FnDvmhMyzGxKUGXEu * style(think): format README table Claude-Session: https://claude.ai/code/session_01PbR74FnDvmhMyzGxKUGXEu * fix(sessions): report path-cap truncation only when older rows exist A branch of exactly the cap's length returned every row and was still reported truncated. Truncation now means the oldest returned row has a parent the read could not follow.github.com-cloudflare-agents · ec93caf6 · 2026-09-03
- 7.1ETVfeat: Cloudflare-native AI tracing (agents/observability/ai) (#1860) * feat: initial pass of cloudflare-native ai tracing Co-authored-by: msmps <7691252+msmps@users.noreply.github.com> * feat: add ai sdk v7 telemetry support to ai-tracing Co-authored-by: msmps <7691252+msmps@users.noreply.github.com> * chore: align ai-tracing with repo conventions - match workspace devDependency versions (sherif) - oxfmt formatting, remove unused type imports (oxlint) - bundler moduleResolution with extensionless relative imports - build with tsdown like sibling packages (cloudflare:workers kept external) - explicit types field for TS 6 (no automatic @types inclusion) - start at version 0.0.0 with an initial-release changeset - update pnpm lockfile * refactor: fold ai tracing into agents observability exports Move the ai-tracing package into the agents package: the tracer core (createTracer, the cloudflare:workers-bound tracer, span types) is exported from agents/observability and the AI SDK v6/v7 adapters from the new agents/observability/ai entry. The cloudflare:workers 'tracing' export is accessed via the module namespace with a no-op fallback so runtimes that predate it degrade gracefully instead of failing at module-link time (the observability module loads with the main agents entry). The hand-rolled cloudflare:workers type shim is dropped in favor of @cloudflare/workers-types. Tests run in the agents workers pool. Co-authored-by: msmps <7691252+msmps@users.noreply.github.com> * feat: align tracing schema with OTel GenAI semconv, harden public surface Schema (per semconv research; nothing shipped, renames free): - span names follow the semconv formula with a 64-byte bare-op fallback: 'invoke_agent {agent}', 'chat {model}', 'execute_tool {tool}' — the stable query key is gen_ai.operation.name, never the span name - vendor keys move to cloudflare.agents.* (ai.* is the Vercel AI SDK's de-facto namespace); ai.tool.call_id becomes semconv gen_ai.tool.call.id - failures record otel.status_code: ERROR + error.type (the spec-defined status encoding for status-less backends) instead of a bare error boolean; cancellations record cloudflare.agents.canceled and are not errors - gen_ai.provider.name normalized to the semconv enum; gen_ai.request.stream emitted only when true; gen_ai.response.time_to_first_chunk and response id/model captured on the stream path Wrapper fixes surfaced by the trace-content audit: - AI SDK v6 signals aborts as in-band {type:'abort'} chunks and never rejects with AbortError — recognize them so aborted streams close as canceled instead of false successes - streaming tools (async-generator execute) keep their execute_tool span open until the iterable is consumed instead of finishing at ~0ms - tool spans carry gen_ai.tool.call.id from the execute options Public surface hardening (runtime will gain native OTel support later): - types renamed to avoid @opentelemetry/api collisions: AgentTracer, AgentSpan, TraceAttributes, TraceAttributeValue; startSpan renamed openSpan (OTel's startSpan means create-without-activating — a semantic inversion) - createTracer, SpanRuntime, SpanWriter, MaybePromise are private: SpanRuntime is the OTel-convergence seam and must stay free to change * feat: instrument think out of the box Zero new public surface. Think's streamText call routes through the always-on agents/observability/ai wrapper, so every turn emits an 'invoke_agent {agent class}' root span with 'chat {model}' and 'execute_tool {tool}' children in Workers Observability. - the admittedTurnContext ALS internally carries trigger/admission/channel/ continuation/generation; _turnTelemetry() injects agent identity and turn metadata into experimental_telemetry.metadata (caller values win; inert for the AI SDK's own telemetry unless enabled) - agents adapters (v6 + v7) project telemetry metadata onto root-span attributes: reserved keys -> cloudflare.agents.turn.*, userId -> user.id, other scalars -> cloudflare.agents.metadata.{key}, objects dropped - drain loops finalize the underlying model stream on early exit (in-stream error break, stall abort, user abort) via a WeakMap finalizer calling consumeStream — the SDK tees its base stream, so an abandoned tee branch would otherwise leave the operation span open forever - wrapModel skips middleware for gateway-style string model ids (the root span still carries the model) * fix: address external code review of the tracing wrapper Verified against the pinned ai@6.0.208 and fixed: - stream observation now unwraps the SDK's {part} baseStream envelope — previously real spans missed usage, finish reasons, errors, and aborts (only look-alike test fixtures passed); added real-SDK integration tests (actual streamText + MockLanguageModelV3) covering envelope unwrapping, in-band error/abort parts, tool call ids, and time-to-first-chunk - removed the eager result-getter 'safeguard': steps/totalUsage/finishReason getters call consumeStream(), so touching them started hidden stream consumption at wrap time; added a laziness regression test - untraced fast path: when an invocation is not traced the wrapper calls the original operation with the original params — no tool wrapping, no model middleware, no stream patching (AgentSpan gains readonly isTraced) - main agents entry no longer initializes tracing: diagnostics-channel events moved to observability/events.ts; the public barrel composes events+tracing - provider doStream now runs inside the chat span's activation so provider work nests under it; stream patching fails open on unknown result shapes - extractors read the public result shapes (inputTokenDetails/ outputTokenDetails, response.modelId, deprecated flat fields) and string gateway model ids - think: agents peer floor raised to >=0.18.0; the early-exit stream drain is idempotent (deleted before invocation) and rides ctx.waitUntil - v7 tool spans keyed by callId:toolCallId (concurrent id reuse); operation wrappers cached for stable identity; tracer attribute writes fail-safe; cloudflare.agents.operation.id renamed to .operation.name (values are names) * fix: address round-2 review findings - untraced calls no longer compute the span spec: roots open with only the semconv name (agent name via direct property reads) and empty attributes; the full spec — metadata enumeration, request fields, context allowlists — is computed after the isTraced check and written through an internal writeSpanAttributes seam, so caller getters/proxies are never enumerated on untraced calls - think drains the model stream only on early exits (break or throw), via a natural-exhaustion flag — consumeStream is not a no-op (it tees baseStream and traverses the buffered branch), so draining every call was per-inference overhead; a thrown exit (stall watchdog) still drains - the finalizer runs exactly once: the drain promise is created before ctx.waitUntil, so a missing/throwing waitUntil cannot start a second tee consumer - async-generator tool bodies are re-entered into the tool span's async context via AsyncLocalStorage.snapshot() on every pull, so spans created inside the body parent under execute_tool (verified in workerd) - extractors: provider response-metadata stream parts populate response id/model on chat spans; v7 reads public usage detail shapes (inputTokenDetails/outputTokenDetails + deprecated flat fields) and prefers the served response.modelId over the requested event.modelId * fix: forward early termination to streaming tool iterators The round-2 manual iterator.next() loop dropped for-await's automatic return() forwarding: a consumer breaking while the wrapper was suspended at yield closed the span but never ran the tool generator's own finally blocks. The wrapper now tracks exhaustion and, on early termination, forwards iterator.return() inside the tool span's context before finishing the span. Regression test: consumer breaks after the first yield; the tool generator's cleanup runs (and a span opened in that cleanup parents under execute_tool). * fix(observability): keep tracing adapter internal * refactor(observability): trim tracing surface * fix(observability): correct AI trace semantics * fix(observability): retain span name limit * docs(observability): remove repeated scope section * refactor(observability): scope AI tracing to SDK v6 * feat: wrap agent initialization in tracing span Group constructor-time setup (method wrapping, schema creation, MCP client manager initialization) under one stable agent_initialization span so the UI can collapse it instead of surfacing top-level clutter, and give init-specific trace behaviour a hook. The agent id attribute is read defensively: facets restore their name after construction and idFromString()/newUniqueId() DOs are named later via setName(), so an unreadable name leaves the attribute unset instead of failing construction. * feat(observability): restore AI SDK v7 telemetry integration Restore the v7 Telemetry adapter (createAISDKTelemetry) alongside the v6 wrapAISDK, conformed to the ai@7.0.22 GA Telemetry interface. The adapter's structural event/hook types remain independent of the "ai" package so it still compiles in this v6-installed repo. Re-adds the cloudflare.agents.call.id correlation attribute and the v7 docs sections removed when v7 was scoped out. - observability/ai/v7/{types,extract,telemetry}.ts - observability/ai/index.ts: re-export createAISDKTelemetry - genai/attributes.ts: restore Cloudflare.CallID - tests: ai-sdk-v7-telemetry.test.ts (structural, RecordingTracer) - docs + changeset: v7 usage via registerTelemetry / experimental_telemetry * feat(observability): opt-in span content capture Add an explicit, default-off opt-in for recording chat inputs/outputs and tool inputs/outputs on the AI SDK tracing spans. This content is potentially PII, so it is emitted only when a record flag resolves to true; the default projection remains content-free. - v6: `recordInputs`/`recordOutputs` on the `wrapAISDK` options, plus per-call `experimental_telemetry.recordInputs`/`recordOutputs` (authoritative, mirrors the AI SDK's own TelemetrySettings). Chat inputs and streamed/generated output and tool arguments/results are serialized onto the operation and execute_tool spans. - v7: `createAISDKTelemetry(options)` gains the same flags; content is read from the event fields the adapter already receives and emitted only when opted in. - Shared genai builders serialize each value to a JSON string attribute truncated to a safe byte cap with a marker; semconv-aligned keys (gen_ai.input.messages / gen_ai.output.messages / gen_ai.tool.call.arguments / gen_ai.tool.call.result). Never emitted on error/abort beyond the flag. - Think exposes a single `recordTraceContent` flag (off by default) that flows into the per-turn telemetry; agent-think opts in. Tests assert the default records no content attribute and the opt-in records the expected serialized (and truncated) value for v6, v7, and Think. Docs updated with the opt-in, flagged as PII-recording and off by default. * refactor(observability): match AI SDK recordInputs/recordOutputs in Think; cap content to span budget Think exposes recordInputs/recordOutputs fields (matching the AI SDK's own TelemetrySettings and the tracing adapter) instead of a single recordTraceContent flag; agent-think opts into both. Derive the content-attribute cap from workerd's 64 KiB MAX_SPAN_BYTES total-span budget (split across the up-to-two content attributes a span can carry, with headroom for scalar metadata) instead of a flat 4 KiB. * docs(observability): clarify system-role message + per-turn override + metadata budget notes * fix(observability): record message content on chat spans * feat(observability): trace tool approval lifecycle * fix(think): run durable submissions from alarm invocations * feat(observability): reference AI Gateway logs * fix(observability): restore opt-in GenAI payloads * feat(observability): group agent storage spans * fix(observability): conform stored messages to GenAI schemas Map AI SDK-native message fields to the OpenTelemetry GenAI role/parts contract, including canonical text, reasoning, tool-call, and tool-response parts. Embed normalized finish_reason in buffered and streamed outputs so Workers Observability can render Think traces.\n\nAdd dedicated genai_semantics coverage plus real AI SDK integration assertions for system history and streamed tool calls. --------- Co-authored-by: msmps <7691252+msmps@users.noreply.github.com> Co-authored-by: Thomas Ankcorn <tankcorn@cloudflare.com>github.com-cloudflare-agents · f5b1dd81 · 2026-07-21
- 7.0ETVfeat(streams): durable incremental output as a Lifecycle capability (agents/streams) (#2173) * docs(fibers): add accepted fibers RFC amended to the contribution model * feat(fibers): add agents/fibers durable replayable execution capability One Fibers capability per Lifecycle Object owns named definitions created with fibers.create(name, run). Runs replay from the top on every attempt: journaled step results, durable sleeps with first-deadline authority, per-step retry/timeout policy, a status live gate, cooperative cancellation, and generation-fenced claims. Deadlines live in cf_fiber_runs.next_at and reach the shared physical alarm only through the Lifecycle alarm-contribution model, exactly as the Scheduler does. * test(fibers): capability harness, workers suite, and export typing FiberHarnessObject drives the capability with real Lifecycle startup, real SQLite, and real platform alarms; instance counters separate real step execution from journal hits to prove replay memoization. Covers acceptance dedup, retry parking, interrupted-attempt reclaim from a seeded dead generation, sleep deadline authority, the status live gate, cancellation, timeouts, divergence, missing definitions, Scheduler alarm coexistence, and the bounded alarm batch. * docs(fibers): reference page, design-doc updates, and changeset * refactor(fibers): declare definitions in the Fibers constructor Replace imperative fibers.create() handles and the startup registry lock with a Scheduler-style constructor definitions map. The map is the registry, rebuilt on every wake, so recovery of in-flight runs is correct by construction — nothing to register at the right moment and no lock to trip over. Runs start with the typed fibers.run(name, input, options); fibers.handle(name) is a pure typed lens scoped to one definition; framework internals attach through an internal composition-root resolver aperture mirroring the Scheduler's callback-name resolver. Also adds the examples/next/fibers example (smoke-tested end to end under wrangler dev) and records the amendment in the RFC. * feat(fibers): custom recovery as { run, recover } definitions (Phase 2) A definition may pair its handler with a recover callback that owns unclean interruptions instead of automatic replay. It receives the run input, metadata, and the interrupted step — name, attempt, stable idempotency key, and the last checkpoint() the lost attempt wrote through its step attempt context — and decides replay (immediate or deferred), complete, fail, or cancel. Clean step failures never reach recovery; the retry policy owns them. Recovery runs under its own 'recovering' claim with the same generation fencing and claim-deadline backstop, survives its own interruption (callbacks must be idempotent), and a throwing recover retries on exponential backoff with a bounded budget before the run fails. The internal composition-root resolver accepts { run, recover } entries so framework definitions (the Think replatform) get the same recovery seam. * feat(fibers): install on Agent and replatform Think, AIChatAgent, and messengers Agent installs the capability automatically as experimental this.fibers. Subclass definitions go on the overridable fiberDefinitions field (rebuilt every wake, resolved lazily so field order never matters); framework definitions attach through a composition-root aperture mirroring the Scheduler's callback resolver; handlers run in the Agent's invocation boundary; and due runs dispatch at the same startup point as the legacy fiber scan — before the user's onStart. Think chat turns, AIChatAgent chat turns, and Think messenger replies now execute on the capability: each live closure runs as one journaled step in the caller's invocation context with checkpoint-backed stash(), and an unclean interruption synthesizes the legacy FiberRecoveryContext and routes through the unchanged _handleInternalFiberRecovery -> ChatRecoveryEngine seam (messenger recovery likewise, with re-entry checkpoints persisted in host storage). The recovery brain did not move, which is why the full think suite passes untouched: agents 1,938/1,938, think 887/887, ai-chat 655/655. Legacy runFiber()/startFiber() user APIs are unchanged and still recovered by their own scan; facet-hosted turns stay on the legacy engine until routed Fibers land. * test(fibers): standalone + coexistence fixtures and a real process-kill e2e Split the workers-pool harness so Fibers is proven both ways: FiberHarnessObject now installs Fibers as its ONLY capability, and the new FiberSchedulerCoexistObject installs Fibers and the Scheduler together for the shared-alarm arbitration test. Add fibers-capability-eviction.test.ts on the existing e2e kill harness: real wrangler dev with persisted state, SIGKILL mid-step, restart. Step executions are recorded in the host's own SQLite, proving completed steps replayed from the journal without re-executing while the interrupted step ran again; the { run, recover } variant proves the recovery callback receives the interrupted step and its last checkpoint() across a real process death and settles the run by decision. * refactor(tasks): ship the capability as Tasks; add the Streams RFC Rename the new durable-execution capability from Fibers to Tasks: agents/fibers -> agents/tasks, class Tasks, taskDefinitions on Agent, Task* types/errors, task:* events on a new agents:task diagnostics channel, and cf_agents_task_runs / cf_agents_task_steps tables (all unreleased). 'Fiber' now unambiguously means the legacy engine (runFiber/startFiber and their vocabulary, all untouched) — the rename removes the two-meanings problem the constructor-map redesign created, and lines up with the future MCP Tasks adapter story. Fixtures, suites, the kill e2e, docs, the example, and the RFC record all follow; the Agent schema DDL snapshot now includes the capability's tables. Add design/rfc-streams.md (proposed): a Streams capability owning the durable chunk log, cursor, and replay-then-tail reads — the incremental- output half of the pattern the Think migration validated — composed with Tasks through checkpointed cursors and status() evidence, populated by extracting chat's resumable-stream store. Verified on the renamed engine: agents 1,938/1,938, think 887/887, ai-chat 655/655, tasks SIGKILL e2e 2/2. * feat(streams): durable incremental output as a Lifecycle capability One Streams instance per Durable Object owns an ordered, durable chunk log per stream with a monotonic cursor: idempotent open(), synchronous durable append() that wakes live readers, close()/error() settlement (no-op when already terminal, so recovery callers stay idempotent), replay-then-tail read({ from, signal }), and status() reporting state and cursor. Reads are independent of producer liveness; the capability consumes only storage and events — no alarm — so it also works on facets. This is the incremental-output half of the pattern the Tasks migration validated, composed without coupling: a task step appends to a stream and checkpoints { streamId, cursor }, and its recover callback reads streams.status() as durable interruption evidence. Producers that resume from stream.cursor never duplicate a chunk. Proven on real DOs in both fixture shapes (StreamHarnessObject standalone, TaskStreamComposeObject composed, 13 tests) and across a real SIGKILL (streams-capability-eviction e2e): the chunks appended before death survive exactly, and recovery finalizes the stream at precisely that cursor. Ships with examples/next/streams (SSE serving with cursor reconnects), docs/agents/streams.md, and the accepted design/rfc-streams.md. The chat resumable-stream migration follows separately with the chat suites as the parity ratchet. * feat(streams): batched reads via readBatches readBatches(streamId, { from, signal, batchSize }) yields non-empty arrays of consecutive chunks with the same lifecycle as read(): replay yields up to batchSize chunks per array (default 100), and a live tail yields everything that accumulated since the last wakeup as one array — so a consumer paying per write (an SSE flush, an RPC hop, a history append) pays once per backlog, not once per chunk. read() now delegates to readBatches with per-chunk abort checks, so the existing suite exercises the shared core; two new tests pin the batch boundaries (batchSize slicing from a cursor, one-array-per-wakeup coalescing on a live tail). * feat(streams): replatform chat/think resumable streams onto the capability ResumableStream becomes chat's producer-side coalescing and wire-protocol adapter over agents/streams: in-flight turn output lives in the shared durable chunk log (cf_agents_streams / cf_agents_stream_chunks), written through the capability's fenced append — which also wakes live streams.read() consumers and emits agents:stream events — with completion/error mapped onto stream settlement. The packed-segment write policy (~10 wire chunks per stored chunk) stays in the adapter, so write economy is preserved; retention keeps its 10m/1h windows but keys off the stream row's updated_at, so sweeps never scan the chunk table. Legacy cf_ai_chat_stream_* tables (both on-disk generations) migrate wholesale on first construction — an in-flight stream keeps its id, chunks, and last-activity across the upgrade — then are dropped. The adapter's surface is synchronous and constructed before the Lifecycle starts, so it runs on a loud-named internal sync aperture (Streams.__DO_NOT_USE_WILL_BREAK__sync()) whose invariant-bearing writes go through the same private methods as the public API. StreamStatus gains updatedAt (last write activity). AIChatAgent, Think, and the experimental recovery agents install the backing capability as readonly streams (createChatStreams() raises maxChunkBytes for packed segments); the recovery engines' stream-evidence lookups move from raw legacy-table SQL onto adapter methods. A storage-ops benchmark (storage-ops-bench.test.ts, real DO SQLite via total_changes()) pins the cost model: packed adapter writes 440 rows vs the legacy pattern's 240 (the fence per segment) and 4040 for naive per-chunk appends, with retention-sweep reads down from 239 to 40 and no longer proportional to stored chunks. Parity ratchet, assertions unchanged: think 887/887 (+2 react), ai-chat 737/737 and e2e 11/11 (real wrangler SIGKILL recovery on the new store — also fixes the nightly-only hasFiberRows helper stale since the chat turns moved to the Tasks capability), agents 1954/1954. * feat(streams): tags, up-to-date signal, and SSE serving - open(id, { tag }): an indexed, deliberately non-unique application lookup key, fixed at creation (a reopen naming a different tag throws — config conflict, not resume). list({ tag }) composes with the state filter, newest first, so 'latest stream of this operation' is list({ tag, limit: 1 })[0]. Part of the initial schema — the tables are unreleased, so no migration. - readBatches onUpToDate: fires once when the reader first reaches the durable tail. Caught-up is distinct from ended — a live stream is up to date while tailing. Useful to flush replayed UI or flip a live indicator. - sseResponse(streams, id, { request }): one-call SSE serving. Each chunk's seq rides the SSE id: field, so a reconnecting EventSource resumes via Last-Event-ID with zero client code (?from= works too); control events mark up-to-date and done/error (carrying the recorded reason); heartbeat comments keep idle proxies alive; request.signal aborts the tail; 404 for missing streams. examples/next/streams now serves through it. Producer epoch fencing and sliding-TTL retention stay named follow-ups; both need design. * refactor(tasks): remove recover — interruption handling is uniform replay The custom-recovery surface is gone: { run, recover } definitions, TaskInterruption/TaskRecoveryDecision, the recovering state and its backoff budget, step checkpoint(), and the engine's recovery claim path (~350 lines). Definitions are plain handlers again. An unclean interruption replays the handler on the next wake; replay safety comes from step idempotency keys (external writes deduplicate) and durable evidence read at the top of the work — a producer that starts at stream.cursor resumes instead of redoing. task:attempt:interrupted still reports the step a lost attempt left mid-execution. Two things made recover unnecessary once the API was used end-to-end: the Streams capability turned interruption evidence into durable state a replayed handler reads directly, and the chat replatform showed its one real consumer — the ChatRecoveryEngine — expresses the same decision as a branch at handler entry when the live closure is missing. Chat turns and messenger replies now take exactly that shape: the live path persists its stash snapshot in host storage; a replay whose closure is gone enters the unchanged recovery engine with that snapshot plus stream evidence, keyed by stable run ids (chat_<nonce>, msgr_<nonce>). Fixtures, e2es, the example, docs, and changesets move to the replay model; the SIGKILL e2es now prove resume-without-duplication (gapless seq sequences) rather than finalize-at-cursor. The RFCs record the amendment. Tasks and Streams are unreleased, so no compatibility surface changes. * refactor(tasks,streams,chat): quality pass — evidence API, shared turn definition, typed aperture, decomposition - step.interrupted: the interrupted step ({ name, attempt } | null) is first-class on the step surface, captured once at claim. The engine, the test harness, and the e2e handler previously reimplemented the same raw journal query; all three now read the API, and it is the documented way a replayed handler branches on interruption evidence. - The chat-turn Task definition lives once in agents/chat (createChatTurnTaskDefinition): AIChatAgent and Think wire their protected internals through a narrow hooks contract instead of carrying 60 identical lines each. The stash fire-and-forget storage writes are .catch-guarded (previously an unhandled-rejection risk on the token hot path), and the snapshot-key vocabulary is centralized. Messenger registration renamed to _registerMessengerReplyTaskDefinition. - The Streams internal sync aperture is fully typed: the raw exec escape hatch is gone, replaced by latestRowByTag/deleteMany/importStream/ importChunk, so no SQL crosses the capability boundary. Chat streams carry their request id as the indexed tag (metadata keeps only the ownership marker), and the legacy-table migration reads chat's own tables through the host-supplied sql handle. - tasks.ts decomposed under 1k lines: store.ts owns the tables (DDL, row access, fenced writes, snapshot projection) and engine-port.ts builds the step-engine port. Queue-mirror syncs moved inside the settle helpers, so a state transition cannot forget its wake. - resumable-stream.ts decomposed: replay wire-framing extracted to replay-frames.ts, collapsing three near-identical send loops; the three latest-stream query variants collapsed onto latestRowByTag. - sseResponse cancel race fixed: a heartbeat tick racing a client disconnect no longer throws from the interval, and finish() tolerates a cancelled controller. - Fixture seeds updated to the tag column; docs, changesets, and mirrors updated (tasks.md documents step.interrupted). * chore: format docs with the repo-pinned oxfmt * fix(chat): recovery dispatch must not hold the Lifecycle job queue The exhaustion e2es exposed a queue-starvation regression from the work-queue port: _chatRecoveryRetry/_chatRecoveryContinue are schedule callbacks — queue jobs — and the replatform made them await the recovered turn inline. A turn is legitimately unbounded (a hanging model stream is capped only by the step timeout), so one stuck recovery dispatch starved every other job on the object: keepAlive stopped, the interrupted turn's own replay-wake never fired, budgets never advanced, and onExhausted never sealed. Legacy dispatch was fire-and-forget runFiber, which never had this property. The callbacks are now split: the public schedule-facing method awaits the bounded pre-turn phase and detaches at the turn boundary via a handoff (Promise.race of the dispatch against a reached-the-turn signal), so a platform transient thrown before the turn still reaches the queue and defers the job (#1730). A platform-class failure after the handoff — when the job has already completed — re-defers itself by rescheduling the same callback (isPlatformFailure, now re-exported through agents/chat). All incident bookkeeping (OOM intercept, budget evaluation, stranded-child reconcile) lives unchanged in the protected *Detached body, which fixtures drive directly when they need settled-state assertions. Verified end to end: the exhaustion e2es seal all three budget kinds again (3/3), think 887/887 (+2 react) including the #1730 deferral and storage-reset pairs, ai-chat 737/737, agents 1957/1957. * chore: drop committed e2e probe persist dir; ignore .wrangler-* variants * docs(design): rewrite the Tasks and Streams records; clean Agent task wiring The two design records had accumulated amendments faster than their bodies: rfc-fibers.md was 2,785 lines of pre-implementation proposal in 'Fibers' vocabulary describing recover callbacks, checkpoints, and the alarm-contribution model — all superseded — with the corrections stacked at the bottom. Both files are rewritten as records of the shipped design (~180 lines each): the problem, the shipped API and architecture, an honest 'how the design evolved' section (runtime create() → constructor map; alarm contribution → job queue; recover shipped-then-removed with the reasoning; the checkpoint→cursor contract collapse), alternatives considered, deferred work, and the verification stance. Filenames stay for link stability. docs/agents/tasks.md gets a real fix (the install example declared 'readonly fibers' but installed 'this.tasks'), section reordering so both replay discussions sit together, and current-limits wording aligned with the record. streams.md drops the last 'recovery finalizes' phrasing. The Agent-side Tasks wiring Matt flagged is refactored at the source: Tasks runs onError through the standard runInHostContext boundary itself (hosts pass a plain callback — the hand-rolled runInInvocation scope bag is gone), and the definition-resolver aperture exports its value type (TaskDefinitionResolver, the input-erased TaskCallbacks form), replacing the ReturnType<Parameters<...>> cast gymnastics with one documented cast. * chore: format design records with oxfmt write mode Local 'oxfmt --check' accepts these markdown files while CI's identical check rejects them; write mode settles the canonical form. * fix(tasks): drop unused TaskStep type import The Agent-wiring refactor removed the last use; oxlint in CI rejects the leftover import. * fix(streams,tasks,chat): harden resume, join, and scoping edges - sseResponse: a fresh EventSource connection (no Last-Event-ID header) now starts at chunk 0 — Number(null) parsed as 0, which skipped the first chunk and made the ?from= fallback unreachable. - Tasks.handle().cancel() is scoped to its definition, matching get(). - run() rejects a runId/idempotencyKey pair that names two different runs instead of silently joining one of them. - Task wake jobs are namespaced task:<runId> so caller-selected run ids stay inside Tasks' own job-id space. - Documented why fire-and-forget stash writes are safe (Durable Object storage applies same-key operations in issuance order) in the chat turn and messenger reply definitions. * refactor(lifecycle): define the job dispatch contract Four named rules now govern the queue (design/lifecycle-work-queue.md): - Job ids are scoped to their owner: push replaces only the owner's own job, and a cross-owner id collision throws instead of silently replacing the other owner's job. - Newer pushes win over drive results: every dispatched job carries a durable in-flight marker, a same-id push or reschedule clears it, and applyOutcome only applies to still-marked jobs — a wake pushed mid-drive can no longer be lost. The memory-limit breaker retimes through an unguarded path because its backoff must land regardless. - Dispatch must be bounded: a dispatch outliving its job's hung timeout warns and emits job:slow_dispatch telemetry. - Platform failures abort the drive loop (existing behavior, now a documented rule), and cross-owner drive order is explicitly unspecified so lanes or fairness can arrive without a contract change. * fix(lifecycle,tasks,streams): close review-flagged dispatch and resume gaps - The drive loop refetches each due job before claiming it: a job replaced by an earlier dispatch in the same alarm cycle dispatches with fresh data, or is skipped when no longer due, instead of being driven and settled from its stale snapshot. - Tasks.onJob is bounded: a queue-driven attempt holds the serial dispatch loop for at most a small budget, then detaches and keeps executing while the isolate lives. The claim backstop remains the durable wake, and a detached settle re-syncs the wake mirror, which supersedes the returned outcome. - The idempotency key is the deduplication authority in run acceptance: a fresh runId alongside a key that names an existing run joins that run (the repeated-delivery pattern), while a run matched by ID with a different stored key still refuses the join. - sseResponse checks for already-aborted signals before wiring abort listeners, so a pre-aborted request ends instead of tailing a live stream forever. * test(lifecycle): make the stale-snapshot probe arming deterministic A backdated push can auto-fire its alarm between two awaited arms (seen on the slower CI runner), letting the victim dispatch before the retimer even existed. Push both jobs far-future, backdate them synchronously in one breath, then rearm. * fix(tasks,streams,chat,think): close review-flagged durability gaps - A platform-class failure in a task attempt (superseded isolate, memory-limit reset, storage transient) no longer settles the run as failed: the attempt unwinds and rethrows, the claim backstop stays the durable wake, and the next invocation reclaims and replays. - The messenger reply definition durably persists its initial accepted snapshot before delivery begins, mirroring the chat turn definition; an isolate lost mid-answer can always recover through it. - Chat tag lookups filter to cfChat-owned rows: the stream table is shared and tags are non-unique, so the newest row by tag alone could be an unrelated application stream masking chat recovery evidence. The sync aperture's latestRowByTag becomes rowsByTag. - readBatches re-polls after onUpToDate fires instead of sleeping: the callback is application code and a synchronous append inside it fired its wake before any waiter registered — a lost wakeup. - The last aborted waiter removes its stream's empty wake set, so abandoned reads stop accumulating map entries. * fix(streams): wake tailing readers when an aperture delete removes a live stream deleteMany (the sweep) and deleteUnchecked can remove a streaming row; a reader parked in the live tail never woke to observe the deletion and stayed pending until an unrelated abort.github.com-cloudflare-agents · 71ce28a8 · 2026-09-01
- 5.9ETVfeat(mcp): add SDK v2 client and stateless server support (#1557) * feat(mcp): add SDK v2 handler with v1 compatibility * docs(mcp): simplify raw Worker example * refactor(mcp): hoist modern elicitation handler * refactor(mcp): clarify v2 and legacy handler APIs * chore(mcp): update server SDK to v2 beta.4 * feat(mcp): add SDK v2 client compatibility * refactor(mcp): isolate SDK v2 compatibility concerns * test(mcp): make conformance reporting truthful * fix(mcp): rediscover migrated OAuth issuers * fix(mcp): validate stateless handler origins The SDK v2 handler intentionally leaves deployment validation to its host, but the Agents Worker wrapper previously delegated present Origin headers without a guard. Validate them against localhost-class hostnames by default, expose an explicit browser-host allowlist, and keep Origin-less non-browser clients working. Also allow the modern Mcp-Method and Mcp-Name headers in default CORS preflights and remove the now-clean v2 conformance baselines. * fix(mcp): address SDK v2 review findings * feat(mcp): isolate stateless SDK v2 server path * fix(mcp): reconcile v2 client recovery * test(mcp): trim SDK v2 review surface - delegate scenario execution to the official conformance CLI - remove non-gating extension lanes, empty baselines, and redundant tests - keep bounded concurrency and truthful process/warning handling - create a fresh legacy chess server for each request * test(mcp): update conformance referee to alpha.10 Remove the two stale modern-protocol exceptions fixed by alpha.10, leaving the stateless server lane 40/40 clean and the modern client lane with four documented expected failures. Also drop the temporary MCP release-age exclusions. * fix(mcp): make stateless examples runnable Wrap callable Agents handlers inside Worker object fetch exports so Wrangler does not treat them as WorkerEntrypoint classes. Carry multi-round elicitation data in signed requestState because each retry includes only the current round's input responses. * docs(mcp): preserve callable handler invocation Match the existing Agents and Sentry migration pattern: keep the Worker object export and pass the SDK v2 factory to the callable handler. The .fetch method remains available for request-options composition but is not required for Worker dispatch. * refactor(mcp): narrow stateless handler controls Expose only callable/fetch request handling and typed change notifications. Keep upstream close and bus internals private, reject the bus option, and remove now-unreachable close-race machinery from the legacy compatibility adapter. * refactor(mcp): align handler fetch with SDK v2 Keep Worker dispatch on the callable signature and expose only the lower-level fetch(request, options?) method from the SDK. Remove the redundant fetch(request, env, ctx) overload. * docs(mcp): prioritize stateless migration Direct deprecated SDK v1 handler and McpAgent users to SDK v2 factories first, reserving legacy handlers for temporary sessionful migration lanes. Document the full v0.20.0 deprecation set and correct the x402 result-schema guidance. * docs(examples): fix MCP startup commands Use the package-defined start scripts and describe the McpAgent example as a deprecated migration reference rather than a new-server path.github.com-cloudflare-agents · 447013d0 · 2026-07-27
- 5.7ETVfeat(lifecycle): add composable Scheduler (#1897) * feat(lifecycle): add composable scheduler * feat(lifecycle): add capability event bus * refactor(lifecycle): route installed capabilities * refactor(lifecycle): add host-callback, startup, and teardown services Capabilities now receive lifecycle.callbacks (named host-callback dispatch through one overridable invocation boundary), lifecycle.starting(), and lifecycle.alarms.disabled() alongside storage, readiness, events, and routes. bindLifecycleCapability() is public so capability unit tests can bind fake services through the same seam Lifecycle.use() uses. Also removes the unreachable local-dispatch branch from routes.to(). * refactor(schedules): dissolve the Scheduler host integration into services Scheduler now consumes only the standard capability services plus policy options (retry, hungScheduleTimeoutSeconds, onError). The 15-member SchedulerIntegration adapter, its WeakMap installer, and createScheduler() are gone: callback invocation goes through Lifecycle's host-callback boundary (Agent overrides it once, at its composition root, to apply its tracing invocation scope), teardown checks read alarms.disabled(), and the non-idempotent-onStart warning is universal Scheduler behavior driven by lifecycle.starting(). A pure schedule-timing module replaces the four duplicated insert branches with one parse-then-insert path, and the public surface shrinks to schedule/set, scheduleEvery/every, get, list, cancel plus the deprecated synchronous reads. Also removes a dead storage guard in the MCP client capability. * test(schedules): add capability-level coverage; rebind standalone MCP tests New layers under the Lifecycle test pyramid: pure schedule-timing unit tests, and a Scheduler capability suite that binds fake Lifecycle services over real Durable Object storage via bindLifecycleCapability(). The onStart-warning probes now assert the observable console warning instead of Scheduler internals. Standalone MCPClientManager tests bind the same fake services instead of the removed storage option, fixing the 145 test failures and type errors the capability migration left behind. * docs(examples): add the next/schedules example A server-only example installing Scheduler on a plain DurableObject: typed set() creation, cron and delayed reminders, list/cancel over HTTP, and a scheduled callback that runs with host context and records delivered reminders in the host's own table. Fills the slot the examples/next catalog reserved for the schedules capability. * test(lifecycle): exercise the Scheduler capability through a real Durable Object bindLifecycleCapability() returns to being internal — exporting it publicly was a test seam leaking into the API. The capability suite now installs Scheduler on SchedulerHarnessObject, a minimal real Durable Object, and drives real Lifecycle startup, real storage, real platform alarms (runDurableObjectAlarm), host context inside callbacks, and the real diagnostics event sink. The fake-services binder remains as an in-package test shim only for the legacy mock-storage MCP manager suites. * fix(schedules): restore pre-refactor parity flagged by adversarial review - Retry defaults are resolved but no longer validated in the Scheduler constructor: a historically tolerated invalid static retry config must not start throwing in the Agent constructor and brick every entry point of the Durable Object. Invalid defaults surface per execution as schedule:error, as before; per-schedule retry overrides stay validated. - One-shot idempotent dedup accepts any truthy value again (historic behavior), not only literal true. - Scheduler storage failures throw the exported SqlError again; the class moved to sql-error.ts so agents/schedules can share it without a cycle. - The startup schedule() warning message now says 'during startup' (the window deliberately covers all startup hooks, not just onStart), and the convention-based underscore-callback exemption is gone — the one internal startup caller passes an explicit idempotent choice instead. - The capability test's cron advance assertion tolerates a minute-boundary landing on the current second; stale 'Lifecycle controller' wording fixed in docs. * test(mcp): add a real-Durable-Object harness for MCP client capability tests McpTestHarnessObject is a bare Durable Object; withMcpHarness() runs a test body inside a fresh instance where each created MCPClientManager is bound through a real Lifecycle to real SQLite storage. Managers can be created repeatedly over the same storage to simulate hibernation wake-ups. * test: one shared workers project with module-mirrored capability tests Structure requested in review prep: - The standalone Lifecycle vitest project (own vitest.config, wrangler, worker, env types) is gone; everything runs in the shared workers project and the plain-worker main-module routing is exercised by calling routeAgentRequest(request, env, { props }) directly. - tests/lifecycle/ holds one file per Lifecycle functionality: runtime handlers, startup, alarm arbitration, capability events, capability routing, host context, hibernating WebSockets, identity, disposal — plus new coverage for startup-failure retry (RetryableStartObject), use-after-start, duplicate capability IDs, and uninstalled-capability service access. - Capability contract tests mirror their source module: tests/schedules/{capability,timing}.test.ts and tests/mcp/client-capability.test.ts. - tests/capabilities/ is the lower-level sibling of tests/agents/: harness Durable Objects one file per capability (harness.ts generic bare-DO installer, lifecycle.ts, scheduler.ts, mcp-client.ts), documented in its AGENTS.md. Generic drivers (captureDiagnosticsEvents, captureConsoleWarnings) live in tests/shared/. - The three standalone MCP manager suites run on withMcpHarness — real Lifecycle over real SQLite storage in a bare CapabilityHarnessObject — replacing the hand-rolled mock storage and the deleted bindTestLifecycleServices shim entirely; the TestMCPClientManager subclass installs through the harness instead of a prototype swap. - env imports come from cloudflare:workers (cloudflare:test's env is deprecated). * refactor(mcp): make the client/server split real folder structure src/mcp was flat despite the public API already naming ./mcp/client and ./mcp/server. The module now mirrors that boundary: client/ (manager, connection, storage, catalog, invoker, rpc restore, runtime, transports, OAuth provider, errors, x402), server/ (stateless entry, handlers, legacy McpAgent, transports, event store, auth context, utils), with shared types/rpc/abort and the compatibility barrel at the root. Files moved with git mv so history follows; every module's exports are unchanged — public import paths are identical and only build entry points and dist layout moved (package.json exports updated in lockstep). * docs(mcp): point source boundaries at the client/server folders * chore: format test coverage matrix * ci: raise the test-job timeout to 35 minutes The affected-test matrix outgrew the 20-minute budget: this branch adds the lifecycle/capability suites and the Scheduler feature tests, and the last green run on the old budget finished at 11 minutes with a much smaller suite. The previous head's run failed on the (now-fixed) MCP suites, so today's run was the first to execute the full grown matrix — it was cancelled by the job timeout at 20m16s with the three largest suites still running. * fix(schedules): restore constructor-time schedule-table creation for Agent origin/main created cf_agents_schedules inside Agent's constructor-time _ensureSchema; the extraction moved creation into Scheduler.onStart, which runs during async lifecycle startup. On a brand-new agent that regressed synchronous pre-startup reads ('no such table') and opened a permanent-loss window if a fresh DB's first wake crashed after the schema version write but before startup, then rolled back to main (whose version-gated DDL would never run again). The DDL now lives in one shared ensureScheduleTable() called from both Agent's _ensureSchema and Scheduler.onStart, with a regression test for fresh-agent sync reads. The changeset now declares the PR's intended compatibility changes explicitly (MCP storage option removal, parsed Schedule callback argument, internal facet-RPC replacement). * fix(schedules): keep the stored schedule DDL byte-identical The extracted ensureScheduleTable had de-indented the CREATE TABLE template; sqlite_master stores statement text verbatim and the schema DDL snapshot test pins it. Restore the historical whitespace so existing and fresh databases carry identical stored DDL. * fix(tests): keep the shared test worker loadable outside the vitest pool The capability fixture files mixed harness Durable Object classes with their cloudflare:test-importing drivers, so worker.ts transitively pulled cloudflare:test — a module that only exists inside the vitest pool. The React project boots that worker under wrangler unstable_dev, so its global setup failed and every browser test burned its full retry budget, which is what pushed CI's agents:test past the job timeout. Harness classes stay in tests/capabilities/ (worker-safe, documented rule); withCapabilityHarness and withMcpHarness join the other pool-only drivers in tests/shared/. * feat(schedules): verify capability host identity at install time The Scheduler's host argument previously anchored set()/every() typing but was unused at runtime, so a scheduler constructed for one object and installed on another type-checked against the first and dispatched on the second. The anchor can no longer lie: a LifecycleCapability may declare the host it was constructed for, and Lifecycle.use() throws when it differs from the Lifecycle's own host. The argument is now also optional — omit it for string-typed scheduling with no anchor to diverge; pass it for typed callbacks plus the install-time identity guarantee (SchedulerCallbacks is the permissive default host type for the bare form). * refactor(schedules): register scheduled callbacks on Scheduler Callbacks are now registered in the Scheduler constructor (new Scheduler({ callbacks })), and set()/every() type both the name and payload against that registration — the typed scheduling surface and the runtime dispatch target are the same object by construction. The stringly schedule()/scheduleEvery() verbs and the install-time host-identity check are gone. With host-method dispatch no longer a generic need, the Lifecycle callbacks service (has/invoke/run) shrinks to runInHostContext() — the one boundary for running capability-held user callbacks inside the host invocation context. Agent keeps its historical name-based scheduling API through a Scheduler-specific composition-root resolver (setSchedulerCallbackResolver), so this.schedule(60, "methodName") still dispatches to Agent methods inside the traced host boundary. * docs: mark Lifecycle and capability surfaces @experimental The agents/lifecycle entry point and the capabilities built on it (Scheduler, MCPClientManager installed directly as a capability) may change between releases while the composition surface stabilizes. Uses the repo's existing @experimental convention; Agent's established APIs (this.schedule() and friends, agent.mcp) are unaffected. Also refreshes Scheduler's class doc for the registered-callbacks constructor. * docs(scheduling): separate Scheduler and Agent API reference The API reference listed Agent's schedule()/scheduleEvery()/get/list/ cancel methods directly under the Scheduler constructor heading, reading as methods on the Scheduler — which no longer has stringly verbs at all. Group the reference into 'Scheduler primitive' (constructor, set, every, get/list/cancel) and 'Agent methods' (the stable delegating surface). * refactor(schedules): make Scheduler sync reads internal apertures The synchronous getSchedule()/getSchedules() on Scheduler exist only to back Agent's deprecated sync compat surface — the primitive never shipped them, so born-deprecated was the wrong label. Retag @internal and move them into the host-owned policy aperture section beside cleanupRoutePrefix(), keeping the primitive's contract at set/every/get/list/cancel. Agent's deprecated methods are unchanged. * refactor(schedules): loud-name Scheduler internal sync reads Rename the @internal sync compat reads to __DO_NOT_USE_WILL_REMOVE__getSchedule(s), matching the existing __DO_NOT_USE_WILL_BREAK__ convention, so the published types make their status unmissable. Agent's deprecated getSchedule()/getSchedules() delegators are unchanged. * docs(lifecycle): purge stale callbacks-service references The registered-callbacks migration replaced the Lifecycle callbacks service with runInHostContext and moved Agent's name fallback into a composition-root resolver, but comments and docs still described the old model in nine places: the LifecycleHostInvoker JSDoc (pointing at the removed LifecycleServices.callbacks), the setLifecycleHostInvoker and Agent composition-root comments, the Scheduler module header and executeCallback doc, the SchedulerCallbacks and callbacks-option docs (claiming a generic host-method fallback the Scheduler does not perform), the schedules example README, and the capability-harness driver doc (which also pointed at a nonexistent scheduler-harness.ts). Comment/doc changes only. * refactor(schedules): apply procedural-review sweep findings Six-lens consistency review of the full diff surfaced the remaining scope-fuzz; all mechanical, no behavior changes except loud-renames: - agents/schedules now exports SchedulerHandlers, SchedulerPayload, and SchedulerEventType (they appear in Scheduler's public signatures) and carries a scoped @experimental module banner; SchedulerCallbacks, SchedulerHandlers, SchedulerPayload tagged @experimental; Agent's lifecycle/scheduler properties tagged @experimental. - The permanent Agent-only host hooks on Scheduler are loud-named __DO_NOT_USE_WILL_BREAK__cleanupRoutePrefix/handleAlarmMemoryLimit, matching the repo convention (they are permanent internals, unlike the WILL_REMOVE sync-read shims). - unstable_getSchedulePrompt/unstable_scheduleSchema moved off the new agents/schedules/parser entry back to the deprecated agents/schedule compat entry, so the new surface ships no born-deprecated aliases. - Scheduler messages stop recommending Agent-only method names: the stale-one-shot warning is host-neutral and the sync-read errors give both Agent and standalone guidance; constructor JSDoc no longer promises a host-method fallback. - Deleted the phantom ScheduleStorageRow.retry field, two stale @template tags, and a stale resolveRetryConfig comment. - Tests: env imported from cloudflare:workers in capability-harness; console-capture.ts moved beside the fixtures that import it (tests/shared stays pool-only); stale lifecycle.test.ts pointer fixed; the legacy-parser tests-d check now actually asserts. - Docs: exclusive-contribution paragraph moved into Shared alarm ownership; example imports getCurrentAgent from agents/lifecycle; next README marks mcp-client Available; changeset names the experimental Agent properties. * style: format lifecycle.md after paragraph movegithub.com-cloudflare-agents · 29b01079 · 2026-08-27
- 4.1ETVfeat(agent-think): issue repro/fix agent on Workers + Containers (#1861) * feat(agent-think): issue repro/fix agent on Workers + Containers A Think agent that reproduces and fixes cloudflare/agents GitHub issues inside a container-backed @cloudflare/workspace VFS, triggered from an issue comment (@agent-think <instruction>) via a GitHub App webhook worker. Supersedes the CI-based /repro + /pr Actions from #1844 — same skills, but running as a persistent Worker + pre-warmed container instead of Actions runners. Architecture (patterns from aron/cloudflare-workspaces-prototype): - AgentThink WorkerEntrypoint: dispatch() RPC from the webhook worker; returns in ~1s (submitMessages only — container gh/git auth happens inside the durable turn via beforeTurn, so the caller's waitUntil cancellation window can never kill the run) - ThinkAgent DO owns the Workspace (SQLite VFS) + the durable turn; two exec backends: container (full Linux: gh/git/npm/node/wrangler) and just-bash isolate for cheap text ops - Sandbox DO hosts the Cloudflare Container; WarmPool DO keeps one pre-warmed and hands them out per session - live thread UI (Vite + React) at /thread/:session - skills (reproduce / open-pr) mounted read-only from R2; repros must ship a minimal Vite frontend so maintainers can click the deployed URL and watch the failing behavior in a UI The worker holds no GitHub App credentials — the webhook worker mints a short-lived installation token per dispatch. Note: requires the enable_abortsignal_rpc compat flag — the container backend's health probe passes an AbortSignal over cross-DO RPC. Verified end-to-end in prod on issue #1859: trigger comment to bot reply in 5s, 30-minute turn with real clone/install/deploy, structured repro report posted back on the issue. * docs(agent-think): HANDOFF — branch/PR landed, note skill-recipe verification * docs(agent-think): gh-app no longer posts an 'on it' comment (👀 only) * chore(agent-think): AGENTS.md, tidy root configs, standardise on .env - Replace HANDOFF.md (session-log style) with AGENTS.md: aims, how the system works, the rules we hold ourselves to, and the edge cases that cost real debugging time (abortsignal RPC flag, lossy tail, stale container reconcile, R2 skill seeding, Access, WARP builds). - Vitest configs move next to their suites (test/, tests-e2e/); root keeps only vite.config.ts (thread UI build). - Env files standardise on .env + .env_example (the e2e harness reads .env); drop .dev.vars.example and the stray root WARP pem copy. - Drop the vite alias workaround for agents/chat/react — the subpath export exists upstream now, plain resolution works. * chore(agent-think): prune unused deps, add author @cloudflare/worker-bundler, ws, @types/ws: imported nowhere. @cloudflare/workers-types: redundant — tsconfig consumes the wrangler-generated worker-configuration.d.ts runtime types only. (isomorphic-git and @platformatic/vfs stay: optional peers of @cloudflare/workspace whose main entry — which we bundle — imports both; git.diff runs on isomorphic-git.) * fix(agent-think): commit generated worker types for CI; address review - Commit worker-configuration.d.ts (wrangler types) and stop ignoring it — CI has no way to generate it, so the tsconfig types reference failed with TS2688 on a fresh checkout. - tsconfig extends agents/tsconfig (verbatimModuleSyntax et al.), with the types list overridden to the generated runtime file — keeping @cloudflare/workers-types alongside it would conflict. client.tsx now typechecks too (was outside the old include). - compatibility_date 2026-05-26 -> 2026-06-11 (repo standard), both configs; types regenerated against it. - write tool now takes the same per-file lock as edit: its stat-then-write mode preservation had the same interleaving window edit's read-modify-write guards against. Lock extracted to src/tools/fs/file-lock.ts. * docs(agent-think): advertise auto-created parent dirs in the write tool The store already mkdir -p's the parent on every write; telling the model saves it a container exec mkdir round-trip first. * feat(agent-think): GPT-5.5 via gateway catalog + command-center UI Model: openai/gpt-5.5 through the default AI Gateway's model catalog (Unified Billing over the AI binding — no provider key). The providers: [openai] plugin is required: workers-ai-provider refuses {provider}/{model} slugs without it (verified empirically: text, tool calls, and streaming all work with the plugin; raw env.AI.run works either way but Think needs an AI SDK LanguageModel). Command center: the root URL is now a dashboard run by a singleton CommandCenterAgent (synced-state registry of every thread + counters). ThinkAgent reports dispatch/tool/turn events fire-and-forget — observing must never break a run. The UI gains a ChatGPT-style left sidebar listing threads reverse-chronologically, live over agents state sync; /thread/:session renders inside the same shell. The old plain-text root banner is gone (root serves the SPA, with a worker fallback where asset-first routing is not emulated). * feat(agent-think): command-center repo cards + sidebar search Main screen leads with per-repo cards (name, github link, issue/status counts) per the wireframe; the sidebar gains a search filter and a ChatGPT-style Recents treatment. Sidebar persists across thread navigation (unchanged). * fix(agent-think): per-comment turn idempotency The repo#issue idempotency key silently swallowed re-mentions: once an issue's first turn completed, submitMessages returned the old submission (accepted:false) and nothing ran. The key now includes the triggering commentId (passed by gh-app); dev dispatches without one get a random key. Webhook redeliveries are already deduped in gh-app's KV before dispatch, so nothing is lost. * chore(agent-think): observable command-center reporting Log (never throw) when a lifecycle report fails, and emit one structured line per registry update — silent-success and silent-failure were indistinguishable in the logs. * fix(agent-think): hermetic assets fixture for the unit suite CI has no vite output (dist/client is not committed), so the root-route test read an empty body. The test config now points ASSETS at a committed fixture with the SPA root node. * fix(agent-think): HTTP snapshot fallback for the command center Cloudflare Access on the domain passes authenticated HTTP but eats WebSocket upgrades (zero WS ever reached the worker — the thread view only worked via useAgentChat's HTTP get-messages polling). Plain useAgent state sync has no such fallback, so the command center rendered empty. GET /api/command-center returns the registry snapshot; the client hydrates from it and polls while the WS is not connected. * feat(agent-think): issue title + requester avatar on thread rows ThreadMeta carries the GitHub issue title and who mentioned @agent-think (login + avatar). Activity rows and the sidebar show the title; the requester's avatar sits on each row with a hover tooltip ('login: instruction'). Both flow from the webhook payload through dispatch; old threads without the fields fall back to the instruction. * fix(agent-think): route /agents/* and /api/* worker-first — WS upgrades died at the assets router The assets layer forwards ordinary no-asset-match requests to the worker but not WebSocket upgrades, so every wss:// connect to /agents/* failed while plain HTTP worked — which is why the command center sat on the HTTP fallback and showed 'disconnected'. (Corrects the earlier Access diagnosis; Access passes authenticated WS fine.)github.com-cloudflare-agents · d1ce3cbd · 2026-07-03
- 3.8ETVfeat(codemode): connector model + durable runtime, snippets, and vite plugin (#1581) * feat(codemode): connector model with durable runtime, skills, and vite plugin Executor is the dumb code sandbox (DynamicWorkerExecutor, IframeSandboxExecutor). CodemodeRuntime is a DurableObject facet that wraps an executor and makes execution durable via abort-and-replay. Connectors — class-based service integrations (WorkerEntrypoint subclasses): CodemodeConnector, McpConnector, OpenApiConnector, ToolsetConnector Runtime — durable execution engine: - Every tool call recorded in a durable log (the replay spine) - Observations execute and record; approval-required actions abort the run - resumeCodemode() replays the log and runs the approved action - rejectCodemode() / rollbackCodemode() for HITL resolution - codemode.get/set persist scratchpad state across runs Model-facing tool: createProxyTool({ ctx, executor, connectors, skills }) → { code }. Sandbox SDK: codemode.search/describe/connectors/pending/run/get/set + connector globals. Skills: CodemodeSkillSource interface — pluggable reusable code patterns. Vite: @cloudflare/codemode/vite discovers *.codemode.ts, auto-exports connectors + runtime. Search: Executor-style ranked search with normalization/scoring. Connectors support revertAction() for rollback. * feat(codemode): runtime handle with pending(), spec+request OpenAPI surface - createCodemodeRuntime({ ctx, executor, connectors }) returns the runtime handle; runtime.tool() is the primary way to expose codemode to a model - add runtime.pending() (and pendingCodemode) so approval UIs can list actions awaiting approval, per the RFC runtime API - OpenApiConnector is now two overridable primitives: spec() returns the OpenAPI doc into the sandbox, request() performs an authenticated call; drop the search substring matcher and operationId dispatch - docs, README, changeset and PR body updated to snippets language and the runtime-first API * Merge origin/main into feat/codemode-executor-style-providers (pnpm migration) * feat(codemode): trim to the minimal API surface Sandbox SDK is now five methods: search, describe, step, save, run. Removed codemode.connectors()/pending()/fork()/get()/set()/snippets(): - pending() was dead code (a pause aborts the run, so there is never anything pending while model code is executing) - fork() is a host decision, not a model decision - connectors() duplicated the tool description and search/describe - get/set duplicated step (deterministic code recomputes on replay; nondeterministic work belongs in a step) - snippets() duplicated search Host runtime handle is now: tool, pending, approve, reject, rollback. - removed the resume() alias of approve() - removed runtime.fork() and the facet fork(): no concrete developer story yet; the replay log supports re-adding it later - removed the duplicate description option on createCodemodeRuntime (set it on runtime.tool({ description }) instead) - scratch state and parentId removed from ExecutionState The low-level proxy-tool functions (createProxyTool, resumeCodemode, rollbackCodemode, ...) are no longer exported: the runtime handle is the one public API, matching the RFC's one-way-of-doing-things thesis. Docs, changeset, PR body and the RFC wiki (v12) updated to match. * feat(codemode): one tools() record per connector, curated snippets, execution audit trail Connector authoring is now a single surface. A connector is three things: name(), instructions()?, and tools() — one record, one entry per tool, with each tool carrying its own description, schema, requiresApproval, execute, and optional revert. The old parallel string-keyed maps (loadDescriptors/annotations/executeTool/ revertAction) are now wire plumbing derived from the record, not something authors write. ToolsetConnector is deleted: AI SDK toolsets are shape-compatible and return from tools() directly. Derived connectors (MCP) are decorated via a single tool(name, t) hook. observation and approvalDescription annotations are gone (observation was behaviorally dead; the approval UI uses the tool's own description). setConnection two-phase init replaced by constructor injection in the example. Snippets are curated by the developer, not self-promoted by the model: codemode.save is removed from the sandbox; runtime.saveSnippet(name, { executionId? }) promotes any run's script, with runtime.snippets() and runtime.deleteSnippet(name) for management. runtime.executions() exposes the full run history (the audit trail) for developer UIs. The sandbox SDK is now four methods: search, describe, step, run. Also: docs/codemode overhauled around why/configure/use per page (search-and-describe.md folded into runtime.md), example dependency versions aligned with the workspace so sherif passes, RFC wiki updated to v15. * chore(codemode): refresh PR body with audience-split API summary * fix(codemode): address review findings - connector sandbox proxies guard non-string property access, matching the dispatcher proxy, so symbol lookups no longer produce bogus RPC calls - codemode.run executes snippets with the platform provider attached: snippets are saved execution code and may use codemode.step, which previously threw ReferenceError inside a snippet run - McpConnector throws on sanitized tool-name collisions instead of silently dropping tools; override toolName() to disambiguate - add connector base tests (describe derivation, execute/revert dispatch, tool() decoration hook, collision error) * fix(codemode): harden durable runtime — stateless, explicit executionId, resilient rollback Reworks the CodemodeRuntime durable-execution model for correctness under hibernation and concurrency, simplifies the OpenAPI connector, and adds an end-to-end test suite. Runtime architecture - Make CodemodeRuntime stateless across calls: no in-memory cursor or annotations. Every interaction is addressed by (executionId, seq), with seq allocated host-side, so a run survives eviction between any two tool calls. - Remove the global CURRENT_KEY "current execution" pointer and its helpers (#currentId, #current, #resolve). approve/reject/rollback/saveSnippet now require an explicit executionId, eliminating a class of races when multiple runs share one Durable Object. - Thread executionId through to every tool outcome: ProxyToolOutput now includes executionId on completed/paused/error so callers can follow up (e.g. saveSnippet) without guessing the newest run. Replay correctness - Add "executing" ToolLogEntryState: non-approval calls/steps are logged as executing by decide() and only promoted to "applied" once recordResult() stores the real value. A crash between the two re-executes instead of replaying undefined. - Detect replay divergence by hashing connector/method/args via a stable stringify (sorted keys, bigint-tagged). Divergence is recorded as a terminal error and surfaced as { status: "error" } rather than thrown across RPC. - Guard decide() on terminal/paused state: once a run is paused/error/ rolled_back, further decide() calls are inert and return a pause decision, so model code that swallows the pause sentinel cannot apply more side effects. Approvals & rollback - rollback() now reverts ALL applied reversible actions (any tool with a revert), not just approval-gated ones, in reverse order. requiresApproval (pause-before-do) and revert (undo-after-do) are orthogonal. - Make rollback resilient: each revert is wrapped in try/catch, all reverts are attempted, failures are aggregated and thrown, and the run is marked with the new "rolled_back" status when anything was undone. - listPending()/pending() aggregate pending actions across ALL paused runs when no executionId is given, fixing a racy single-run approvals view. - Document that reject() ends a paused run but does not undo applied actions. Execution retention - begin() accepts maxExecutions and prunes old terminal runs automatically; add explicit deleteExecution() and pruneExecutions() APIs. Connectors / DX - OpenApiConnector derives one typed tool per operation host-side (e.g. repoApi.get_repository) instead of making the model parse the raw spec; request() remains as an escape hatch. Adds module-level memoization of derived operations (WeakMap keyed by spec), deeper $ref resolution across allOf/oneOf/anyOf/additionalProperties, and collision warnings for operation names that clash or hit reserved names. - Pass connector bindings as RpcTarget evaluate() arguments instead of via worker env to fix DataCloneError; route pause via a control marker rather than throwing across the sandbox→host RPC boundary. - Switch DynamicWorkerExecutor to loader.load() for one-off dynamic workers (loader.get(random-id) gave no caching benefit). - Widen CodemodeConnector ctx to DurableObjectState | ExecutionContext so connectors inside a Durable Object no longer need to cast this.ctx. - revertAction() returns boolean to report whether a revert actually occurred. Tests, docs, cleanup - Add src/runtime-tests/ e2e suite (vitest-pool-workers) driving a real DO host: read-only, pause/approve, replay, reject, rollback (+rolled_back), divergence, step replay-safety, concurrent runs, retention, snippets, delete, pause-swallow guard, and pending aggregation. Wire vitest.runtime.config.ts into test script. - Rewrite examples/codemode-connectors with an approvals panel and snippet flow. - Update changeset and docs (runtime, approvals, connectors, index, READMEs). - Delete orphaned src/mcp-provider.ts and stray .pr-body.md / EXECUTOR_TODO.md. * feat(codemode): per-execution connector lifecycle + result shaping Adds the two codemode primitives needed for stateful connectors (e.g. reusable browser sessions) to ride on the durable runtime instead of reinventing session storage, plus a model-facing result transform. Both are additive. Per-execution resource lifecycle - A tool's execute(args, ctx) and revert(args, result, ctx) now receive a ToolExecuteContext carrying the run's executionId, stable across pause/resume, so a connector can lazily acquire/reconnect a resource keyed by that id. - CodemodeConnector.disposeExecution(executionId, status) is an optional override (default no-op) called when a run reaches a terminal state, so a connector can tear the resource down. It fires on each terminal transition (completed/error/rejected/rolled_back) and never on pause — a paused run may resume, so the resource must outlive a pause. Documented to be idempotent (a completed-then-rolled-back run disposes twice), to not rely on instance memory (keyed off durable storage), and to never throw (rejections ignored). - A stale/no-op reject() no longer triggers teardown: runtime.reject now returns whether it actually terminated the run, and dispose is gated on that, so a still-resumable run keeps its resources. rejected is now a first-class ExecutionStatus - reject() marks the execution "rejected" instead of masquerading as "error", so the audit trail distinguishes a user rejection from a failure, and ExecutionEndStatus is exactly the terminal subset of ExecutionStatus. Result shaping - createCodemodeRuntime accepts an optional transformResult that reshapes the model-facing result of a completed run (initial run and resume), after the raw value is recorded — so the audit trail keeps the full result while the model sees the shaped one. A throwing transform falls back to the raw result rather than failing a completed run. - New exported truncateResult/truncateResponse (token-aware, { maxChars?, maxTokens? }) as the default building blocks: small structured results pass through unchanged; oversized ones serialize to a bounded, marked string. Tests + docs - e2e: executionId threading, dispose on complete/reject/rollback, no dispose while paused, no dispose on a stale reject, transformResult on run + resume. - unit: truncateResponse/truncateResult. - Documented the lifecycle contract, result shaping, the rejected status, and the sequential-tool-call determinism constraint; updated the changeset. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(codemode): guard resume() to paused runs + make example methods callable Two correctness fixes surfaced in review, plus the docs/readme/changeset updates that go with them. 1. resume() no longer revives terminal runs CodemodeRuntime.resume() reset status to "running" unconditionally, so approve({ executionId }) on a completed/error/rejected/rolled_back run flipped it back to running and re-executed it — bypassing decide()'s terminal guard. The concrete hazards: a rejected action's log entry (state "reverted") fell through decide() to a fresh "pending" entry, re-offering the exact action the user rejected; and a rolled_back run re-applied the side effects rollback had just undone. resume() now changes nothing unless the run is "paused" (returns null otherwise). resumeCodemode() distinguishes missing vs. not-paused and returns a { status: "error", executionId, error } ProxyToolOutput instead of throwing — matching the divergence/pause paths, so the result crosses RPC cleanly and the agent loop is never broken by an exception. This is intentionally a safe no-op rather than a hard error: approve() is operator-initiated (never on the model's tool path), and a stale/racing approval UI hitting an already-finished run is an expected race, not a caller bug. 2. example server methods are now @callable() examples/codemode-connectors exposed pendingApprovals/approveExecution/ rejectExecution/rollbackExecution/executions/saveSnippet/snippets for the client's agent.call(), but none carried @callable(). The Agent RPC dispatcher rejects any method without callable metadata ("Method X is not callable"), so the entire approval/snippet UI threw at runtime. Added the import and the decorators. Tests: new e2e "refuses to approve a terminal run, never re-offering a rejected action" (reject a paused run, then assert approve returns status:"error", no new pending action, no leaked side effects, run stays rejected). 282 unit + 21 e2e + 33 browser pass; pnpm run check clean. Docs: approvals.md and the example README document approve() as a safe no-op on a non-paused run; README snippets fixed to show @callable() and the now-required saveSnippet executionId; changeset updated. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(codemode): listPending() only surfaces paused runs pendingOf() filters log entries by state === "pending" without regard to the execution's overall status, and the aggregate listPending() scanned every execution. A non-paused run can retain a stale "pending" entry — #diverge sets status to "error" but leaves the log untouched, so a resume that diverges before reaching the pending entry ends the run as "error" while that entry stays "pending". Those entries aren't actionable (approve() is a no-op on a non-paused run), so they must not clutter the approval queue. listPending() now considers only paused runs on both paths. The explicit executionId path is only ever called from runPass on a confirmed-paused run, so guarding it is safe and makes "pending = actionable approval on a paused run" the consistent contract. This matches the docs, which already said "all paused runs" — the code was the side out of sync. Regression: the divergence e2e now asserts that after the run ends "error" with a leftover pending entry, both pending() and pending(executionId) return []. 282 unit + 21 e2e + 33 browser pass; pnpm run check clean. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(codemode): reserve __connectors so a provider can't shadow RPC bindings RESERVED_NAMES guarded __dispatchers (one evaluate() parameter) but not __connectors (the other). A provider named __connectors passed validation and emitted `const __connectors = new Proxy(...)` into the same function scope as the `evaluate(__dispatchers = {}, __connectors = {})` parameter — clobbering the RPC bindings that every connector proxy reads from (`__connectors.<name>.callTool`), and in fact producing a SyntaxError (const redeclaring a parameter binding). The connector validation path already special-cased "__connectors"; the provider path didn't. Add __connectors to RESERVED_NAMES so both providers and connectors are checked against it, and drop the now-redundant special case in the connector loop. Regression: new executor test asserts a provider named __connectors is rejected as reserved. 283 unit + 21 e2e + 33 browser pass; pnpm run check clean. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(codemode): connector errors return a marker instead of rejecting across RPC The connector binding's comment promised "the RpcTarget method always resolves" (control signals are returned, not thrown, to avoid an unhandled rejection on the host), but the execute/record path didn't honor it: connector.executeTool, runtime.recordResult, and runtime.decide could all reject ConnectorCallTarget. A rejected promise returned from a DO/RPC method is tracked as an uncaught (in promise) on the host even though the sandbox awaits it — so any throwing connector tool (a failed API call, a bug) produced a misleading "unhandled rejection" host trace. Correctness was already fine (the sandbox try/catch ends the run as "error"), but the noise contradicted the design. Make the error path symmetric with pause: the whole binding body is wrapped so it always resolves — to a result, a { control: "pause" } marker, or a new { control: "error", message } marker. The sandbox connector proxy re-throws the error marker locally, so the run's own try/catch records it and the run ends "error" with the message exactly as before — just without a host-side rejection. Regression: ItemsConnector gains a boom tool that throws; a new e2e asserts the run ends "error" with the message and the suite completes with no unhandled rejection. 283 unit + 22 e2e + 33 browser pass; pnpm run check clean. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(codemode): log connector-call failures on the host before returning the marker Returning an error marker keeps the RPC call from rejecting (no misleading unhandled-rejection trace), but a genuine connector failure still deserves a host-side log with its stack for debugging. Add a console.error in the binding's catch with the connector/method and execution id. This restores the visibility the pre-marker throw had — minus the "uncaught (in promise)" framing — while the message continues to reach the model and the audit trail via the run's "error" outcome. Pause is unaffected (it isn't an error and isn't logged). 283 unit + 22 e2e + 33 browser pass; pnpm run check clean. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(codemode): mark approved action "executing" before running it (reject race) decide() returned { kind: "execute" } for a just-approved (pending) entry WITHOUT persisting, leaving the entry "pending" in storage for the entire tool-execution window. Between decide() returning and recordResult(), the DO is idle (the tool runs on the host worker), so a concurrent reject() — e.g. a second UI tab — could read "pending", mark it "reverted", and set status "rejected"; recordResult() then overwrote the entry back to "applied". Net result: the side effect ran even though the user rejected it, and the status was left inconsistent. The fresh-call path already guarded this window by persisting "executing" before returning; the pending→execute path skipped it. Now the pending→execute transition writes "executing" before returning, so a racing reject() sees "executing" and no-ops (reject only acts on "pending"). decide() also handles an existing "executing" entry explicitly — re-execute, never re-pause — so a crash mid-execution recovers without re-requesting approval for an already-approved action (which a naive "executing" flip would have caused via the requiresApproval branch on the fall-through). Either interleaving is now consistent: reject-before-decide ends the run before the action runs (decide sees status != running → pause); decide-before-reject runs and applies the action while reject no-ops. Regression: new e2e drives the facet directly (begin → decide → resume → decide → reject → recordResult) and asserts the approved action is "executing" at the decision boundary, the racing reject returns false and leaves the run "running", and the action records as "applied". 283 unit + 23 e2e + 33 browser pass; pnpm run check clean. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(codemode): normalize snippet code before embedding it in codemode.run A snippet stores the model's raw code (runtime.begin keeps it verbatim, and saveSnippet copies it). codemode.run embeds that raw text as an expression: `const snippet = (${snippet.code})`. Normal runs and replay survive fenced or statement-style code because they pass through normalizeCode (strip markdown fences, wrap non-expressions into an arrow), but the snippet wrapper bypassed that — so a snippet saved from ```ts-fenced output or a statement block (`const x = ...; return x;`) became a syntax error on re-run. Normalize snippet.code to a valid arrow expression before embedding it, the same transform the executor applies to a fresh run; runCode still normalizes the outer wrapper. The fix lives in the execution layer (proxy-tool) so the runtime facet stays pure storage and snippet.code remains the faithful raw model output. Regression: new e2e saves snippets from both fenced (```ts ... ```) and statement-block code, then re-runs each via codemode.run and asserts they complete with the right result. 283 unit + 24 e2e + 33 browser pass; pnpm run check clean. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(examples): richer codemode-connectors UI + dark-mode fix Render assistant/user text through Streamdown (markdown + highlighted fences), add collapsible tool cards showing the model's code, result, console logs, and errors, and add a collapsible reasoning-trace block. Fix the user message bubble, which used a non-theme-aware `text-black` on the accent background, switching to `bg-kumo-contrast` + `**:text-kumo-inverse` so it reads correctly in both light and dark mode. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Matt Carey <matt@cloudflare.com> Co-authored-by: Sunil Pai <spai@cloudflare.com> Co-authored-by: Cursor <cursoragent@cursor.com>github.com-cloudflare-agents · b2b67623 · 2026-06-10
- 3.1ETVfix(observability): preserve AI span hierarchy across WebSocket turns (#1982) * fix(observability): bound AI spans to websocket invocations * fix(observability): bound AI spans to invocations without losing attributes Closing a span at the first `await` ended every WebSocket-turn span before its result existed. Because a closed ManagedSpan drops writes silently, that discarded token counts, finish reason, response id and model, time to first chunk, AI Gateway log id, tool results, and error classification, and left every span reporting a zero duration. It applied to `_withAgentSpan` too, so non-AI agent spans lost their deferred `update()` writes on any WebSocket request. Bounded spans now close at the end of the invocation that owns their tracing context, not at the first async boundary, so everything that completes during the invocation still records its finish attributes. A span still open when the invocation ends is closed and marked `cloudflare.agents.span.truncated` rather than passing as complete. Work deliberately detached from its handler -- `ctx.waitUntil` bodies, queue drains -- opens its own boundary instead of being cut off with the handler that started it. On v7 the turn metadata bag no longer exists, so identity and turn context arrive through `runtimeContext` and the SDK's `telemetry.includeRuntimeContext` allowlist. Reserved keys project onto the attributes v6 already emits, so a query written against v6 traces still matches v7 ones; other included keys pass through as `cloudflare.agents.runtime_context.{key}`. Runtime context the caller did not mark stays off the span. The v7 integration test pinned the values of two attribute keys, which is how this shipped. It now asserts the same attribute set the v6 test does, over the real AI SDK: usage, tool arguments and results, response metadata, and both span lifetime outcomes. * fix(observability): give Think turns their own invocation boundary A turn admitted from a timer has no live invocation to nest in. Auto- continuation arms a coalescing timer and returns, so the turn it later fires resumes carrying the context of a handler that has already finished; every span it opened was bound to that dead context and closed on sight, leaving an `invoke_agent` span with no usage, no finish reason and no duration. The turn body now declares the boundary itself. Admitted from a handler it still nests in that handler's invocation and nothing changes; admitted from a timer or a fire-and-forget continuation it becomes its own, which is the only lifetime that is actually known at that point. Also fixes a typecheck failure in the v7 boundary test: a conditional between two chunk fixtures widened to a union the mock model's stream type rejects. * fix(observability): bound generateText and reconcile the two context allowlists `isAISDKInvocationBounded` was only ever consulted on the streaming path, so `generateText` and `generateObject` — and the `chat` span inside `wrapGenerate` — stayed open past the invocation that owned their tracing context. Both now take the same lifetime the streaming path does. The wrapper-level `includeRuntimeContext` and the SDK's per-call option select from the same runtime context but landed on different attributes: a key named in both produced a canonical attribute AND a `runtime_context.*` near-duplicate, and the wrapper option could resurrect identity keys that belong on `gen_ai.*`. They now share one projection, so selecting a key twice emits it once. An array of key names is accepted alongside the SDK's boolean map, since that is the shape of the wrapper's option of the same name and silently ignoring it helps nobody; an explicit `false` now keeps identity off the span, where before only a missing key was honoured. There is still deliberately no "include everything" shorthand. Identity is read from v6 `experimental_context` as well as v7 `runtimeContext` through one accessor, so the two majors cannot drift on where it comes from. * fix(observability): keep the usage a failed or cancelled turn already reported `fail()` records only the classification, so a turn that errored or was cancelled part-way through recorded `error.type` and nothing else — no tokens, no finish reason, no response metadata — even when the stream had already reported all of it. A cancelled turn read as having consumed nothing, which is worse than reporting the partial truth, and it is the same "no tokens" symptom that made v7 telemetry look broken in the first place. The stream observer already accumulates that data to build its completion summary. It now hands the same summary to the failure path, which writes it before classifying the span. Note the summary has to be threaded through `errorOnce` as well: widening only the hook declarations leaves the forwarder silently dropping the argument, so the attributes never reach the span and the fix looks correct while doing nothing. * fix(observability): let a chat turn own its traced boundary A turn borrowed the boundary of whoever admitted it, which is wrong whenever the admitting handler does not await it. A WebSocket handler that starts a turn and returns an ack closes in ~100ms while the turn runs for seconds; because the handler's scope was still live when the turn asked for one, the turn reused it, and the handler's return truncated every span in the turn. The root `chat_turn` went with them — `_withAgentSpan` marks a span bounded whenever a connection is present — so even `turn.status` was lost. That is a regression against main, where no span lifetime policy existed at all. A turn's own lifetime is the boundary that is actually knowable, in every case: a handler that awaits its turn ends at the same moment anyway, and one that does not no longer drags the turn down with it. AIChatAgent needed the same treatment. It drives the same AutoContinuationController as Think and establishes the same connection-bearing context, so bounding was already on for its continuation turns, but it never declared a boundary — leaving them to run inside the dead scope the coalescing timer inherited, with every span truncated on sight. * chore(observability): drop an unused test constant * fix(observability): bound approval spans to the invocation that decided them Tool approval is decided asynchronously: `needsApproval` may return a promise and v7's top-level `toolApproval` policy always does. When the decision lands after the invocation has ended, the approval spans were opened against a context that no longer exists — emitted unflagged, under a parent already closed — while every other span in the same position was marked. The cause was in the tracer rather than the approval code. `withSpan` only consulted `boundToInvocation` on its promise branch, so a span that opens and closes within one tick ignored the policy entirely; approval segments are exactly that shape. It now asks before running the callback, so a span whose whole tick falls outside its invocation is marked on both paths. The approval helpers were never handed the policy either, so `wrapApprovalCheck`, `wrapToolApprovalPolicy` and both `recordApproval*` functions now receive it. The decision itself is still recorded either way — flagged, not discarded. * docs(observability): tighten the changesetgithub.com-cloudflare-agents · e983026b · 2026-07-28
- 3.0ETVrefactor(agents): extract facets into a dynamic-agents capability, reposition for isolation not chat sessions (#2193) * refactor(agents): extract dynamic-agent types, identity, and RPC bridges First step of moving the facet (sub-agent) machinery out of the Agent god class into packages/agents/src/dynamic-agents/. Pure motion: the moved types, identity helpers, and connection/reply bridges keep their behavior; index.ts imports them under the old local names. Public exports (SubAgentClass/SubAgentStub) are unchanged, now aliasing the module's DynamicAgent* names. No wire- or storage-visible identifier is renamed. Claude-Session: https://claude.ai/code/session_01KZ4booD9Pt5jXkRVmjhdb7 * refactor(agents): move the sub-agent registry into dynamic-agents/registry The cf_agents_sub_agents table (identity versioning, has/list/record/ forget) now lives in DynamicAgentRegistry with a two-method SQL host port. Agent keeps hasSubAgent/listSubAgents overloads and the _cf_ identity entry point as thin delegates. Table and column names are storage-frozen and unchanged. Claude-Session: https://claude.ai/code/session_01KZ4booD9Pt5jXkRVmjhdb7 * refactor(agents): move lifecycle routing and root facet bookkeeping into DynamicAgents Introduces the DynamicAgents class with an explicit host port (DynamicAgentHostPort) that documents every Agent internal the facet machinery touches. Moves lifecycle route addressing/traversal, root alarm-owner resolution, facet-prefix cleanup, facet keepAlive leases, and the cf_agents_facet_runs row index. Agent keeps all _cf_* RPC entry points as one-line delegates. Claude-Session: https://claude.ai/code/session_01KZ4booD9Pt5jXkRVmjhdb7 * refactor(agents): move facet resolution, teardown, and fiber recovery into DynamicAgents resolveSubAgent (the ctx.facets.get bootstrap + identity handshake), abort/delete, recursive descendant destroy, workflow path invocation, and the root-side facet fiber recovery scan now live on the DynamicAgents class; the registry is owned by it. Agent keeps _cf_* RPC entry points and public methods as delegates, plus a private _runFacetInitInvocation helper so the module never touches the invocation context machinery directly. Claude-Session: https://claude.ai/code/session_01KZ4booD9Pt5jXkRVmjhdb7 * refactor(agents): move WebSocket forwarding and facet invocation into DynamicAgents The parent-side frame forwarding, facet-side virtual connections, connection-operation queues and broadcast barrier, /sub/ request forwarding, stub invocation (single-expression RpcProperty dispatch preserved verbatim), facet init handshake, and connection hydration now live on DynamicAgents, with module-owned state (bridge ALS, virtual-connection map, operation tails) and terse method names (invoke, invokePath, forward, resolve, delete, init, ...). Agent keeps every _cf_* RPC entry point as a delegate; calls that subclasses override (_cf_broadcastToSubAgent, _cf_checkRunFibersForFacet) still dispatch through the host so overrides keep intercepting. The WS multiplexing keys move to the module; wire and storage names are unchanged. Full workers project: 1966 tests green. Claude-Session: https://claude.ai/code/session_01KZ4booD9Pt5jXkRVmjhdb7 * refactor(agents): move facet-context restore into DynamicAgents The startup restore of cf_agents_is_facet / cf_agents_facet_name / cf_agents_parent_path plus best-effort virtual-connection hydration becomes DynamicAgents.restoreFacetContext(); Agent keeps only the startup-span wrapper so ordering relative to onStart is unchanged. Claude-Session: https://claude.ai/code/session_01KZ4booD9Pt5jXkRVmjhdb7 * feat(agents): this.dynamicAgents capability facade over facet children Adds the public dynamic-agents capability surface: an @experimental this.dynamicAgents accessor exposing get/abort/delete/has/list over the same machinery as the deprecated subAgent()/abortSubAgent()/ deleteSubAgent()/hasSubAgent()/listSubAgents(), which stay working with @deprecated pointers. DynamicAgentsInternal now registers with lifecycle.use() under capabilityId "dynamic-agents" (its hot paths remain composition-root wired; the class doc explains why the runner hooks cannot express them). New agents/dynamic-agents subpath export carries DynamicAgents, DynamicAgentClass, and DynamicAgentStub; the main entry gains no new surface. Facade behavior pinned against the legacy API in dynamic-agents-api.test.ts. Claude-Session: https://claude.ai/code/session_01KZ4booD9Pt5jXkRVmjhdb7 * refactor(agents): no new package surface — legacy sub-agent methods ride this.dynamicAgents Drops the agents/dynamic-agents subpath export; this.dynamicAgents on the Agent class is the only public addition. subAgent/abortSubAgent/ deleteSubAgent/hasSubAgent/listSubAgents now delegate through the facade so both names are one code path. Claude-Session: https://claude.ai/code/session_01KZ4booD9Pt5jXkRVmjhdb7 * feat(examples): next/chats and next/dynamic-agents examples/next/chats: the recommended many-chats shape — one top-level ChatAgent DO per conversation plus a per-user UserAgent index DO that chats push {title, lastMessage, updatedAt} into. Listing, ordering, and cross-chat search read only the index; deletion is destroy() plus one row. Five workers-pool tests pin the pattern. examples/next/dynamic-agents: what facets are for — a Supervisor agent stores user-submitted DO code, loads it via Worker Loader, and mounts it as a facet with its own SQLite. Demonstrates supervised abort with surviving storage, code upgrades over stable state, capability confinement (globalOutbound: null), and full teardown. Four tests, including loader-backed facets under vitest-pool-workers. Claude-Session: https://claude.ai/code/session_01KZ4booD9Pt5jXkRVmjhdb7 * feat(examples): full-stack React + Vite UIs for next/chats and next/dynamic-agents Converts both examples to the mcp-client-style stack: Vite + @cloudflare/vite-plugin + Tailwind/Kumo, served with 'pnpm run start'. chats: sidebar lists/searches via one useAgent connection to the per-user index DO; each open chat gets its own WebSocket straight to that chat's ChatAgent DO. dynamic-agents: an editor + invoke panel drives the Supervisor — create a gadget, edit its code, deploy (aborts the facet, loads the new class over the same storage), invoke, abort, delete, all visible in a live log. Pinned react/react-dom to the exact 19.2.7 the agents workspace package resolves — pnpm had picked 19.2.8 for these two new packages, producing two React copies and an Invalid hook call crash in useAgent/partysocket. Claude-Session: https://claude.ai/code/session_01KZ4booD9Pt5jXkRVmjhdb7 * docs: reposition sub-agents as dynamic agents; light touches + multi-ai-chat caveat - docs/agents/sub-agents.md: full rewrite. Facet semantics section (separate isolate, own SQLite, no alarms, depth limit, machine-pinned tree, design intent); when-to-use table built on the decision rule (facet = parent-supervised child that must live inside the parent; independent peer = its own DO); corrects the false claim that WS frames flow directly to the child post-upgrade (they don't — every frame wakes the root and is forwarded over RPC); this.dynamicAgents documented as the primary API with the legacy names as a migration table; links to both new examples. - index.md / long-running-agents.md / agent-tools.md: one-sentence repositioning touches, no restructuring. - examples/multi-ai-chat/README.md: caveat pointing at examples/next/chats for the many-chats case. Claude-Session: https://claude.ai/code/session_01KZ4booD9Pt5jXkRVmjhdb7 * chore: fix unused import + dependency version alignment (sherif/oxlint clean) - packages/agents/src/index.ts: drop the now-unused isValidParentPath import (moved to restoreFacetContext). - examples/next/chats, examples/next/dynamic-agents: pin react/react-dom and vitest to the exact versions the rest of the monorepo uses so sherif's multiple-dependency-versions check passes. Claude-Session: https://claude.ai/code/session_01KZ4booD9Pt5jXkRVmjhdb7 * chore: add changeset Claude-Session: https://claude.ai/code/session_01KZ4booD9Pt5jXkRVmjhdb7 * fix(agents): address dynamic-agent review findings - load the selected gadget's stored source before deploying edits - order chat activity in the User DO and prevent delayed pushes from recreating deleted rows - remove dead extraction delegates and pin the public Agent.dynamicAgents type surface - add a proposed RFC for a User hub plus independent top-level Chat DOs; no Think or AIChatAgent topology change in this PR * fix(example): make chat index projection idempotent - key message writes by caller-supplied ids so retries do not duplicate them - project complete chat snapshots with monotonic revisions and ignore stale delivery - keep message acceptance independent from index availability and provide pull repair - cover failed delivery, repair, stale snapshots, deletion, and stable retry results * fix(example): close chat projection and deletion races - accept messages idempotently by caller-supplied id - project complete revision-fenced snapshots and repair stale index rows by pull - gate browser routes through the User catalog instead of exposing physical Agent names - mark catalog rows deleting before Chat destruction and refuse stale-handle writes - keep Chat activity time primary, using User sequence only for deterministic ties * refactor(example): keep the chat topology example focused - remove idempotency, repair, gated-routing, and deletion protocols from the example - document its User index as a best-effort eventually consistent projection - keep production consistency concerns in the separate RFC - retain deterministic User ordering and fix colon-containing user ids * refactor(example): keep the chat topology example focused - remove idempotency, repair, gated-routing, and deletion protocols from the example - document its User index as a best-effort derived projection - keep production consistency concerns in the separate RFC - retain deterministic ordering, reject older metadata timestamps, and support colon-containing user ids * fix(agents): address public API review - export DynamicAgentClass and DynamicAgentStub alongside legacy aliases - mark the new public Agent.dynamicAgents surface as a minor release - order chat lists by activity time with deterministic receipt-order ties - cover delayed cross-DO metadata deliverygithub.com-cloudflare-agents · 87bd5940 · 2026-09-01
- 2.6ETVrefactor(chat): run recovery continuations on Tasks (#2194) * fix(tasks): contain memory-limit loops with the alarm breaker Two confirmed gaps (pinned red-first in memory-limit.test.ts): 1. The driver's in-process dispatch retry converted a memory-limit reset into a silent success — the retry found the half-claimed run not due, returned void, and the wake was deleted without the breaker ever engaging. Memory-limit resets now defer to the alarm boundary like code-update resets: the isolate is condemned either way. 2. The run row outlives the breaker's queue-row policy: startup reconciliation re-derives due-now wakes from it, resurrecting a doomed run through backoff and past sealing. MemoryLimitContext now carries the striking job's identity, and Tasks applies the breaker to the run itself — demoted to the backoff wake on a strike (claim stripped so reconcile honors the deadline), terminally failed (task:failed, TaskMemoryLimitSealed) when the breaker seals. Generic for every definition: the striking run is the one that exhausted memory. Claude-Session: https://claude.ai/code/session_011QZUJztM1rMTsHEC7mbcbz * refactor(chat): run recovery continuations on Tasks Replace root-agent recovery schedule rows with chained runs of the reserved __cf_internal_chat_recovery Task definition shared by AI Chat and Think. Initial attempts deduplicate by incident, delayed retries use step.sleep, and the existing bounded callback methods still detach at model handoff. Legacy and routed dynamic-agent schedules remain as compatibility shims. Make Tasks breaker-safe for recovery definitions: condemned-isolate failures escape journal retries, flagged framework definitions carry queue membership, and onMemoryLimit aligns or seals their authoritative run rows so startup cannot resurrect purged work. Think's submission sweep now inspects Tasks rather than Scheduler rows. AI Chat and Think require agents >=0.23.1, the release containing the shared definition and internal enqueue aperture. * fix(tasks): release non-retained terminal runs Apply retain: false through one terminal cleanup path shared by completed, failed, and cancelled runs. A memory-limit seal now emits task:failed, removes the run and journal, cancels its wake, and releases its idempotency key. Make the breaker regression tolerate only the intentional workerd isolate reset and use per-attempt Durable Object names so Vitest retries cannot collide with durable rows from the failed attempt. * chore: restack recovery tasks on main Preserve #2192's exact atomic helper shape while checking the Task transport through a separate same-RPC method. Align chat peer ranges with the pending changesets release batch, which publishes this work in agents 0.23.0. * fix(recovery): preserve queue attempt ownership Upgrade existing Lifecycle job tables before reading the recovery-loop flag. Let Task wakes defer after one JobDriver attempt because ReplayStep owns their durable retry budget, including reconciliation of older wake rows. Keep pre-handoff chat failures on the current Task or schedule, and enqueue a replacement only after the bounded callback has handed off to the model turn. * fix(tasks): carry late memory resets to breaker When bounded Task dispatch returns before an attempt, preserve a durable late-memory-limit marker if that detached attempt later OOMs. The next alarm rethrows the canonical signal inside JobDriver so existing strike, backoff, and sealing policy remains authoritative. Mark detached job outcomes so their alarm is not treated as a clean breaker cycle, preserve marker wakes through startup reconciliation, and cover queued and warm attempts plus marker cleanup. * fix(tasks): clear stale strikes after detached settlement When work previously detached from an alarm settles without a memory reset, enqueue one no-op Task wake. That wake restores any authoritative run deadline and gives the existing JobDriver a clean alarm boundary to clear stale strikes. Late OOMs keep using the existing late-memory-limit marker. * refactor(lifecycle): own bounded alarm work Keep Lifecycle's deadman alarm armed while it joins promises registered at bounded job handoffs. Classify the whole dynamically growing alarm batch once: all clean work clears prior strikes, while any memory reset enters the existing breaker once with its captured executing job. Tasks now only registers attempts at its five-second handoff, and AI Chat and Think register post-handoff model work so Task and Scheduler transports share the same breaker. Remove the Task-specific late marker jobs and their state. * refactor(lifecycle,tasks): track handed-off alarm work without holding the alarm Lifecycle: `trackAlarmWork` no longer holds the alarm invocation open or coalesces physical re-arms. Handed-off work is classified when it settles: a memory reset records a strike against the job that handed it off, one strike per reset however many flows observe it, and strikes clear only once no handed-off work is outstanding and the last of it settled clean. Attribution follows the dispatch's async context and alarms in flight are counted, so overlapping invocations (tests drive alarm() by hand while the pool auto-fires the physical alarm) stay correct. Tasks: drop the per-definition recoveryLoop grouping and its composition root plumbing (setTaskRecoveryLoopDefinitionResolver, Agent._recoveryLoopTaskDefinitions, the recoveryLoop option on _registerInternalTaskDefinition). onMemoryLimit acts only on the run whose wake struck, stripping the claim and pushing the deadline so the reclaim still sees an interrupted attempt. onJob hands off one canonical promise per attempt, #syncWake skips same-values pushes, and runAttached actually attaches instead of racing the warm start. Chat: both hosts run the bounded recovery callbacks through the shared dispatchChatRecoveryToHandoff and register the recovery Task with hooks keyed by callback name. Design docs, changeset, stale Scheduler/recovery-engine comments and tests updated; new tests pin one-strike-per-reset and the startup re-arm. Claude-Session: https://claude.ai/code/session_011DVPXpp9SyXM1ChxvkQCTB * fix(lifecycle): attribute strikes correctly under overlapping alarms Two correctness fixes from Devin Review, both against the previous commit's job-driver.ts: - Overlapping alarm() invocations attributed a memory-limit strike to whichever job a shared instance field (#executingRow) last pointed at, not the job that actually struck — the struck job could escape backoff/sealing while an unrelated one absorbed it. Replaced with an AttributedPlatformFailure carrying the row out of the throw itself, correct by construction regardless of overlap. - The quiescence-clear check for the strike counter read stale snapshots of #alarmsInFlight/#outstandingAlarmWork depending on which of two transitions (an alarm ending, a handoff settling) ran first, so a strike recorded in the gap between checks could never clear. Fixed by having each transition re-check both counters fresh, with no yield point between the mutation and the check on either side. Fixing the second issue introduced a regression caught by the full suite: the quiescence check started running unconditionally in runAlarm's finally, including right after a strike was just recorded, immediately clearing what had just been set. Restored the placement-based guard (only a clean pass triggers the check) that the prior design relied on. New regression test isolates the attribution fix from Tasks' own active-attempt tracking, which independently and correctly re-tracks a Task run an overlapping alarm re-dispatches and was masking the bug in two earlier attempts at this test. Claude-Session: https://claude.ai/code/session_011DVPXpp9SyXM1ChxvkQCTB * fix(lifecycle): preserve a strike through a later clean sibling settling Third correctness fix from Devin Review against job-driver.ts, following the same shape as the two already fixed: a strike could be fully recorded (durable counter written, isolate reset scheduled via setTimeout(0)), and then a slower, unrelated clean handoff still outstanding at that moment could settle in the gap before the reset actually lands, find the alarm domain quiescent, and clear what was just recorded. Fixed with #strikeRecordedThisIsolate: once any flow records a strike in this isolate, no later settlement may clear the durable counter for the rest of this isolate's life. The isolate always resets shortly after a recorded strike, so the next genuinely clean cycle runs in a fresh isolate with a fresh JobDriver and the flag back at its default — there is no cross-isolate state to reset. New regression test (oomBeforeCleanSibling) orders a run's own strike distinctly before a separately tracked clean sibling; verified against the pre-fix code that it exercises the intended scenario and passes correctly with the fix. I could not force this specific narrow race window (bounded by workerd's own near-instant setTimeout(0) teardown scheduling) to reproducibly fail pre-fix within this test harness after several timing attempts, so this is a positive regression guard rather than a failing-then-passing proof like the other two fixes in this PR. The fix itself is verified by direct code tracing, matching Devin's own independent diagnosis of the same race. Claude-Session: https://claude.ai/code/session_011DVPXpp9SyXM1ChxvkQCTB * refactor(tasks): add Tasks#register for framework-reserved definitions Replaces the imperative _registerInternalTaskDefinition/_internalTaskDefinitions mechanism on Agent (a Map populated via a protected method, five near-identical one-line wrapper methods across AIChatAgent and Think just to call it) with a plain `register(name, definition)` method directly on Tasks. Framework code calls this.tasks.register(...) once per reserved name from its own constructor; it throws if the name lacks the `__cf` prefix or is already registered, so registration composes correctly no matter how many subclass layers exist or what any of them do with their own taskDefinitions field — it no longer depends on Agent's own field-override machinery at all. Agent's constructor keeps setTaskDefinitionResolver, but its only remaining job is bridging the end user's own overridable taskDefinitions field, which genuinely cannot be read at Tasks-construction time (a further-downstream subclass's field initializer runs only after every constructor up the chain returns) — register() has no such problem since it's called eagerly from each host's own constructor, after this.tasks already exists. design/rfc-fibers.md updated to describe the new mechanism (an adversarial review caught the stale reference to the old resolver-based path). New tests cover register()'s own validation branches directly (missing __cf prefix, empty name, duplicate registration, collision with a constructor-declared definition, and that a registered definition is reachable only through the internal aperture, never the public run()) — the only real definitions that previously exercised it (chat turn, chat recovery, messenger reply) never hit any of its failure paths. No public API surface changes: docs/agents/tasks.md and the existing changeset need no edits, matching every other __DO_NOT_USE_WILL_BREAK__- style internal aperture on Tasks. Claude-Session: https://claude.ai/code/session_011DVPXpp9SyXM1ChxvkQCTB * chore(lifecycle): remove dead ALTER TABLE migration for cf_agents_jobs cf_agents_jobs (and its recovery_loop column) has never shipped in a release — the whole table was introduced in this same unreleased branch (#2175, then extended with recovery_loop in #2190/#2194). No deployed Durable Object can have this table without the column already present: CREATE TABLE IF NOT EXISTS already includes it in the same statement. The pragma_table_info probe + ALTER TABLE fallback was defending against a schema history that cannot exist yet. Removes the dead branch, its regression test (which only proved the migration path itself, not anything a real caller depends on), and the two doc/changeset sentences describing it. Claude-Session: https://claude.ai/code/session_011DVPXpp9SyXM1ChxvkQCTB * feat(tasks): support routed wakes so facet chat recovery drops the Scheduler bridge Tasks now mirrors a routed sub-agent's run deadline to the root's job queue while the run row and step journal stay put: only the root owns the physical alarm, so only the wake needs to cross that boundary. onRoute handles syncWake/dispatch/memoryLimit; a routed strike forwards to the owning facet's own onAlarmMemoryLimit hook, the same bridge Scheduler already used, since the facet's own Lifecycle never observes the root's alarm directly. AIChatAgent and Think's _enqueueChatRecovery now always uses Tasks, removing the parentPath-gated Scheduler fallback and the now-dead chatRecoverySchedulePolicy/chatRecoveryRedeferPolicy helpers. Claude-Session: https://claude.ai/code/session_011DVPXpp9SyXM1ChxvkQCTB * fix(ai-chat): import AgentContext for AIChatAgentToolChild's constructor Fixes a typecheck failure in packages/ai-chat/src/tests/tsconfig.json CI caught: the test-only OOM Task definition added in the routed-wake work needed the child's own constructor to register it, which used AgentContext without importing it. Claude-Session: https://claude.ai/code/session_011DVPXpp9SyXM1ChxvkQCTB * fix(tasks): fix three Devin-flagged routed-dispatch bugs - onRoute's "dispatch" case awaited #executeRun directly, bypassing onJob's active-run claim refresh and its five-second dispatch budget. A routed run already active on the facet lost its wake when the root's mirror job settled with no reschedule, and a long-running routed handler blocked the root's whole alarm cycle. Extracted the shared bounded-dispatch logic into #dispatchRun, used by both onJob locally and onRoute's "dispatch" case, whose wake outcome now flows back as the RPC's own return value instead of being discarded. - onMemoryLimit only forwarded a routed run's SEALED strike to its owning facet. A non-sealed strike backed off the root's mirror job but left the facet's own claim (generation, next_at) untouched; any facet startup before the backoff elapsed read that claim as an interrupted attempt and reconciled it due again now, resurrecting the run through the breaker. Forward every strike, sealed or not. Verified the backoff fix is real: reverting to sealed-only forwarding reproduces the failure (generation stays set) in the new "backs off a routed facet's own claim" test. The active-run and dispatch-budget fixes reuse #dispatchRun verbatim from the local path, already covered by the existing local dispatch tests; a dedicated routed-side regression test for the concurrent-active and >5s-budget cases was not added given the cost of reliably constructing those races across a real DO-RPC boundary in this harness. Claude-Session: https://claude.ai/code/session_011DVPXpp9SyXM1ChxvkQCTB * fix(tasks): track routed dispatch on the root alarm, not the facet; cancel stale wakes on facet deletion Two more Devin-flagged bugs in the routed-Tasks work: - Routed tasks bypassed the memory breaker. A routed dispatch that exceeded the five-second budget called trackAlarmWork from inside onRoute on the facet, but that call only attaches to a live alarm invocation's AsyncLocalStorage scope — onRoute runs in response to an incoming RPC, not the facet's own alarm, so the call silently did nothing. Every redispatch of a routed run goes through this same path (root owns the physical alarm), so a routed run that regularly overran the budget could OOM repeatedly without the breaker ever seeing it. Moved the budget race to the root side instead: root now races its own await of the routed RPC call, and on budget keeps the still-pending call itself tracked via trackAlarmWork, which works because onJob runs inside root's own live alarm scope. The facet's onRoute dispatch handler no longer needs its own budget or tracking at all — it just fully awaits, since the call keeps running on the facet regardless of whether root is still waiting on it. Verified this is actually safe with a deployed repro (not simulated via ctx.abort(), which is deliberately deferred and wouldn't reject an in-flight caller): a callee DO whose isolate is killed by the platform's real memory-limit enforcement while mid-flight on an RPC call reliably rejects the caller's pending promise with the platform's own "exceeded its memory limit" text — exactly what the breaker already matches on. Confirmed across 3 trials against a real deployment, then deleted the repro worker. Added a regression test whose failure lands after root's own budget elapses (a real >5s delay, not a simulated abort) and confirmed it fails when reverted to the old (silently-inert) tracking call. - Deleting a facet left its routed Task wake mirrored on the root forever, since only Scheduler's routed rows were cleaned up on facet-subtree deletion. Added the same __DO_NOT_USE_WILL_BREAK__cleanupRoutePrefix aperture to Tasks, mirroring Scheduler's, and wired it into the same cleanupPrefix call site. Claude-Session: https://claude.ai/code/session_011DVPXpp9SyXM1ChxvkQCTB * fix(tasks,chat): mirror a routed run's claim deadline before dispatch; retry a failed post-handoff redefer Two more Devin-flagged bugs from the latest routed-Tasks review pass: - Once root started racing its own await of a routed dispatch (the RPC-tracking fix earlier today), a budget win now returns undefined as root's own job outcome. JobDriver deletes the mirror job on an undefined outcome — but only when the row is still marked "running" from that same dispatch. #executeRun now pushes the claim deadline via #syncWake immediately after claiming, before the handler runs: that push clears the row's in-flight marker (job-queue's own "newer durable intent wins" guard), so root's later stale undefined outcome no-ops against it instead of deleting a still-live claim. A hung or interrupted routed attempt keeps its alarm either way now, independent of whether this specific attempt happens to settle cleanly, fail, or never resolve at all. - dispatchChatRecoveryToHandoff swallowed a failed post-handoff redefer entirely. The Task that dispatched it has already settled by the time redefer runs, so nothing else owned that incident — a transient failure to enqueue the replacement abandoned recovery silently. Wrapped it in a bounded retry (tryN, 3 attempts) and surface the final failure through the same onDetachedError channel an unowned detached failure already uses, rather than a bare swallowed catch. Both verified non-vacuous: reverting each fix reproduces the exact failure Devin described (the mirror job actually gets deleted; the redefer failure is actually silent) in new regression tests, then passes again restored. The claim-mirroring test uses a real 6.5s delayed failure, not a simulated one. Claude-Session: https://claude.ai/code/session_011DVPXpp9SyXM1ChxvkQCTB * fix(chat): dedupe a retried post-handoff redefer instead of creating duplicate runs The redefer retry added last commit (tryN, 3 attempts) has its own bug: Tasks acceptance can throw after already durably inserting the run row — most likely on the wake-mirror push, not the insert itself — so a rejected enqueue does not prove nothing was created. Retrying the same unkeyed "redefer" enqueue (intentionally unkeyed for a genuinely new attempt, per chatRecoveryTaskRunOptions) could create up to three replacement runs for one incident instead of joining the first. dispatchChatRecoveryToHandoff now generates one dedupe key per failure and passes it to every retried redefer call; chatRecoveryTaskRunOptions keys the run by it (runId) when supplied, so a retry after a partial success joins that same row instead of duplicating it. Verified non-vacuous: reverting chatRecoveryTaskRunOptions's use of the key reproduces exactly the gap in the new "keys the run by dedupeKey" test. Claude-Session: https://claude.ai/code/session_011DVPXpp9SyXM1ChxvkQCTB * fix(tasks): repair a missing wake mirror when acceptance joins an existing run The dedupe fix from the last commit prevents a retried enqueue from creating a duplicate run, but joining the existing row alone doesn't help if that row's wake was never durably pushed in the first place. #accept can throw after already inserting the run — most likely on the wake-mirror #syncWake call itself, not the insert — so a retry that finds the existing row and returns accepted:false was reporting success against a run nothing would ever wake again. The join branch now calls #syncWake before returning, for every caller (runId or idempotencyKey match), not just chat recovery's retried redefer — any Tasks caller retrying acceptance after a partial failure benefits the same way. Cheap in the common case (#syncWake already no-ops when the mirror already matches). Verified non-vacuous: reverting the added #syncWake call and rerunning the new "repairs a missing wake mirror" capability test reproduces the gap exactly — the mirror job stays missing after the retry joins the run. Claude-Session: https://claude.ai/code/session_011DVPXpp9SyXM1ChxvkQCTB * refactor(scheduler): remove the retired routed chat-recovery memory-limit bridge Scheduler's routed memory-limit compatibility bridge existed to deliver a sealed strike to a dynamic agent whose chat-recovery schedule row was purged, back when routed chat recovery ran on Scheduler. Chat recovery now always uses Tasks (this PR), which has its own routed memory-limit bridge (setTaskRoutedMemoryLimitHandler), so this path carries no live traffic. Removed setSchedulerRoutedMemoryLimitHandler, the WeakMap backing it, the "memoryLimit" SchedulerRouteMessage variant, and Scheduler's onMemoryLimit/onRoute handling of it, plus Agent's wiring. Deliberately left in place: LEGACY_RECOVERY_LOOP_CALLBACKS' migration of pre-existing legacy schedule rows (still needed regardless of routing, so the local alarm breaker still counts them), and MemoryLimitContext.purgedRecoveryLoopJobs itself (now unread by any capability, but a generic hook a future routed capability with purge-as-a-pack semantics could still use — Tasks backs off one run at a time instead). Full suites still pass (2012 agents, 661 ai-chat, 888 think). Claude-Session: https://claude.ai/code/session_011DVPXpp9SyXM1ChxvkQCTBgithub.com-cloudflare-agents · 6da4c44b · 2026-09-02