From 311306b9a1ad99e60782bcb44a09f617c4bb7da3 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Mon, 14 Sep 2026 18:18:45 +0100 Subject: [PATCH 1/2] feat(cloudflare): run submissions on Agents SDK Tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hand-rolled Cloudflare execution layer — the zero-delay drain plus 30s heartbeat on schedule(), the in-isolate supervisor chain, detached runFiber attempt fibers with onFiberRecovered, and live deadline enforcement with a force-settle grace — with two Agents SDK Task definitions declared on the generated class: - flue:drive@v1: one bounded reconcile pass (materialize, finalize, recover interrupted rows, claim runnable heads) that starts an attempt run per claim and completes; every former arm-the-drain boundary now ensures this run and joins it when live. - flue:attempt@v1: one submission processed to settlement in the handler body, with the flue abort controller linked to step.signal and the submission's durability timeout as the run deadline. The SDK owns the claim backstop, the replay after an interruption (the body reconciles from evidence first), and settling over a signal-deaf attempt. The submission ledger, canonical stream, admission paths, and recovery classification are unchanged. Requires the agents release that carries cloudflare/agents#2274 (step.signal, run deadline, onError context). --- .../docs/ecosystem/deploy/cloudflare.md | 2 +- .../content/docs/guide/cloudflare-target.md | 4 +- .../docs/src/content/docs/guide/durability.md | 8 +- .../src/cloudflare/agent-coordinator.ts | 762 +++++++++--------- .../src/cloudflare/flue-agent-class.ts | 77 +- packages/vite/package.json | 2 +- pnpm-lock.yaml | 387 ++++----- 7 files changed, 586 insertions(+), 656 deletions(-) diff --git a/apps/docs/src/content/docs/ecosystem/deploy/cloudflare.md b/apps/docs/src/content/docs/ecosystem/deploy/cloudflare.md index 97650ed49..1c566984d 100644 --- a/apps/docs/src/content/docs/ecosystem/deploy/cloudflare.md +++ b/apps/docs/src/content/docs/ecosystem/deploy/cloudflare.md @@ -215,7 +215,7 @@ export const cloudflare = extend({ }); ``` -This is an advanced Cloudflare-only extension point. Flue applies `base` first, then defines its own Durable Object subclass with the generated binding and class identity. For the `Heartbeat` agent, authored Worker code can access the namespace as `env.FLUE_HEARTBEAT_AGENT`, and Wrangler binds that name to `FlueHeartbeatAgent`. Use `base` for native SDK lifecycle hooks and additional named methods. Do not override `fetch()`, `onRequest()`, `onFiberRecovered()`, or `alarm()`: Flue and the Agents SDK use those methods for routing, interruption recovery, and alarm multiplexing. +This is an advanced Cloudflare-only extension point. Flue applies `base` first, then defines its own Durable Object subclass with the generated binding and class identity. For the `Heartbeat` agent, authored Worker code can access the namespace as `env.FLUE_HEARTBEAT_AGENT`, and Wrangler binds that name to `FlueHeartbeatAgent`. Use `base` for native SDK lifecycle hooks and additional named methods. Do not override `fetch()`, `onRequest()`, `onError()`, `alarm()`, or `taskDefinitions`: Flue and the Agents SDK use them for routing, Task failure recovery, alarm multiplexing, and the durable execution definitions. A `base` that declares its own `taskDefinitions` keeps them; Flue merges its two definitions over them. Use `wrap` when an integration needs to wrap the final Flue-generated Durable Object class: diff --git a/apps/docs/src/content/docs/guide/cloudflare-target.md b/apps/docs/src/content/docs/guide/cloudflare-target.md index a3116b31a..7949fd526 100644 --- a/apps/docs/src/content/docs/guide/cloudflare-target.md +++ b/apps/docs/src/content/docs/guide/cloudflare-target.md @@ -107,10 +107,10 @@ dispatch(...) input ──────────────────── The submitting connection observes the work but does not own it. If a client disconnects after admission, backend work can continue. Agent events are durably stored and can be replayed from any offset via the Durable Streams protocol. -Accepted work runs inside the Durable Object itself, as one platform-visible unit per response: after admission answers, Flue schedules a zero-delay wake, and the object's alarm invocation claims the queued submission and awaits the full response — every model turn and tool call — before completing. Admission always returns first; the response begins on the next alarm tick, typically within tens of milliseconds. Three consequences: +Accepted work runs inside the Durable Object itself, on the Agents SDK's durable [Tasks](https://github.com/cloudflare/agents/blob/main/docs/agents/tasks.md) capability. Admission ensures a `flue:drive@v1` run, whose one bounded pass claims the queued submission and starts a `flue:attempt@v1` run for it; that run's handler awaits the full response — every model turn and tool call — and settles the submission. Admission always returns first; the response begins in the same invocation when the object is warm, or on the next alarm tick otherwise. The SDK owns the durable wake while a run is held (a claim backstop that survives eviction), replays the attempt run on a fresh isolate after an interruption, and enforces the submission's [durability timeout](/docs/guide/durability/#retry-budget-and-timeout) as the run's deadline. Three consequences: - **Platform observability sees agent work.** Workers Logs, Workers Traces, and invocation-wrapping integrations (the Sentry `wrap` pattern in [Extending Agents](#extending-agents-on-cloudflare)) attribute the whole response to the one invocation that ran it. [`createCloudflareTracing()`](#createcloudflaretracing) (installed by default) adds agent-level spans to those traces. See [Observability](/docs/guide/observability/#cloudflare) for the two views and [Deploy on Cloudflare](/docs/ecosystem/deploy/cloudflare/#observability) for enabling them. -- **Scheduled callbacks wait for a running response.** A Durable Object runs one alarm at a time, so `schedule()`/`scheduleEvery()` callbacks that come due mid-response fire after it settles — delivery is durable; timeliness is not guaranteed while the agent is busy. `queue()` is not alarm-driven and is unaffected. Steering is also unaffected: a message that arrives mid-response still joins it at the next turn boundary. +- **Scheduled callbacks share the alarm with running responses.** The SDK drives due work from one alarm loop and hands a long attempt off after a short dispatch budget, so `schedule()`/`scheduleEvery()` callbacks that come due mid-response still fire — delivery is durable, and timeliness is best-effort while the agent is busy. `queue()` is not alarm-driven and is unaffected. Steering is also unaffected: a message that arrives mid-response still joins it at the next turn boundary. - **CPU limits are per invocation.** Agent responses are I/O-bound and sit far below the default 30-second active-CPU limit; raise [`limits.cpu_ms`](https://developers.cloudflare.com/durable-objects/platform/limits/) in `wrangler.jsonc` if an agent computes heavily. When a Durable Object resumes after interruption, Flue decides what to do next from the stored input and canonical conversation progress. It requeues only when it can prove the input was not applied, recognizes already-completed output, and records an interruption instead of blindly repeating uncertain model or tool work. diff --git a/apps/docs/src/content/docs/guide/durability.md b/apps/docs/src/content/docs/guide/durability.md index 632d779eb..7769b4be2 100644 --- a/apps/docs/src/content/docs/guide/durability.md +++ b/apps/docs/src/content/docs/guide/durability.md @@ -44,7 +44,7 @@ A recovered conversation always comes to rest in a state where the next message Each interruption consumes one attempt. When a submission exhausts its attempts, or exceeds its wall-clock timeout, retrying stops: the conversation is settled to a rest state, a `submission_interrupted` advisory lands in the timeline, and the submission settles `failed` — waiters reject with the structured error, including which tool calls were left with unknown outcomes. -The timeout is enforced preemptively, not just between attempts. The coordinator supervises running attempts on its wake cadence: at the deadline it fires the attempt's abort signal, so work suspended on a signal-aware await (a provider call, a sandbox command, any tool — a `run` that ignores its signal is abandoned rather than awaited) unwinds and settles through the normal paths — and an attempt hung below the abandonable layer is settled `failed` over the hung fiber after a short grace, its late writes fenced off. A hang delays settlement by at most the deadline plus that grace; it can never strand the submission. Most stalls never reach the deadline at all: a model stream that goes silent past its [idle timeout](/docs/reference/provider-api/#cloudflarebindingprovider) fails as a transient provider error and the turn retries under the [error budget](#recovery-after-an-interruption). +The timeout is enforced preemptively, not just between attempts. At the deadline the running attempt's abort signal fires, so work suspended on a signal-aware await (a provider call, a sandbox command, any tool — a `run` that ignores its signal is abandoned rather than awaited) unwinds and settles through the normal paths — and an attempt hung below the abandonable layer is settled `failed` over it, its late writes fenced off. A hang delays settlement by at most the deadline plus one wake; it can never strand the submission. Most stalls never reach the deadline at all: a model stream that goes silent past its [idle timeout](/docs/reference/provider-api/#cloudflarebindingprovider) fails as a transient provider error and the turn retries under the [error budget](#recovery-after-an-interruption). The defaults are 10 attempts and one hour per submission. Override them per agent with the `durability` static: @@ -134,10 +134,10 @@ See the [Node.js target guide](/docs/guide/node-target/#state-and-durability) fo ### Cloudflare recovery -On Cloudflare, every agent conversation is a Durable Object with its own SQLite storage, so ownership is structural — the platform guarantees one live instance per conversation, and there is no lease protocol to operate. Recovery is wake-driven: +On Cloudflare, every agent conversation is a Durable Object with its own SQLite storage, so ownership is structural — the platform guarantees one live instance per conversation, and there is no lease protocol to operate. Execution runs on the Agents SDK's durable Tasks capability, and recovery is wake-driven: -- **Wake on start.** Whenever the Durable Object starts — after an eviction, a code deploy, or a platform reset — Flue immediately flags any attempt that was running when the previous instance died and reconciles it before serving new work. The platform's fiber-recovery callback triggers the same reconciliation path. -- **A durable wake schedule.** While unsettled work exists, the object keeps a short self-renewing wake scheduled, so an interrupted submission recovers promptly even if no external request ever arrives to wake the object. Each wake runs a bounded supervision pass — reconcile, enforce deadlines, start work — and re-arms its successor before doing anything that can fail, so a hung attempt or a failed pass can delay supervision by at most one wake, never break it. Attempt execution runs detached from the wake that started it. +- **Wake on start.** Whenever the Durable Object starts — after an eviction, a code deploy, or a platform reset — Flue ensures a drive run, which flags any attempt that was running when the previous instance died and reconciles it before new work starts. The SDK replays the interrupted attempt run itself on the same start, and that replay reconciles from the same evidence. +- **A durable claim.** While an attempt run is held, the SDK keeps a claim deadline in storage and wakes the object at it, so an interrupted submission recovers promptly even if no external request ever arrives. The submission's wall-clock timeout is the run's deadline: the SDK wakes at it, settles the run over an attempt that ignores its abort signal, and fences that attempt's later writes; the next drive then settles the submission through the timeout classification above. A signal-deaf attempt therefore settles at the timeout rather than after a fixed grace. Abort intents, attempt bookkeeping, and settlement records all live in the object's own storage, so an abort requested while the object was evicted is honored on the next wake. See the [Cloudflare target guide](/docs/guide/cloudflare-target/#durable-agent-execution) for the target's execution model. diff --git a/packages/runtime/src/cloudflare/agent-coordinator.ts b/packages/runtime/src/cloudflare/agent-coordinator.ts index 82acba452..b41e5a335 100644 --- a/packages/runtime/src/cloudflare/agent-coordinator.ts +++ b/packages/runtime/src/cloudflare/agent-coordinator.ts @@ -58,20 +58,27 @@ import { export const CLOUDFLARE_AGENT_INTERNAL_DISPATCH_PATH = '/__flue/internal/dispatch'; export const CLOUDFLARE_AGENT_INTERNAL_INSTANCE_INFO_PATH = '/__flue/internal/instance-info'; -const FLUE_AGENT_SUBMISSION_WAKE_CALLBACK = '__flueWakeAgentSubmissions'; -const FLUE_AGENT_SUBMISSION_WAKE_SECONDS = 30; -const FLUE_AGENT_SUBMISSION_ATTEMPT_FIBER = 'flue:submission-attempt'; /** - * How long past a deadline (durability timeout, or a durable abort intent) a - * live attempt fiber gets to unwind through its own settle path after its - * controller is aborted, before the supervisor force-settles the submission - * over it. Signal-aware awaits (provider fetches, exec) unwind well inside - * this; only a signal-deaf hang (a sandbox RPC pending against a - * healthy-reporting container, a wedged stream reader) reaches the force - * path. Two heartbeat periods, so the abort is guaranteed at least one full - * pass to land before force-settlement. + * The two Agents SDK Task definitions the generated Durable Object class + * declares on `taskDefinitions` (see `flue-agent-class.ts`). The SDK persists + * only the name per run and resolves it against the class on every wake, so + * an in-flight run always finds the same handler. + * + * `flue:drive@v1` is the supervisor pass: reconcile durable submission state + * and start an attempt run for every claimable head. One run per object, + * joined (not duplicated) by everyone who needs the queue to progress. + * + * `flue:attempt@v1` is one submission's processing, awaited in the handler + * body. One run per submission; the SDK replays it after an interruption and + * enforces its deadline over a hung attempt. */ -const FLUE_AGENT_SUBMISSION_SETTLE_GRACE_MS = 2 * FLUE_AGENT_SUBMISSION_WAKE_SECONDS * 1000; +export const FLUE_DRIVE_TASK = 'flue:drive@v1'; +export const FLUE_ATTEMPT_TASK = 'flue:attempt@v1'; +const FLUE_DRIVE_RUN_ID = 'flue:drive'; + +function attemptRunId(submissionId: string): string { + return `flue:submission:${submissionId}`; +} import type { SqlStorage } from '../sql-storage.ts'; @@ -80,6 +87,25 @@ interface CloudflareAgentStorage { transactionSync?(closure: () => T): T; } +/** The slice of an Agents SDK Task run snapshot the coordinator reads. */ +interface CloudflareTaskRunSnapshot { + readonly state: 'pending' | 'running' | 'waiting' | 'completed' | 'failed' | 'cancelled'; +} + +/** + * The slice of the Agents SDK `Tasks` capability the coordinator drives. + * Structural, like the rest of this file: `@flue/runtime` never imports + * `agents`; the generated entry supplies the class. + */ +interface CloudflareAgentTasks { + run( + definition: string, + input: unknown, + options: { runId: string; retain: false; deadline?: number }, + ): Promise<{ accepted: boolean }>; + get(runId: string): Promise; +} + interface CloudflareAgentInstance { readonly name: string; readonly env: Record; @@ -87,39 +113,24 @@ interface CloudflareAgentInstance { readonly id: { toString(): string }; readonly storage: CloudflareAgentStorage; /** - * DurableObjectState.waitUntil. Optional because test fakes and older - * SDK surfaces may omit it; a detached attempt fiber survives without - * it (the durable keepAlive alarm chain re-enters through fiber - * recovery), waitUntil just tells the platform the work is deliberate. + * DurableObjectState.waitUntil. Optional because test fakes may omit + * it; it only tells the platform that fire-and-forget event delivery + * left behind by an invocation is deliberate. */ waitUntil?(promise: Promise): void; }; - schedule( - delaySeconds: number, - callback: string, - payload: undefined, - options: { idempotent: boolean }, - ): Promise; - runFiber( - name: string, - callback: (ctx: { stash(snapshot: unknown): void }) => Promise, - ): Promise; + readonly tasks: CloudflareAgentTasks; } -interface CloudflareAgentRecoveredFiberContext { - readonly name?: string; - readonly snapshot?: Record; +/** The slice of an Agents SDK `TaskStep` the Task handler bodies read. */ +export interface CloudflareTaskStep { + /** Aborts for the whole attempt: on `cancel()` and at the run deadline. */ + readonly signal: AbortSignal; } -/** - * Handle for a started attempt. `running` is the guarded fiber promise - * (never rejects; resolves after settlement AND cleanup). Wrapped in an - * object so async plumbing can hand it around without the runtime flattening - * a returned promise into an inline await of the whole attempt. - */ -interface StartedSubmissionAttempt { +/** Input of one `flue:attempt@v1` run. */ +export interface CloudflareAttemptTaskInput { readonly submissionId: string; - readonly running: Promise; } interface CloudflareAgentPreparedCoordinator { @@ -160,24 +171,33 @@ export interface CloudflareAgentRuntime { inherited: () => Promise | unknown, ): Promise; /** - * The single place submission attempts start. Dispatched by the - * `__flueWakeAgentSubmissions` schedule target from inside the Durable - * Object's alarm invocation as one bounded, storage-only supervisor - * pass: it reconciles durable state, enforces deadlines, and starts - * attempt fibers detached — the pass returns without awaiting agent - * execution, so a hung attempt can never block supervision, and the - * pass arms its successor heartbeat before doing any failable work. - * Attempt fibers ride the SDK's runFiber keepAlive/recovery machinery - * across invocations and isolates. Every other boundary only records - * durable intent and arms this drain. + * Handler of the `flue:drive@v1` Task: one bounded, storage-only + * supervisor pass that reconciles durable submission state and starts an + * attempt run for every claimable head, then completes. The single place + * submission attempts start; every other boundary only records durable + * intent and ensures a drive run exists. */ - drainSubmissions(instance: CloudflareAgentInstance): Promise; - onRequest(instance: CloudflareAgentInstance, request: Request): Promise; - onFiberRecovered( + drive(instance: CloudflareAgentInstance, step: CloudflareTaskStep): Promise; + /** + * Handler of the `flue:attempt@v1` Task: process one submission to + * settlement in the handler body. The SDK replays it on a fresh isolate + * after an interruption (the body reconciles from durable evidence + * first) and enforces the submission's durability timeout as the run's + * deadline, settling over an attempt that ignores its signal. + */ + attempt( instance: CloudflareAgentInstance, - ctx: CloudflareAgentRecoveredFiberContext, - inherited: () => Promise | unknown, - ): Promise; + input: CloudflareAttemptTaskInput, + step: CloudflareTaskStep, + ): Promise; + /** + * The SDK recorded a terminal Task failure without running (or over) a + * handler — a deadline, an exhausted budget, a missing definition. The + * submission row it owned is still unsettled: ensure a drive run so the + * reconcile pass settles it from evidence. + */ + onTaskError(instance: CloudflareAgentInstance, error: unknown): Promise; + onRequest(instance: CloudflareAgentInstance, request: Request): Promise; /** * Run the Agents SDK alarm handler inside the instance context. Alarms * dispatch `schedule`/`scheduleEvery`/`queue` callbacks to methods on the @@ -220,15 +240,18 @@ export function createCloudflareAgentRuntime( onStart(instance, inherited) { return getCoordinator(instance).onStart(inherited); }, - drainSubmissions(instance) { - return getCoordinator(instance).drainSubmissions(); + drive(instance, step) { + return getCoordinator(instance).drive(step); + }, + attempt(instance, input, step) { + return getCoordinator(instance).attempt(input, step); + }, + onTaskError(instance, error) { + return getCoordinator(instance).onTaskError(error); }, onRequest(instance, request) { return getCoordinator(instance).onRequest(request); }, - onFiberRecovered(instance, ctx, inherited) { - return getCoordinator(instance).onFiberRecovered(ctx, inherited); - }, onAlarm(instance, inherited) { return getCoordinator(instance).onAlarm(inherited); }, @@ -267,167 +290,182 @@ class CloudflareAgentCoordinator { */ private readonly mcpConnections = createMcpConnectionCache(); /** - * Abort controllers for in-flight attempt fibers in this isolate, keyed by - * submissionId, so an incoming cancel request can abort the running attempt. - * The DO is single-threaded but interleaves at `await` points, so a cancel - * request can set the controller while the fiber is suspended on provider - * I/O. If the isolate is evicted the controller is gone and the abort - * falls back to the durable `abortRequestedAt` + reconcile path. + * Abort controllers for attempts live in this isolate, keyed by + * submissionId, so an incoming cancel request can abort the running + * attempt. The DO is single-threaded but interleaves at `await` points, + * so a cancel request can fire the controller while the attempt is + * suspended on provider I/O. If the isolate is evicted the controller is + * gone and the abort falls back to the durable `abortRequestedAt` + + * reconcile path. */ private activeControllers = new Map(); /** - * When each live attempt's controller was first fired by deadline - * enforcement, keyed by the controller so a replacement attempt starts - * its own clock. In-memory is exactly coextensive with the enforcement - * itself: both only apply to fibers live in this isolate. + * Attempt ids this isolate claimed and handed to an attempt run that has + * not yet entered its handler body. The run's first execution takes the + * id out and processes the claim as-is; a body that finds no entry is a + * replay on a fresh isolate (or after its previous execution ended) and + * must reconcile the row from durable evidence before doing anything. */ - private attemptDeadlineSignaledAt = new WeakMap(); + private readonly startedAttempts = new Set(); - // Instance context is established at exactly two boundaries: the public - // coordinator entry points below (onStart/drainSubmissions/onRequest/ - // onFiberRecovered/onAlarm) and the durable submission fiber in - // startSubmissionAttempt. These are the only ways execution enters the - // Durable Object, and a recovered fiber resumes with no ambient context, so - // each must (re)establish it. onAlarm wraps the Agents SDK alarm handler, - // covering every scheduled callback it dispatches — including - // extension-authored schedule/scheduleEvery/queue targets (#437); Flue's - // own wake target still self-wraps via drainSubmissions, which simply - // nests. Everything reachable from these boundaries — dispatch admission, - // reconciliation, materialization, submission processing — assumes the - // context is already present and never re-wraps. + // Instance context is established at the boundaries where execution + // enters the Durable Object: onStart, onRequest, onAlarm, and the two + // Task handler bodies (drive, attempt). A Task handler may run on a fresh + // isolate with no ambient context, so each body (re)establishes it. + // onAlarm wraps the Agents SDK alarm handler, covering every scheduled + // callback it dispatches — including extension-authored + // schedule/scheduleEvery/queue targets (#437). Everything reachable from + // these boundaries — dispatch admission, reconciliation, materialization, + // submission processing — assumes the context is already present and + // never re-wraps. // - // Execution ownership: attempts start ONLY inside drainSubmissions' - // supervisor pass, which runs as an alarm-dispatched schedule callback. - // The pass is bounded and storage-only — it never awaits agent execution. - // Attempt fibers run detached: the SDK's runFiber keepAlive holds a ≤30s - // durable alarm chain for a fiber's whole lifetime, and fiber recovery - // re-enters through onFiberRecovered if the isolate dies with the fiber. - // Supervision therefore never inherits an attempt's liveness: a hung - // await inside a fiber cannot block deadline enforcement, and the - // heartbeat is armed BEFORE the pass does any failable work, so no - // single pass failure (throw, platform cancellation, code-update reset) - // can break the wake chain — a lost wake costs one heartbeat of latency, - // never settlement. All other boundaries (admission, abort, onStart, - // onFiberRecovered) record durable intent and arm the drain. The SDK - // deletes a one-shot schedule row only AFTER its callback returns, so an - // armed row doubles as durable recovery on isolate death. + // Execution ownership: attempts start ONLY from the drive run's + // reconcile pass, each as its own `flue:attempt@v1` Task run whose + // handler body awaits the submission to settlement. The Agents SDK owns + // the durable wake (a claim backstop while the run is held, a replay on + // a fresh isolate after an interruption), the deadline (the submission's + // durability timeout, settled over a hung attempt with its later writes + // fenced out), and the attempt-wide abort signal. All other boundaries + // (admission, abort, onStart, Task failures) record durable intent and + // ensure the drive run exists; joining an existing one is free. onStart(inherited: () => Promise | unknown): Promise { return this.runWithInstanceContext(async () => { // A fresh isolate has no live attempt by definition, so unsettled - // work needs nothing beyond a drain: its reconcile pass classifies - // interrupted attempts directly. Arm before the (possibly - // extension-authored) inherited onStart — the durable driver must be - // in place even if extension startup throws. - await this.armDrainIfUnsettled(); + // work needs nothing beyond a drive: its reconcile pass classifies + // interrupted attempts directly. Ensure it before the (possibly + // extension-authored) inherited onStart — the durable driver must + // be in place even if extension startup throws. + await this.ensureDriveIfUnsettled(); await inherited(); }); } /** - * In-isolate serialization of supervisor passes. Alarm-dispatched wakes - * are serialized by the platform (one `alarm()` at a time), but a direct - * call while a pass is live must not interleave with it. A promise chain - * instead of a boolean guard: a wake that arrives mid-pass queues its own - * full pass rather than being silently consumed — no code path can eat a - * wake without doing (or scheduling) the work it promised. Passes are - * bounded and storage-only, so the chain drains in bounded time. + * The `flue:drive@v1` handler: one bounded supervisor pass. Reconcile + * durable state (settlement finalization, interrupted-attempt recovery, + * runnable claims) and start an attempt run per claim WITHOUT awaiting + * it — attempt runs settle their submissions themselves and ensure the + * next drive when queued work remains. The pass never waits on agent + * execution, so it completes in bounded time whatever any attempt is + * doing. */ - private supervisorChain: Promise = Promise.resolve(); - - drainSubmissions(): Promise { - const pass = this.supervisorChain.then(() => - this.runWithInstanceContext(() => this.supervisorPass()), - ); - // The chain absorbs rejections so one failed pass can't wedge every - // later one; the caller's `pass` still rejects, preserving the SDK's - // schedule-callback deferral semantics (code-update resets and - // transient platform errors rethrow so the preserved row re-runs). - this.supervisorChain = pass.then( - () => undefined, - () => undefined, - ); - return pass; + drive(step: CloudflareTaskStep): Promise { + return this.runWithInstanceContext(async () => { + if (step.signal.aborted) return; + if (!(await this.submissions.hasUnsettledSubmissions())) return; + // The reconcile pass is storage-only and runs under the + // `flue.coordinator` interception so tracing backends can group + // its platform-instrumented storage spans. Attempt runs start + // AFTER the interception settles, deliberately: a run warm-started + // inside the span's activation would re-parent its invoke_agent + // span under the coordinator span. A claim whose start is + // preempted here (crash, code-update reset) is a running row with + // no attempt run — the next drive's running-recovery loop + // reconciles it, and a durable abort in the claim-to-start gap + // resolves through the existing abortRequestedAt path. + const claims = await interceptExecution( + { type: 'coordinator', phase: 'reconcile' }, + { instanceId: this.instance.name, agentName: this.agentName }, + () => this.reconcileSubmissions(), + ); + for (const claimed of claims) { + try { + await this.ensureAttemptRun(claimed); + } catch (error) { + this.logSubmissionReconciliationFailure(claimed, 'start_submission', error); + } + } + // observe() deliveries are fire-and-forget on the emit path; hand + // whatever this pass emitted (recovered settlements) to the + // platform so the invocation's end can't tear them down mid-POST. + this.instance.ctx.waitUntil?.(drainGlobalEventDeliveries()); + }); } /** - * One bounded supervisor pass: arm the heartbeat, reconcile durable - * state (settlement finalization, interrupted-attempt recovery, deadline - * enforcement), claim runnable work, and start attempt fibers WITHOUT - * awaiting them — fibers settle their submissions themselves and re-enter - * through onFiberSettled. The pass never waits on agent execution, so - * supervision stays live no matter what any attempt is doing. + * The `flue:attempt@v1` handler: process one submission to settlement. + * + * First execution of a claim this isolate made: process it as-is. Any + * other entry — a replay on a fresh isolate, or after the previous + * execution ended — is an interrupted attempt: reconcile the row from + * durable evidence, which settles it or claims a replacement attempt to + * process. A row that is no longer running, or that another live attempt + * already owns, is left alone. Never throws for application failures: + * `processSubmission` settles them durably; a throw here is a platform + * failure the SDK replays. */ - private async supervisorPass(): Promise { - if (!(await this.submissions.hasUnsettledSubmissions())) return; - // Heartbeat-first: the successor wake is armed before any failable - // work, so a pass that throws, or an invocation the platform cancels, - // leaves a live wake behind. The extra rows this arms are cheap no-op - // passes. Non-idempotent — an idempotent arm from inside this callback - // could dedupe onto the row being executed, which the SDK deletes - // after return, losing the wake. - await this.armBackstop(); - // The reconcile pass is storage-only and runs under the - // `flue.coordinator` interception so tracing backends can group - // its platform-instrumented storage spans. Attempt fibers start - // AFTER the interception settles, deliberately: `runFiber` runs - // its callback in the caller's async context, so a fiber started - // inside the span's activation would re-parent its invoke_agent - // span under the coordinator span. A claim whose start is - // preempted here (crash, code-update reset) is an interrupted - // attempt with no live controller — the next pass's - // running-recovery loop reconciles it, and a durable abort in the - // claim-to-start gap resolves through the existing - // abortRequestedAt path. - const claims = await interceptExecution( - { type: 'coordinator', phase: 'reconcile' }, - { instanceId: this.instance.name, agentName: this.agentName }, - () => this.reconcileSubmissions(), - ); - for (const claimed of claims) { - try { - const attempt = await this.startSubmissionAttempt(claimed); - if (attempt) this.watchAttempt(attempt); - } catch (error) { - this.logSubmissionReconciliationFailure(claimed, 'start_submission', error); + attempt(input: CloudflareAttemptTaskInput, step: CloudflareTaskStep): Promise { + return this.runWithInstanceContext(async () => { + const { submissionId } = input; + // Two passes at most: a lost reconcile race re-reads once, and a + // row that is then still unclaimed belongs to whoever won it. + for (let pass = 0; pass < 2; pass++) { + if (step.signal.aborted) return; + const row = await this.submissions.getSubmission(submissionId); + if (!row || row.status !== 'running' || !row.attemptId) return; + if (this.activeControllers.has(submissionId)) return; + let claimed: AgentSubmission | undefined; + if (this.startedAttempts.delete(row.attemptId)) { + claimed = row; + } else { + claimed = await this.reconcileInterruptedSubmission(row); + if (!claimed?.attemptId) continue; + this.startedAttempts.delete(claimed.attemptId); + } + await this.runAttempt(claimed, step.signal); + return; } - } - // observe() deliveries are fire-and-forget on the emit path; hand - // whatever this pass emitted (deadline signals, force settlements) to - // the platform so the invocation's end can't tear them down mid-POST. - this.instance.ctx.waitUntil?.(drainGlobalEventDeliveries()); + }); } /** - * Detach an attempt fiber from the pass that started it. `running` is - * catch-guarded and finally-cleaned in startSubmissionAttempt, so the - * tail here cannot reject. When the fiber settles its submission, any - * queued work behind it becomes runnable — arm a zero-delay wake so the - * queue progresses promptly instead of waiting out the heartbeat. A - * fiber that ends with its submission still unsettled (a failure inside - * settlement itself) deliberately does NOT arm: the heartbeat owns that - * class at polling cadence, which throttles a pathological - * claim/crash/reclaim cycle instead of hot-looping it. + * Run one claimed attempt in the current handler body, its flue abort + * controller linked to the Task's attempt-wide signal (an SDK + * cancellation or the run deadline unwinds it), and ensure the next + * drive when the submission settled with queued work behind it. Never + * rejects for application failures — `processSubmission` settles them + * durably; a platform failure propagates so the SDK replays the run. */ - private watchAttempt(attempt: StartedSubmissionAttempt): void { - const tail = attempt.running.then(async () => { + private async runAttempt(submission: AgentSubmission, taskSignal: AbortSignal): Promise { + const controller = new AbortController(); + this.activeControllers.set(submission.submissionId, controller); + const onTaskAbort = () => controller.abort(submissionAbortReason(taskSignal.reason)); + if (taskSignal.aborted) onTaskAbort(); + else taskSignal.addEventListener('abort', onTaskAbort, { once: true }); + try { + await this.processSubmissionEntry(submission, controller.signal); + } finally { + taskSignal.removeEventListener('abort', onTaskAbort); + this.deleteControllerIfCurrent(submission.submissionId, controller); try { const settled = - (await this.submissions.getSubmission(attempt.submissionId))?.status === 'settled'; + (await this.submissions.getSubmission(submission.submissionId))?.status === 'settled'; if (settled && (await this.submissions.hasUnsettledSubmissions())) { - await this.armDrain(); + await this.ensureDrive(); } } catch { - // Wake arming is best-effort here: the heartbeat armed by the - // starting pass (and every pass since) covers the work. + // Best-effort: an attempt that ends with its submission still + // unsettled (a failure inside settlement itself) is a running + // row the next drive reconciles. } - // The fiber's settlement events (submission_settled and any - // subscriber bridge work they trigger) ride this waitUntil too. - await drainGlobalEventDeliveries(); + // The attempt's settlement events (submission_settled and any + // subscriber bridge work they trigger) ride this waitUntil. + this.instance.ctx.waitUntil?.(drainGlobalEventDeliveries()); + } + } + + /** + * A terminal Task failure the SDK recorded — a deadline settled over a + * hung attempt, an exhausted budget, a definition missing after a deploy. + * The submission it was processing is still a running row; ensure a + * drive so the reconcile pass classifies it. Application failures never + * reach here as Task failures: the attempt body settles them itself. + */ + onTaskError(error: unknown): Promise { + return this.runWithInstanceContext(async () => { + if (!isTaskRecordedFailure(error)) return; + await this.ensureDriveIfUnsettled(); }); - // Tell the platform the invocation intentionally leaves work running. - // Without waitUntil the fiber still survives: runFiber's keepAlive - // alarm chain re-wakes the DO and fiber recovery re-enters the drain. - this.instance.ctx.waitUntil?.(tail); } onRequest(request: Request): Promise { @@ -500,24 +538,6 @@ class CloudflareAgentCoordinator { }); } - /** - * The SDK detected one of our attempt fibers interrupted (crash replay on - * a fresh isolate, or a survived run row after a reset). The durable - * submission row already carries everything recovery needs — the drain's - * reconcile pass classifies it — so the only job here is ensuring a drain - * runs. Resolving (not throwing) tells the SDK the recovery is handled, - * which deletes its run row. - */ - onFiberRecovered( - ctx: CloudflareAgentRecoveredFiberContext, - inherited: () => Promise | unknown, - ): Promise { - return this.runWithInstanceContext(async () => { - if (ctx.name !== FLUE_AGENT_SUBMISSION_ATTEMPT_FIBER) return inherited(); - await this.armDrain(); - }); - } - private get agentName(): string { return this.prepared.agentName; } @@ -576,46 +596,74 @@ class CloudflareAgentCoordinator { return ctx; } - private assertAgentsDurabilityApi(method: 'runFiber' | 'schedule'): void { - if (typeof this.instance[method] !== 'function') { + private get tasks(): CloudflareAgentTasks { + const tasks = this.instance.tasks; + if (!tasks || typeof tasks.run !== 'function') { throw new Error( - `[flue] The installed "agents" package does not provide the required Cloudflare Agents SDK method "${method}". Upgrade @flue/vite (which supplies the Cloudflare Agents SDK), or remove the "agents" dependency from your project if it declares an older one.`, + '[flue] The installed "agents" package does not provide the Cloudflare Agents SDK Tasks capability on Agent. Upgrade @flue/vite (which supplies the Cloudflare Agents SDK), or remove the "agents" dependency from your project if it declares an older one.', ); } + return tasks; } /** - * Arm a zero-delay drain. Always non-idempotent: an idempotent arm can - * dedupe onto a drain row that is currently executing — the SDK keeps the - * row in `cf_agents_schedules` until its callback returns, then deletes - * it — so an admission landing in the tail of a running drain would lose - * its wake entirely. A fresh row per arm closes that race; extra rows - * fire cheap no-op drains. + * Ensure a drive run exists. One run id per object: a caller whose + * request lands while a drive is live joins it (`accepted: false`), and + * the SDK's durable acceptance is the wake that survives isolate death. + * A drive that completed is gone (`retain: false`), so the next ensure + * starts a fresh one. Warm-started in the caller's invocation whenever + * the object is past startup. */ - private armDrain(): Promise { - this.assertAgentsDurabilityApi('schedule'); - return this.instance.schedule(0, FLUE_AGENT_SUBMISSION_WAKE_CALLBACK, undefined, { - idempotent: false, + private async ensureDrive(): Promise { + await this.tasks.run(FLUE_DRIVE_TASK, undefined, { + runId: FLUE_DRIVE_RUN_ID, + retain: false, }); } - /** 30s backstop wake onto the same schedule target (see armDrain for why non-idempotent). */ - private armBackstop(): Promise { - this.assertAgentsDurabilityApi('schedule'); - return this.instance.schedule( - FLUE_AGENT_SUBMISSION_WAKE_SECONDS, - FLUE_AGENT_SUBMISSION_WAKE_CALLBACK, - undefined, - { idempotent: false }, - ); - } - - private async armDrainIfUnsettled(): Promise { + private async ensureDriveIfUnsettled(): Promise { if (!(await this.submissions.hasUnsettledSubmissions())) return false; - await this.armDrain(); + await this.ensureDrive(); return true; } + /** + * Start (or join) the attempt run for one claimed submission. The claim's + * attempt id is registered before the run is accepted, because a warm + * start enters the handler body in this same invocation. The + * submission's durability timeout becomes the run deadline: the SDK + * wakes at it, settles the run over an attempt that ignores its signal, + * and fences the attempt's later Task writes; the drive then reconciles + * the row through its existing timeout classification. + */ + private async ensureAttemptRun(submission: AgentSubmission): Promise { + if (!submission.attemptId) return; + this.startedAttempts.add(submission.attemptId); + try { + await this.tasks.run( + FLUE_ATTEMPT_TASK, + { submissionId: submission.submissionId } satisfies CloudflareAttemptTaskInput, + { + runId: attemptRunId(submission.submissionId), + retain: false, + ...(submission.timeoutAt > 0 ? { deadline: submission.timeoutAt } : {}), + }, + ); + } catch (error) { + this.startedAttempts.delete(submission.attemptId); + throw error; + } + } + + /** Whether the SDK still holds a non-terminal attempt run for this submission. */ + private async hasLiveAttemptRun(submissionId: string): Promise { + const run = await this.tasks.get(attemptRunId(submissionId)); + return ( + run !== null && + (run.state === 'pending' || run.state === 'running' || run.state === 'waiting') + ); + } + /** * One reconcile pass: materialize unready submissions, finalize pending * settlements, recover interrupted attempts, enforce deadlines on live @@ -712,25 +760,42 @@ class CloudflareAgentCoordinator { } } for (const submission of await this.submissions.listRunningSubmissions()) { - // Attempt fibers run detached from supervisor passes, so a running - // row here is live exactly when its attempt controller is still - // registered in this isolate. No controller means the attempt is - // dead (isolate replacement, fiber crash past its cleanup, or a - // start preempted in the claim-to-start gap) and the row goes to - // interrupted-attempt reconciliation. A live row is left alone - // until a deadline demands enforcement — see - // enforceLiveAttemptDeadline. A zombie fiber that outlives either - // path is fenced by the claim CAS and attempt-id ownership checks - // on every durable write. - const liveController = this.activeControllers.get(submission.submissionId); - if (liveController && !this.enforceLiveAttemptDeadline(submission)) { - continue; - } + // A running row whose attempt run the SDK still holds belongs to + // that run: it is either live in this isolate, or interrupted and + // due for the SDK's replay, whose handler body reconciles it. + // Reconciling here too would race that replay for the claim. + // A running row with NO attempt run is this pass's to recover: + // the run settled over a hung attempt (its deadline), failed + // without running (definition missing), or the claim-to-start + // gap was preempted. When the hung attempt is still live here it + // is signaled and orphaned first, so its late writes lose the + // settlement CAS and attempt-id fences and it can no longer + // append through the shared writer. try { + if (await this.hasLiveAttemptRun(submission.submissionId)) continue; + const liveController = this.activeControllers.get(submission.submissionId); + if (liveController) { + liveController.abort( + submission.abortRequestedAt !== undefined + ? new SubmissionAbortedError() + : new SubmissionTimeoutError(), + ); + console.error('[flue:submission-reconciliation]', { + agentName: this.agentName, + instanceId: this.instance.name, + submissionId: submission.submissionId, + sessionKey: submission.sessionKey, + attemptId: submission.attemptId, + operation: 'enforce_deadline', + outcome: 'terminated', + reason: + submission.abortRequestedAt !== undefined ? 'abort_unhonored' : 'exceeded_timeout', + }); + } const replacement = await this.reconcileInterruptedSubmission(submission); - // The attempt fiber starts after the reconcile pass returns — - // see supervisorPass for why starts must escape the pass's - // tracing activation. + // The attempt run starts after the reconcile pass returns — + // see drive for why starts must escape the pass's tracing + // activation. if (replacement) toStart.push(replacement); if (liveController) this.orphanEnforcedAttempt(submission.submissionId, liveController); } catch (error) { @@ -845,71 +910,6 @@ class CloudflareAgentCoordinator { ); } - /** - * Deadline enforcement for an attempt fiber that is still live in this - * isolate. When a deadline has passed — the durability timeout, or a - * durable abort intent the fiber has not honored — fire the attempt's - * abort controller so a signal-aware await unwinds through the fiber's - * own settle path (`processSubmission` settles failed/aborted durably - * and emits the live event). Returns true — demand interrupted-attempt - * reconciliation NOW, settling over the live fiber — only once the - * deadline is more than FLUE_AGENT_SUBMISSION_SETTLE_GRACE_MS old and - * the fiber still has not settled: a signal-deaf hang (a sandbox RPC - * pending against a healthy-reporting container, a wedged stream - * reader). The hung fiber is orphaned; its late writes lose the - * settlement CAS and attempt-id fences. - */ - private enforceLiveAttemptDeadline(submission: AgentSubmission): boolean { - const now = Date.now(); - const timedOut = submission.timeoutAt > 0 && now >= submission.timeoutAt; - const abortRequested = submission.abortRequestedAt !== undefined; - if (!timedOut && !abortRequested) return false; - const controller = this.activeControllers.get(submission.submissionId); - if (!controller) return true; - // Abort intent wins over timeout, mirroring the settle-order in - // reconcileInterruptedSubmission. AbortController.abort is idempotent, - // so re-signaling on every pass is safe. - controller.abort(abortRequested ? new SubmissionAbortedError() : new SubmissionTimeoutError()); - // The grace is anchored to when the fiber was first SIGNALED, not to - // the deadline itself: a delayed first pass (late alarms) must not - // abort and force-settle in the same breath. Abort intents were - // signaled at request time (abortInstance fires the controller in - // this isolate), so they seed from abortRequestedAt. - const signaledAt = this.attemptDeadlineSignaledAt.get(controller); - const deadline = - signaledAt ?? - (abortRequested && submission.abortRequestedAt !== undefined - ? Math.min(submission.abortRequestedAt, now) - : now); - if (signaledAt === undefined) this.attemptDeadlineSignaledAt.set(controller, deadline); - if (now < deadline + FLUE_AGENT_SUBMISSION_SETTLE_GRACE_MS) { - this.emitCoordinatorEvent({ - type: 'submission_recovery', - submissionId: submission.submissionId, - kind: submission.kind, - operation: 'enforce_deadline', - outcome: 'deferred', - attemptCount: submission.attemptCount, - maxAttempts: submission.maxAttempts, - error: serializeSubmissionError( - abortRequested ? new SubmissionAbortedError() : new SubmissionTimeoutError(), - ), - }); - return false; - } - console.error('[flue:submission-reconciliation]', { - agentName: this.agentName, - instanceId: this.instance.name, - submissionId: submission.submissionId, - sessionKey: submission.sessionKey, - attemptId: submission.attemptId, - operation: 'enforce_deadline', - outcome: 'terminated', - reason: abortRequested ? 'abort_unhonored' : 'exceeded_timeout', - }); - return true; - } - /** * Recover one interrupted attempt. Returns the claimed replacement * submission (if recovery produced one) for the caller's reconcile pass @@ -934,79 +934,10 @@ class CloudflareAgentCoordinator { return replacement ?? undefined; } - /** - * Start one attempt fiber and return a handle carrying its guarded - * promise for the drain to await. The promise never rejects (failures - * settle durably in processSubmission and are logged here) and resolves - * only after the abort-controller cleanup, so the drain's next reconcile - * pass observes consistent state. - * The handle object exists because returning the promise directly from - * this async method would flatten it: callers would await the whole - * attempt instead of receiving something awaitable. - */ - private async startSubmissionAttempt( - submission: AgentSubmission, - ): Promise { - if (submission.status !== 'running' || !submission.attemptId) return undefined; - this.assertAgentsDurabilityApi('runFiber'); - const controller = new AbortController(); - this.activeControllers.set(submission.submissionId, controller); - let running: Promise; - try { - running = this.instance.runFiber(FLUE_AGENT_SUBMISSION_ATTEMPT_FIBER, async (fiberCtx) => { - fiberCtx.stash({ submissionId: submission.submissionId, attemptId: submission.attemptId }); - // The fiber is the second context boundary: it may resume on a - // fresh isolate (via the SDK's crash replay) with no ambient - // context, so it establishes its own. - await this.runWithInstanceContext(() => - this.processSubmissionEntry(submission, controller.signal), - ); - }); - } catch (error) { - this.deleteControllerIfCurrent(submission.submissionId, controller); - throw error; - } - return { - running: running - .catch((error) => { - console.error( - '[flue:submission-processing]', - { - agentName: this.agentName, - instanceId: this.instance.name, - submissionId: submission.submissionId, - operation: 'process', - outcome: 'failed', - }, - error, - ); - // Usually post-settlement (processSubmission settles failed - // durably and emits the live submission_settled before - // rethrowing); when the failure struck settlement itself the - // row is still unsettled and the backstop owns it. - this.emitCoordinatorEvent( - { - type: 'submission_recovery', - submissionId: submission.submissionId, - kind: submission.kind, - operation: 'process_submission', - outcome: 'deferred', - error: serializeSubmissionError(error), - }, - { errorInfo: classifyError(error) }, - ); - }) - .finally(() => { - this.deleteControllerIfCurrent(submission.submissionId, controller); - }), - submissionId: submission.submissionId, - }; - } - /** * Controllers are keyed by submissionId and shared across attempts, so a - * late cleanup from a superseded attempt (its fiber promise settling after - * a replacement attempt already registered its own controller) must not + * late cleanup from a superseded attempt (its body settling after a + * replacement attempt already registered its own controller) must not * delete the replacement's controller — that would sever the abort path * for a live attempt. */ @@ -1017,7 +948,7 @@ class CloudflareAgentCoordinator { } /** - * After deadline enforcement settled over a live-but-hung fiber, orphan + * After the reconcile pass settled over a live-but-hung attempt, orphan * it: drop its controller entry (its own finally-cleanup is unreachable) * and rotate the cached conversation writer so later sessions acquire a * fresh producer — a waking zombie's rejected append then fails only the @@ -1041,17 +972,17 @@ class CloudflareAgentCoordinator { ); const affected = await this.submissions.requestSessionAbort(sessionKey); if (affected.length === 0) return false; - // Abort any of those attempt fibers live in this isolate — - // processSubmission's catch settles them aborted and the fiber tail - // arms the next wake. Queued ones settle via the pre-execution abort - // check once a pass claims them; an evicted running attempt is driven - // by the durable flag through reconciliation, and a signal-deaf live - // fiber is force-settled by enforceLiveAttemptDeadline once the abort - // intent outlives the settle grace. + // Abort any of those attempts live in this isolate — + // processSubmission's catch settles them aborted and the attempt body + // ensures the next drive. Queued ones settle via the pre-execution + // abort check once a drive claims them; an evicted running attempt is + // driven by the durable flag through reconciliation, and a signal-deaf + // live attempt is settled over by its run deadline (the submission's + // durability timeout), after which the drive reconciles it aborted. for (const submissionId of affected) { this.activeControllers.get(submissionId)?.abort(new SubmissionAbortedError()); } - await this.armDrain(); + await this.ensureDrive(); return true; } @@ -1127,7 +1058,7 @@ class CloudflareAgentCoordinator { const adoptedReceipt = async (submissionId: string) => { const reducedUid = (await loadReducedState()).uid; if (reducedUid === undefined) return undefined; - await this.armDrain(); + await this.ensureDrive(); return { submissionId, offset: '-1', @@ -1212,7 +1143,7 @@ class CloudflareAgentCoordinator { ...(deduplicated ? { deduplicated: true as const } : {}), }; } finally { - await this.armDrain(); + await this.ensureDrive(); } } @@ -1262,7 +1193,7 @@ class CloudflareAgentCoordinator { const adopted = await adoptKeyedSubmissionReplay(this.submissions, submissionInput); const adoptedUid = adopted ? (await loadReducedState()).uid : undefined; if (adopted && adoptedUid !== undefined) { - await this.armDrain(); + await this.ensureDrive(); return Response.json({ submissionId: adopted.submissionId, acceptedAt: adopted.input.acceptedAt, @@ -1329,7 +1260,7 @@ class CloudflareAgentCoordinator { ...(deduplicated ? { deduplicated: true } : {}), }); } finally { - await this.armDrain(); + await this.ensureDrive(); } } catch (error) { // Structured body so the dispatch() caller's enqueue can rehydrate the @@ -1360,6 +1291,41 @@ class CloudflareAgentCoordinator { } } +/** + * Translate the Agents SDK's attempt-wide abort reason into the submission + * error `processSubmission` keys its settlement on: the run deadline is the + * submission's durability timeout; anything else (an SDK cancellation) is an + * abort. + */ +function submissionAbortReason(reason: unknown): Error { + return isTaskError(reason, 'TaskDeadlineExceededError') + ? new SubmissionTimeoutError() + : new SubmissionAbortedError(); +} + +/** + * Whether an error the Agents SDK reported through `onError` is one it + * recorded against a Task run without (or over) its handler — the cases + * that leave a running submission row behind with no run to finish it. + * Matched by name: `@flue/runtime` does not import `agents`. + */ +function isTaskRecordedFailure(error: unknown): boolean { + return ( + isTaskError(error, 'TaskDeadlineExceededError') || + isTaskError(error, 'TaskAttemptsExhaustedError') || + isTaskError(error, 'MissingTaskDefinitionError') + ); +} + +function isTaskError(error: unknown, name: string): boolean { + return ( + typeof error === 'object' && + error !== null && + 'name' in error && + (error as { name: unknown }).name === name + ); +} + function isInternalDispatchRequest(request: Request): boolean { return ( request.method === 'POST' && diff --git a/packages/runtime/src/cloudflare/flue-agent-class.ts b/packages/runtime/src/cloudflare/flue-agent-class.ts index a2281592e..8e18b0c97 100644 --- a/packages/runtime/src/cloudflare/flue-agent-class.ts +++ b/packages/runtime/src/cloudflare/flue-agent-class.ts @@ -8,15 +8,26 @@ * - `runtime.prepare(...)` runs BEFORE `super(ctx, env)` so the coordinator's * stores exist before the Agents SDK constructor can schedule work, then * `runtime.attach(this, prepared)` binds the coordinator to the instance. - * - `onStart` / `onRequest` / `onFiberRecovered` / the - * `__flueWakeAgentSubmissions` schedule target delegate to the shared - * Cloudflare agent runtime; `onStart`/`onFiberRecovered` forward to an - * inherited implementation when the (possibly extended) base defines one. + * - Submission execution runs on the Agents SDK `Tasks` capability the + * `Agent` base installs as `this.tasks`: `taskDefinitions` declares the two + * Flue definitions (a drive pass and a per-submission attempt), whose + * handlers delegate to the shared Cloudflare agent runtime. The SDK + * resolves the names against this field on every wake, so in-flight runs + * always find their handler. + * - `onStart` / `onRequest` / `alarm` / `onError` delegate to the runtime and + * forward to an inherited implementation when the (possibly extended) base + * defines one. * - The module's `extend({ base, wrap })` export is resolved via * `resolveCloudflareExtension`: `base` reshapes the superclass, `wrap` * wraps the final class, and the wrapped class is what gets exported. */ -import type { CloudflareAgentRuntime } from './agent-coordinator.ts'; +import { + type CloudflareAgentRuntime, + type CloudflareAttemptTaskInput, + type CloudflareTaskStep, + FLUE_ATTEMPT_TASK, + FLUE_DRIVE_TASK, +} from './agent-coordinator.ts'; import { type ExtensionClass, resolveCloudflareExtension } from './extension.ts'; type CloudflareAgentInstance = Parameters[0]; @@ -45,6 +56,15 @@ export interface CreateFlueAgentClassOptions { readonly extension?: unknown; } +/** + * Task definitions an extension `base` declared as an instance field. Base + * class fields are assigned before a subclass's own initializers run, so + * reading the property at that point sees exactly the inherited value. + */ +function inheritedTaskDefinitions(instance: object): Record { + return (instance as { taskDefinitions?: Record }).taskDefinitions ?? {}; +} + /** * Build the final (possibly extension-wrapped) Durable Object class for one * agent module. @@ -69,25 +89,26 @@ export function createFlueAgentClass(options: CreateFlueAgentClassOptions): Exte runtime.attach(this as unknown as CloudflareAgentInstance, prepared); } + /** + * Flue's Task definitions, merged over any the extension `base` + * declared. The Agents SDK reads this field lazily and re-resolves it + * on every wake, which is what makes replay of an in-flight run + * correct by construction. + */ + readonly taskDefinitions: Record = { + ...inheritedTaskDefinitions(this), + [FLUE_DRIVE_TASK]: (_input: undefined, step: CloudflareTaskStep) => + runtime.drive(this as unknown as CloudflareAgentInstance, step), + [FLUE_ATTEMPT_TASK]: (input: CloudflareAttemptTaskInput, step: CloudflareTaskStep) => + runtime.attempt(this as unknown as CloudflareAgentInstance, input, step), + }; + onStart(props?: Record) { return runtime.onStart(this as unknown as CloudflareAgentInstance, () => typeof super.onStart === 'function' ? super.onStart(props) : undefined, ); } - /** - * Durable schedule target that owns submission supervision: armed at - * zero delay by admission/abort/recovery/fiber-settle boundaries and - * at 30s as the heartbeat while unsettled work exists. Dispatched - * from the Durable Object's alarm invocation as one bounded, - * storage-only pass that reconciles, enforces deadlines, and starts - * attempt fibers detached — the fibers outlive the invocation on the - * SDK's runFiber keepAlive/recovery machinery. - */ - __flueWakeAgentSubmissions() { - return runtime.drainSubmissions(this as unknown as CloudflareAgentInstance); - } - onRequest(request: Request) { return runtime.onRequest(this as unknown as CloudflareAgentInstance, request); } @@ -104,10 +125,22 @@ export function createFlueAgentClass(options: CreateFlueAgentClassOptions): Exte ); } - onFiberRecovered(ctx: { readonly name?: string; readonly snapshot?: Record }) { - return runtime.onFiberRecovered(this as unknown as CloudflareAgentInstance, ctx, () => - typeof super.onFiberRecovered === 'function' ? super.onFiberRecovered(ctx) : undefined, - ); + /** + * The SDK reports terminal Task failures here (`onError(error)`; the + * `(connection, error)` overload is WebSocket-only and never Flue's). + * A failure the SDK recorded without running a handler — a deadline + * settled over a hung attempt, a definition missing after a deploy — + * leaves a submission row for the runtime to reconcile. + */ + async onError(...args: unknown[]) { + try { + if (typeof super.onError === 'function') await super.onError(...args); + } finally { + await runtime.onTaskError( + this as unknown as CloudflareAgentInstance, + args.length >= 2 ? args[1] : args[0], + ); + } } } diff --git a/packages/vite/package.json b/packages/vite/package.json index 6251b5a7a..5014d3925 100644 --- a/packages/vite/package.json +++ b/packages/vite/package.json @@ -37,7 +37,7 @@ "dependencies": { "@flue/runtime": "workspace:*", "@hono/node-server": "^2.0.3", - "agents": "^0.20.1", + "agents": "^0.23.0", "magic-string": "^1.0.0", "tinyglobby": "^0.2.15", "ulidx": "^2.4.1" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ffed321f0..63c47ea8a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -164,7 +164,7 @@ importers: version: 19.2.3(@types/react@19.2.17) '@vitejs/plugin-react': specifier: ^6.0.2 - version: 6.0.4(@rolldown/plugin-babel@0.2.3(@babel/core@8.0.1)(@babel/runtime@7.29.7)(rolldown@1.2.1)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)))(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 6.0.4(@rolldown/plugin-babel@0.2.4(@babel/core@8.0.5)(@babel/runtime@7.29.7)(rolldown@1.2.1)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)))(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) oxlint: specifier: ^1.69.0 version: 1.76.0 @@ -1846,8 +1846,8 @@ importers: specifier: ^2.0.3 version: 2.0.12(hono@4.12.32) agents: - specifier: ^0.20.1 - version: 0.20.1(@babel/core@8.0.1)(@babel/runtime@7.29.7)(@cloudflare/workers-types@5.20260731.1)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(@modelcontextprotocol/server@2.0.0)(chat@4.35.0(zod@4.4.3))(just-bash@3.2.0)(react@19.2.8)(rolldown@1.2.1)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(zod@4.4.3) + specifier: ^0.23.0 + version: 0.23.0(@babel/core@8.0.5)(@babel/runtime@7.29.7)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(@modelcontextprotocol/server@2.0.0)(chat@4.35.0(zod@4.4.3))(just-bash@3.2.0)(react@19.2.8)(rolldown@1.2.1)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(zod@4.4.3) magic-string: specifier: ^1.0.0 version: 1.1.0 @@ -2170,28 +2170,28 @@ packages: resolution: {integrity: sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==} engines: {node: ^22.18.0 || >=24.11.0} - '@babel/compat-data@8.0.0': - resolution: {integrity: sha512-DOjnob/cXOUgDOozCDeq/aK2p5y8dUIVdf6tNhEV1HQRd6I8aQ4f4fbtHRVEvb6lP3BGomrKHiS8ICAASSVQSw==} + '@babel/compat-data@8.0.5': + resolution: {integrity: sha512-YLsYoQMvL8l8WrGpN3Zj7O1wK5LEBN+cQtux7BcuHyxIXve724XG+zuJ1n3U1cUweRtTzQOA4IHbuQw3N34SZw==} engines: {node: ^22.18.0 || >=24.11.0} - '@babel/core@8.0.1': - resolution: {integrity: sha512-5FgxM4dLQpMJHSiVATk8foW263dVHQHBVpXYiimNECVWG01f4nFyEbQixeT6Mwvg7TayREJ2gpKl3o2RoMdnqw==} + '@babel/core@8.0.5': + resolution: {integrity: sha512-2/oWkgTbBYoqioCWAE4XJobOrzwxTDa5/XjDP3tJ1BhDr/owcd9qnXBp4xc3/2G5X4bvXM04nrxbRJxFcXAxFQ==} engines: {node: ^22.18.0 || >=24.11.0} - '@babel/generator@8.0.0': - resolution: {integrity: sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==} + '@babel/generator@8.0.5': + resolution: {integrity: sha512-f/TuhuMAxJqhwxEGNsJrswuG9VHmh0oNFoQoo6TbpgtFAz9wYZXcTAcWZMHfp7ljesr0RG04bp3Aos9GI59L7w==} engines: {node: ^22.18.0 || >=24.11.0} '@babel/helper-annotate-as-pure@8.0.0': resolution: {integrity: sha512-NSpMkMsvvZqzThJ0p1B02cbtA2ObEyfBvq950bmNkyxsxvcxwhvvCB036rKhlEnuBBo30bOrk13u3FzlKSoRrw==} engines: {node: ^22.18.0 || >=24.11.0} - '@babel/helper-compilation-targets@8.0.0': - resolution: {integrity: sha512-JwculLABZvyPvyLBpwU/E/IbH2uM3mnxNtIJpxnIfb24y1PrdVxK5Dqjle4DpgqpGRnwgC7G8IkzPdSXZrO1Ew==} + '@babel/helper-compilation-targets@8.0.5': + resolution: {integrity: sha512-Qk8ahMGooH5mz6uuhoDvfZGkUf/Mf3RTBucVVl4MKx4LKMTv872TeW8O92h15iVtlN8wAROBIpI1aV6x1z0LCQ==} engines: {node: ^22.18.0 || >=24.11.0} - '@babel/helper-create-class-features-plugin@8.0.1': - resolution: {integrity: sha512-++t3ZktzlLmASAxIlxeXQK9Z2YwUafYGYcvGBFevqOqt16HozVHStUoQvWD09fzAZOb/uJGpUTBuGK41AJAuOA==} + '@babel/helper-create-class-features-plugin@8.0.5': + resolution: {integrity: sha512-ckUE7tmolBbW4QV02lD874sWSPy8da2A09i/h0qS+IWolR+WiNhpq7C/rHZm00uOIkUb0Cp6+0Cy1ku/wj5uQQ==} engines: {node: ^22.18.0 || >=24.11.0} peerDependencies: '@babel/core': ^8.0.0 @@ -2200,8 +2200,8 @@ packages: resolution: {integrity: sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw==} engines: {node: ^22.18.0 || >=24.11.0} - '@babel/helper-member-expression-to-functions@8.0.0': - resolution: {integrity: sha512-xkXrMbtk87Gk7+oKBVmBc6EORg/Qwx++AHESldmHkpvG8wgccdhJJFwrzqlF382Fk8wfXhJHWE/g/43QvEGNPQ==} + '@babel/helper-member-expression-to-functions@8.0.5': + resolution: {integrity: sha512-GLe05QD98BkNFTkjaqeqF9QSRoHLKrrB4tpoplyQuuPrQJ72rtmkM4wvqJLX/sjZPqkbK5MUYSsuUTqdJoOVjA==} engines: {node: ^22.18.0 || >=24.11.0} '@babel/helper-optimise-call-expression@8.0.0': @@ -2244,8 +2244,8 @@ packages: resolution: {integrity: sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q==} engines: {node: ^22.18.0 || >=24.11.0} - '@babel/helpers@8.0.0': - resolution: {integrity: sha512-wfbi91pM3py96oIiJEz7qIpyXDytgr9zQC1HEWwlGNVRAEmItuU/0a41ZUKu1sJGyhhOIpc4t5vk4PYzt8wpsg==} + '@babel/helpers@8.0.5': + resolution: {integrity: sha512-fQtPOXjYOYv85PIdwotp2TJGVYOycX0PQq+l844fFAxOULtBy8BVF35GyeueX0r4KvDthqPH5xAI1clQPk/2uA==} engines: {node: ^22.18.0 || >=24.11.0} '@babel/parser@7.29.7': @@ -2253,8 +2253,8 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - '@babel/parser@8.0.4': - resolution: {integrity: sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==} + '@babel/parser@8.0.5': + resolution: {integrity: sha512-51RXvQNFakaS0bTpYiGkxNbUVwkPO4kONv6EVLorZABxsx+KZ6Z7uSYvi/wmKS/+X+rfj9RvOw0/ZNh+cmI0Rw==} engines: {node: ^22.18.0 || >=24.11.0} hasBin: true @@ -2282,16 +2282,16 @@ packages: resolution: {integrity: sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==} engines: {node: ^22.18.0 || >=24.11.0} - '@babel/traverse@8.0.4': - resolution: {integrity: sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg==} + '@babel/traverse@8.0.5': + resolution: {integrity: sha512-XFfnuvapSc/vJOcUO7kwORSvpBIvraofKEZ2dhT0PjiF21BRCD7YbAFC8UEeDJNeLoQz82/gVqzgX5hCzkCbdg==} engines: {node: ^22.18.0 || >=24.11.0} '@babel/types@7.29.7': resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} - '@babel/types@8.0.4': - resolution: {integrity: sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==} + '@babel/types@8.0.5': + resolution: {integrity: sha512-eVdMqi3ej5aHhyQ2Si6yD2cAWeV8FJK9UrhK5aL0Sd8hu5GhT+YswhVNbVheOGVYMg8kuGuMaUpkB3stjj4z8A==} engines: {node: ^22.18.0 || >=24.11.0} '@biomejs/biome@2.5.6': @@ -2594,7 +2594,7 @@ packages: vitest: ^4.1.0 '@cloudflare/workerd-darwin-64@1.20260730.1': - resolution: {integrity: sha512-+MBHmPaiTe2KajryW0T24rZvWFxb41hD3d8anNzQqHzft6vSEb18+sp0znSwxgij7ApPhSM1+vhkNg4f3YMguA==, tarball: https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260730.1.tgz} + resolution: {integrity: sha512-+MBHmPaiTe2KajryW0T24rZvWFxb41hD3d8anNzQqHzft6vSEb18+sp0znSwxgij7ApPhSM1+vhkNg4f3YMguA==} engines: {node: '>=16'} cpu: [x64] os: [darwin] @@ -2606,19 +2606,19 @@ packages: os: [darwin] '@cloudflare/workerd-linux-64@1.20260730.1': - resolution: {integrity: sha512-ouyPOSMbiKPeSwUJUvxtMcxGAXs2J4aPE4T5ABIYX5ClcQx5j5bbHTmnqOQEY8sAuLTPjH7dY+iB6UI5ISlwwA==, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260730.1.tgz} + resolution: {integrity: sha512-ouyPOSMbiKPeSwUJUvxtMcxGAXs2J4aPE4T5ABIYX5ClcQx5j5bbHTmnqOQEY8sAuLTPjH7dY+iB6UI5ISlwwA==} engines: {node: '>=16'} cpu: [x64] os: [linux] '@cloudflare/workerd-linux-arm64@1.20260730.1': - resolution: {integrity: sha512-YQ+Mi78U3TPdgBPtwq+Sm6rJU+Ihl2y0pjYtuuKkdmUbYzL7oLR6Xqq9wljhasnuCFICssDJaqhMep5WizYoEQ==, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260730.1.tgz} + resolution: {integrity: sha512-YQ+Mi78U3TPdgBPtwq+Sm6rJU+Ihl2y0pjYtuuKkdmUbYzL7oLR6Xqq9wljhasnuCFICssDJaqhMep5WizYoEQ==} engines: {node: '>=16'} cpu: [arm64] os: [linux] '@cloudflare/workerd-windows-64@1.20260730.1': - resolution: {integrity: sha512-27fAN+vUECW1oYVc1KOcHYpkL8COM2Uxtxql7TL595kxbjoqS5yckw7NLz7bTf2pALFCZWjqXDjZGJ/xbG4ZKQ==, tarball: https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260730.1.tgz} + resolution: {integrity: sha512-27fAN+vUECW1oYVc1KOcHYpkL8COM2Uxtxql7TL595kxbjoqS5yckw7NLz7bTf2pALFCZWjqXDjZGJ/xbG4ZKQ==} engines: {node: '>=16'} cpu: [x64] os: [win32] @@ -3256,6 +3256,9 @@ packages: '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + '@jridgewell/gen-mapping@0.4.0-beta.0': + resolution: {integrity: sha512-JdGNkbE4GlNPYQhM0L95fBQr7ctLZJ276QXQLTad4t1oSdnnCI3fDq9DW3BqYAWv8Wc3+HS+4Gsii1oPMCfz1w==} + '@jridgewell/remapping@2.3.5': resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} @@ -3266,6 +3269,9 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -5096,8 +5102,8 @@ packages: cpu: [x64] os: [win32] - '@rolldown/plugin-babel@0.2.3': - resolution: {integrity: sha512-+zEk16yGlz1F9STiRr6uG9hmIXb6nprjLczV/htGptYuLoCuxb+itZ03RKCEeOhBpDDd1NU7qF6x1VLMUp62bw==} + '@rolldown/plugin-babel@0.2.4': + resolution: {integrity: sha512-ygmm2j/mN+aCvstHo0x3IOu4uc6LqXT9SJ3q4kpfG/+Yhfz0B+E47FITcC0FljZADwUFARP6E2kiE+Lc26D8wg==} engines: {node: '>=22.12.0 || ^24.0.0'} peerDependencies: '@babel/core': ^7.29.0 || ^8.0.0-rc.1 @@ -6012,9 +6018,8 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} - agents@0.20.1: - resolution: {integrity: sha512-HQRYMeZpD3k8djYBH7atRPojZMee3NvmXkzsmMWXfdHZ94vMljmWqSsD1XZd70LovHyQrw6/R81AZZIsRiFM6Q==} - hasBin: true + agents@0.23.0: + resolution: {integrity: sha512-V6K9vSaUn9AWQUKj4rQl6Q+4Q2EJ7+TYwaakY1uBXb9RJvSqIwv25SPjZKZA5Mjy0lCUnBoExJlzUeFERKYrUQ==} peerDependencies: '@ai-sdk/react': ^3.0.0 || ^4.0.0 '@cloudflare/codemode': '>=0.5.0' @@ -6047,6 +6052,8 @@ packages: optional: true just-bash: optional: true + react: + optional: true vite: optional: true @@ -6069,10 +6076,6 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} - engines: {node: '>=12'} - ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -6081,10 +6084,6 @@ packages: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} - ansi-styles@6.2.3: - resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} - engines: {node: '>=12'} - ansis@4.3.1: resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} engines: {node: '>=14'} @@ -6158,8 +6157,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.11.7: - resolution: {integrity: sha512-APw5YuIQAg6L9w4sHDI6j26DGFJI6RpYOhnkMPdC9lWbkKvsyPHzDsve1yd73lk21yz7Y09Kci8B2Pp9FonzWA==} + baseline-browser-mapping@2.11.23: + resolution: {integrity: sha512-le521dGVfxM7yRX0EikCoSz+rOK+hHzdDt/E7mG1jOJB/6WAAUuwVroLwaB7ApaUsz5Q0kFlDXLSA9MheUIfRQ==} engines: {node: '>=6.0.0'} hasBin: true @@ -6206,8 +6205,8 @@ packages: peerDependencies: zod: ^3.25.34 || ^4.0 - browserslist@4.28.7: - resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} + browserslist@4.28.9: + resolution: {integrity: sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -6248,8 +6247,11 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} - caniuse-lite@1.0.30001806: - resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + + capnweb@0.12.0: + resolution: {integrity: sha512-jgZ/LMtMVTi+RVlooIslH78Gg23XbDTdvcLghWx3oz17D6u5GuJARt+uhZk/wcTo+f81eeabfnow4d3zLBj+JQ==} capnweb@0.8.0: resolution: {integrity: sha512-BK/TuXUiyfLSKsmjojn70yN7oYG/JJzoURZ3tckjg5Zj2KcygPm0A5jyOlswK7SYB4f0Gh9tt+RZ132b80iLfA==} @@ -6327,10 +6329,6 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} - cliui@9.0.1: - resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} - engines: {node: '>=20'} - clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -6417,8 +6415,8 @@ packages: resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} engines: {node: '>=22'} - core-js-pure@3.49.0: - resolution: {integrity: sha512-XM4RFka59xATyJv/cS3O3Kml72hQXUeGRuuTmMYFxwzc9/7C8OYTaIR/Ji+Yt8DXzsFLNhat15cE/JP15HrCgw==} + core-js-pure@3.50.0: + resolution: {integrity: sha512-6GP3Pxz4IKyWjAfa747vIu/jilB5z29JWROLqH/b+pXVcpgh6tM06ZIBwSuglgVqzDYURhOK6oEzTrG0bCHitA==} cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} @@ -6752,11 +6750,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.398: - resolution: {integrity: sha512-AsvhAxopJGh6museTDMIjn6JpDYOfgu4RLlygomt87MUwBUqTfd/1EiPtx10/LZE8xpTvkP2E9Gafq7lkLtodQ==} - - emoji-regex@10.6.0: - resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + electron-to-chromium@1.5.427: + resolution: {integrity: sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -6919,8 +6914,8 @@ packages: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} - express-rate-limit@8.6.1: - resolution: {integrity: sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==} + express-rate-limit@8.7.0: + resolution: {integrity: sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==} engines: {node: '>= 16'} peerDependencies: express: '>= 4.11' @@ -7066,10 +7061,6 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - get-east-asian-width@1.6.0: - resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} - engines: {node: '>=18'} - get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -7280,8 +7271,8 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} - ip-address@10.3.1: - resolution: {integrity: sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==} + ip-address@10.7.0: + resolution: {integrity: sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -8058,8 +8049,8 @@ packages: node-mock-http@1.0.5: resolution: {integrity: sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw==} - node-releases@2.0.51: - resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + node-releases@2.0.55: + resolution: {integrity: sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==} engines: {node: '>=18'} normalize-path@3.0.0: @@ -8190,11 +8181,6 @@ packages: partial-json@0.1.7: resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} - partyserver@0.5.9: - resolution: {integrity: sha512-0T1OGQD9Ggllrlp8Bt8XLOglEEktH4bui28Z1dbSGEX/iFmZnEWR148Qmvc1Od3rl64OAOfVtr2sG4XOMI9Y6Q==} - peerDependencies: - '@cloudflare/workers-types': ^4.20260424.1 || ^5.20260703.1 - partysocket@1.3.0: resolution: {integrity: sha512-1zToNyolZFK/7nuAw/K2bZrNzFqaZyRoCEkS+9vG6WSC5ikrN6qWRe96q6ImU51uptz2r+dAwSkwhJVdQi4LiA==} peerDependencies: @@ -8754,14 +8740,6 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} - string-width@7.2.0: - resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} - engines: {node: '>=18'} - - string-width@8.2.2: - resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} - engines: {node: '>=20'} - string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -8772,10 +8750,6 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} - strip-ansi@7.2.0: - resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} - engines: {node: '>=12'} - strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} @@ -9135,8 +9109,8 @@ packages: uploadthing: optional: true - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + update-browserslist-db@1.3.3: + resolution: {integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -9198,6 +9172,10 @@ packages: resolution: {integrity: sha512-w2Eo8LSIIoW7qxNBzT7/17k+bh8plXo7G3dHjEIDqPlnluhzaxr9JX8F28VSYEtDvc1/a3WBDih6xNUZseebXg==} engines: {node: '>=18.12.0'} + verkit@0.3.2: + resolution: {integrity: sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==} + engines: {node: '>=18.12.0'} + vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} @@ -9374,10 +9352,6 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} - wrap-ansi@9.0.2: - resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} - engines: {node: '>=18'} - wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -9445,10 +9419,6 @@ packages: resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} engines: {node: '>=12'} - yargs@18.1.0: - resolution: {integrity: sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==} - engines: {node: ^20.19.0 || ^22.12.0 || >=23} - yocto-queue@1.2.2: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} @@ -9913,18 +9883,18 @@ snapshots: '@babel/helper-validator-identifier': 8.0.4 js-tokens: 10.0.0 - '@babel/compat-data@8.0.0': {} + '@babel/compat-data@8.0.5': {} - '@babel/core@8.0.1': + '@babel/core@8.0.5': dependencies: '@babel/code-frame': 8.0.0 - '@babel/generator': 8.0.0 - '@babel/helper-compilation-targets': 8.0.0 - '@babel/helpers': 8.0.0 - '@babel/parser': 8.0.4 + '@babel/generator': 8.0.5 + '@babel/helper-compilation-targets': 8.0.5 + '@babel/helpers': 8.0.5 + '@babel/parser': 8.0.5 '@babel/template': 8.0.0 - '@babel/traverse': 8.0.4 - '@babel/types': 8.0.4 + '@babel/traverse': 8.0.5 + '@babel/types': 8.0.5 '@types/gensync': 1.0.5 convert-source-map: 2.0.0 empathic: 2.0.1 @@ -9932,66 +9902,66 @@ snapshots: import-meta-resolve: 4.2.0 json5: 2.2.3 obug: 2.1.4 - semver: 7.8.5 + verkit: 0.3.2 - '@babel/generator@8.0.0': + '@babel/generator@8.0.5': dependencies: - '@babel/parser': 8.0.4 - '@babel/types': 8.0.4 - '@jridgewell/gen-mapping': 0.3.13 + '@babel/parser': 8.0.5 + '@babel/types': 8.0.5 + '@jridgewell/gen-mapping': 0.4.0-beta.0 '@jridgewell/trace-mapping': 0.3.31 '@types/jsesc': 2.5.1 jsesc: 3.1.0 '@babel/helper-annotate-as-pure@8.0.0': dependencies: - '@babel/types': 8.0.4 + '@babel/types': 8.0.5 - '@babel/helper-compilation-targets@8.0.0': + '@babel/helper-compilation-targets@8.0.5': dependencies: - '@babel/compat-data': 8.0.0 + '@babel/compat-data': 8.0.5 '@babel/helper-validator-option': 8.0.0 - browserslist: 4.28.7 + browserslist: 4.28.9 lru-cache: 11.5.2 - semver: 7.8.5 + verkit: 0.3.2 - '@babel/helper-create-class-features-plugin@8.0.1(@babel/core@8.0.1)': + '@babel/helper-create-class-features-plugin@8.0.5(@babel/core@8.0.5)': dependencies: - '@babel/core': 8.0.1 + '@babel/core': 8.0.5 '@babel/helper-annotate-as-pure': 8.0.0 - '@babel/helper-member-expression-to-functions': 8.0.0 + '@babel/helper-member-expression-to-functions': 8.0.5 '@babel/helper-optimise-call-expression': 8.0.0 - '@babel/helper-replace-supers': 8.0.1(@babel/core@8.0.1) + '@babel/helper-replace-supers': 8.0.1(@babel/core@8.0.5) '@babel/helper-skip-transparent-expression-wrappers': 8.0.0 - '@babel/traverse': 8.0.4 - semver: 7.8.5 + '@babel/traverse': 8.0.5 + verkit: 0.3.2 '@babel/helper-globals@8.0.0': {} - '@babel/helper-member-expression-to-functions@8.0.0': + '@babel/helper-member-expression-to-functions@8.0.5': dependencies: - '@babel/traverse': 8.0.4 - '@babel/types': 8.0.4 + '@babel/traverse': 8.0.5 + '@babel/types': 8.0.5 '@babel/helper-optimise-call-expression@8.0.0': dependencies: - '@babel/types': 8.0.4 + '@babel/types': 8.0.5 - '@babel/helper-plugin-utils@8.0.1(@babel/core@8.0.1)': + '@babel/helper-plugin-utils@8.0.1(@babel/core@8.0.5)': dependencies: - '@babel/core': 8.0.1 + '@babel/core': 8.0.5 - '@babel/helper-replace-supers@8.0.1(@babel/core@8.0.1)': + '@babel/helper-replace-supers@8.0.1(@babel/core@8.0.5)': dependencies: - '@babel/core': 8.0.1 - '@babel/helper-member-expression-to-functions': 8.0.0 + '@babel/core': 8.0.5 + '@babel/helper-member-expression-to-functions': 8.0.5 '@babel/helper-optimise-call-expression': 8.0.0 - '@babel/traverse': 8.0.4 + '@babel/traverse': 8.0.5 '@babel/helper-skip-transparent-expression-wrappers@8.0.0': dependencies: - '@babel/traverse': 8.0.4 - '@babel/types': 8.0.4 + '@babel/traverse': 8.0.5 + '@babel/types': 8.0.5 '@babel/helper-string-parser@7.29.7': {} @@ -10003,51 +9973,51 @@ snapshots: '@babel/helper-validator-option@8.0.0': {} - '@babel/helpers@8.0.0': + '@babel/helpers@8.0.5': dependencies: '@babel/template': 8.0.0 - '@babel/types': 8.0.4 + '@babel/types': 8.0.5 '@babel/parser@7.29.7': dependencies: '@babel/types': 7.29.7 - '@babel/parser@8.0.4': + '@babel/parser@8.0.5': dependencies: - '@babel/types': 8.0.4 + '@babel/types': 8.0.5 - '@babel/plugin-proposal-decorators@8.0.2(@babel/core@8.0.1)': + '@babel/plugin-proposal-decorators@8.0.2(@babel/core@8.0.5)': dependencies: - '@babel/core': 8.0.1 - '@babel/helper-create-class-features-plugin': 8.0.1(@babel/core@8.0.1) - '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.1) - '@babel/plugin-syntax-decorators': 8.0.1(@babel/core@8.0.1) + '@babel/core': 8.0.5 + '@babel/helper-create-class-features-plugin': 8.0.5(@babel/core@8.0.5) + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.5) + '@babel/plugin-syntax-decorators': 8.0.1(@babel/core@8.0.5) - '@babel/plugin-syntax-decorators@8.0.1(@babel/core@8.0.1)': + '@babel/plugin-syntax-decorators@8.0.1(@babel/core@8.0.5)': dependencies: - '@babel/core': 8.0.1 - '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.1) + '@babel/core': 8.0.5 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.5) '@babel/runtime-corejs3@7.29.7': dependencies: - core-js-pure: 3.49.0 + core-js-pure: 3.50.0 '@babel/runtime@7.29.7': {} '@babel/template@8.0.0': dependencies: '@babel/code-frame': 8.0.0 - '@babel/parser': 8.0.4 - '@babel/types': 8.0.4 + '@babel/parser': 8.0.5 + '@babel/types': 8.0.5 - '@babel/traverse@8.0.4': + '@babel/traverse@8.0.5': dependencies: '@babel/code-frame': 8.0.0 - '@babel/generator': 8.0.0 + '@babel/generator': 8.0.5 '@babel/helper-globals': 8.0.0 - '@babel/parser': 8.0.4 + '@babel/parser': 8.0.5 '@babel/template': 8.0.0 - '@babel/types': 8.0.4 + '@babel/types': 8.0.5 obug: 2.1.4 '@babel/types@7.29.7': @@ -10055,7 +10025,7 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/types@8.0.4': + '@babel/types@8.0.5': dependencies: '@babel/helper-string-parser': 8.0.0 '@babel/helper-validator-identifier': 8.0.4 @@ -10927,6 +10897,11 @@ snapshots: '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/gen-mapping@0.4.0-beta.0': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/remapping@2.3.5': dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -10936,6 +10911,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/sourcemap-codec@1.6.0': {} + '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -11114,7 +11091,7 @@ snapshots: eventsource: 3.0.7 eventsource-parser: 3.1.0 express: 5.2.1 - express-rate-limit: 8.6.1(express@5.2.1) + express-rate-limit: 8.7.0(express@5.2.1) hono: 4.12.32 jose: 6.2.5 json-schema-typed: 8.0.2 @@ -12694,10 +12671,10 @@ snapshots: '@rolldown/binding-win32-x64-msvc@1.2.1': optional: true - '@rolldown/plugin-babel@0.2.3(@babel/core@8.0.1)(@babel/runtime@7.29.7)(rolldown@1.2.1)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))': + '@rolldown/plugin-babel@0.2.4(@babel/core@8.0.5)(@babel/runtime@7.29.7)(rolldown@1.2.1)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))': dependencies: - '@babel/core': 8.0.1 - picomatch: 4.0.5 + '@babel/core': 8.0.5 + picomatch: 4.0.7 rolldown: 1.2.1 optionalDependencies: '@babel/runtime': 7.29.7 @@ -13326,12 +13303,12 @@ snapshots: optionalDependencies: '@aws-sdk/credential-provider-web-identity': 3.972.71 - '@vitejs/plugin-react@6.0.4(@rolldown/plugin-babel@0.2.3(@babel/core@8.0.1)(@babel/runtime@7.29.7)(rolldown@1.2.1)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)))(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))': + '@vitejs/plugin-react@6.0.4(@rolldown/plugin-babel@0.2.4(@babel/core@8.0.5)(@babel/runtime@7.29.7)(rolldown@1.2.1)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)))(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 vite: 8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) optionalDependencies: - '@rolldown/plugin-babel': 0.2.3(@babel/core@8.0.1)(@babel/runtime@7.29.7)(rolldown@1.2.1)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + '@rolldown/plugin-babel': 0.2.4(@babel/core@8.0.5)(@babel/runtime@7.29.7)(rolldown@1.2.1)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) '@vitest-evals/core@0.15.0': dependencies: @@ -13485,33 +13462,32 @@ snapshots: agent-base@7.1.4: {} - agents@0.20.1(@babel/core@8.0.1)(@babel/runtime@7.29.7)(@cloudflare/workers-types@5.20260731.1)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(@modelcontextprotocol/server@2.0.0)(chat@4.35.0(zod@4.4.3))(just-bash@3.2.0)(react@19.2.8)(rolldown@1.2.1)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(zod@4.4.3): + agents@0.23.0(@babel/core@8.0.5)(@babel/runtime@7.29.7)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(@modelcontextprotocol/server@2.0.0)(chat@4.35.0(zod@4.4.3))(just-bash@3.2.0)(react@19.2.8)(rolldown@1.2.1)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(zod@4.4.3): dependencies: - '@babel/plugin-proposal-decorators': 8.0.2(@babel/core@8.0.1) + '@babel/plugin-proposal-decorators': 8.0.2(@babel/core@8.0.5) '@cfworker/json-schema': 4.1.1 '@modelcontextprotocol/client': 2.0.0 '@modelcontextprotocol/sdk': 1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.3) '@modelcontextprotocol/server': 2.0.0 - '@rolldown/plugin-babel': 0.2.3(@babel/core@8.0.1)(@babel/runtime@7.29.7)(rolldown@1.2.1)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + '@rolldown/plugin-babel': 0.2.4(@babel/core@8.0.5)(@babel/runtime@7.29.7)(rolldown@1.2.1)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + capnweb: 0.12.0 cron-schedule: 6.0.0 esbuild: 0.28.1 mimetext: 3.0.28 nanoid: 5.1.16 - partyserver: 0.5.9(@cloudflare/workers-types@5.20260731.1) partysocket: 1.3.0(react@19.2.8) - react: 19.2.8 + postal-mime: 2.7.5 yaml: 2.9.0 - yargs: 18.1.0 zod: 4.4.3 optionalDependencies: chat: 4.35.0(zod@4.4.3) just-bash: 3.2.0 + react: 19.2.8 vite: 8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) transitivePeerDependencies: - '@babel/core' - '@babel/plugin-transform-runtime' - '@babel/runtime' - - '@cloudflare/workers-types' - rolldown ajv-formats@3.0.1(ajv@8.20.0): @@ -13531,16 +13507,12 @@ snapshots: ansi-regex@5.0.1: {} - ansi-regex@6.2.2: {} - ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 ansi-styles@5.2.0: {} - ansi-styles@6.2.3: {} - ansis@4.3.1: {} anymatch@3.1.3: @@ -13685,7 +13657,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.11.7: {} + baseline-browser-mapping@2.11.23: {} before-after-hook@4.0.0: {} @@ -13781,13 +13753,13 @@ snapshots: - '@aws-sdk/credential-provider-web-identity' - supports-color - browserslist@4.28.7: + browserslist@4.28.9: dependencies: - baseline-browser-mapping: 2.11.7 - caniuse-lite: 1.0.30001806 - electron-to-chromium: 1.5.398 - node-releases: 2.0.51 - update-browserslist-db: 1.2.3(browserslist@4.28.7) + baseline-browser-mapping: 2.11.23 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.427 + node-releases: 2.0.55 + update-browserslist-db: 1.3.3(browserslist@4.28.9) bson@7.3.1: {} @@ -13826,7 +13798,9 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 - caniuse-lite@1.0.30001806: {} + caniuse-lite@1.0.30001810: {} + + capnweb@0.12.0: {} capnweb@0.8.0: {} @@ -13895,12 +13869,6 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 - cliui@9.0.1: - dependencies: - string-width: 7.2.0 - strip-ansi: 7.2.0 - wrap-ansi: 9.0.2 - clsx@2.1.1: {} cluster-key-slot@1.1.2: {} @@ -13951,7 +13919,7 @@ snapshots: cookie@2.0.1: {} - core-js-pure@3.49.0: {} + core-js-pure@3.50.0: {} cors@2.8.6: dependencies: @@ -14290,9 +14258,7 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.398: {} - - emoji-regex@10.6.0: {} + electron-to-chromium@1.5.427: {} emoji-regex@8.0.0: {} @@ -14470,11 +14436,11 @@ snapshots: expect-type@1.4.0: {} - express-rate-limit@8.6.1(express@5.2.1): + express-rate-limit@8.7.0(express@5.2.1): dependencies: debug: 4.4.3 express: 5.2.1 - ip-address: 10.3.1 + ip-address: 10.7.0 transitivePeerDependencies: - supports-color @@ -14658,8 +14624,6 @@ snapshots: get-caller-file@2.0.5: {} - get-east-asian-width@1.6.0: {} - get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -14985,7 +14949,7 @@ snapshots: internmap@2.0.3: {} - ip-address@10.3.1: {} + ip-address@10.7.0: {} ipaddr.js@1.9.1: {} @@ -15949,7 +15913,7 @@ snapshots: node-mock-http@1.0.5: {} - node-releases@2.0.51: {} + node-releases@2.0.55: {} normalize-path@3.0.0: {} @@ -16131,11 +16095,6 @@ snapshots: partial-json@0.1.7: {} - partyserver@0.5.9(@cloudflare/workers-types@5.20260731.1): - dependencies: - '@cloudflare/workers-types': 5.20260731.1 - nanoid: 5.1.16 - partysocket@1.3.0(react@19.2.8): dependencies: event-target-polyfill: 0.0.4 @@ -16950,17 +16909,6 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 - string-width@7.2.0: - dependencies: - emoji-regex: 10.6.0 - get-east-asian-width: 1.6.0 - strip-ansi: 7.2.0 - - string-width@8.2.2: - dependencies: - get-east-asian-width: 1.6.0 - strip-ansi: 7.2.0 - string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -16974,10 +16922,6 @@ snapshots: dependencies: ansi-regex: 5.0.1 - strip-ansi@7.2.0: - dependencies: - ansi-regex: 6.2.2 - strip-json-comments@2.0.1: optional: true @@ -17314,9 +17258,9 @@ snapshots: optionalDependencies: aws4fetch: 1.0.20 - update-browserslist-db@1.2.3(browserslist@4.28.7): + update-browserslist-db@1.3.3(browserslist@4.28.9): dependencies: - browserslist: 4.28.7 + browserslist: 4.28.9 escalade: 3.2.0 picocolors: 1.1.1 @@ -17355,6 +17299,8 @@ snapshots: verkit@0.3.1: {} + verkit@0.3.2: {} + vfile-location@5.0.3: dependencies: '@types/unist': 3.0.3 @@ -17491,12 +17437,6 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 - wrap-ansi@9.0.2: - dependencies: - ansi-styles: 6.2.3 - string-width: 7.2.0 - strip-ansi: 7.2.0 - wrappy@1.0.2: {} ws@8.21.0: {} @@ -17531,15 +17471,6 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 - yargs@18.1.0: - dependencies: - cliui: 9.0.1 - escalade: 3.2.0 - get-caller-file: 2.0.5 - string-width: 8.2.2 - y18n: 5.0.8 - yargs-parser: 22.0.0 - yocto-queue@1.2.2: {} youch-core@0.3.3: From 8d8fe38f76264f420ee3e1a7fa6c6146dcffccca Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Sat, 19 Sep 2026 18:13:46 +0100 Subject: [PATCH 2/2] feat(cloudflare): run each conversation as one Agents SDK state machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Cloudflare coordinator's drive and per-submission attempt runs become one durable state machine per conversation, flue:conversation@v1, at a fixed address. Its idle phase parks on the run's mailbox — holding no alarm — until admission or an abort sends it a wake, then claims the next runnable head from the submission ledger; its turn phase processes that submission to settlement and returns to idle, where the next head is claimed in the same invocation. The ledger stays the record of what was accepted and how it settled; the machine is its executor. Interruption is a replayed turn: a turn re-dispatched without the in-isolate claim memory reconciles the running row from the canonical stream, the same way the attempt run did. A hung turn trips the SDK's transition watchdog, whose cancel transition abandons the attempt and returns the machine to idle. Live turns heartbeat the watchdog until the submission's own timeout, which aborts their work from inside. Aborts are honoured in-isolate at once and durably through the mailbox. --- .../content/docs/guide/cloudflare-target.md | 2 +- .../docs/src/content/docs/guide/durability.md | 6 +- .../src/cloudflare/agent-coordinator.ts | 423 ++++++++---------- .../src/cloudflare/flue-agent-class.ts | 15 +- 4 files changed, 205 insertions(+), 241 deletions(-) diff --git a/apps/docs/src/content/docs/guide/cloudflare-target.md b/apps/docs/src/content/docs/guide/cloudflare-target.md index 7949fd526..307434f7d 100644 --- a/apps/docs/src/content/docs/guide/cloudflare-target.md +++ b/apps/docs/src/content/docs/guide/cloudflare-target.md @@ -107,7 +107,7 @@ dispatch(...) input ──────────────────── The submitting connection observes the work but does not own it. If a client disconnects after admission, backend work can continue. Agent events are durably stored and can be replayed from any offset via the Durable Streams protocol. -Accepted work runs inside the Durable Object itself, on the Agents SDK's durable [Tasks](https://github.com/cloudflare/agents/blob/main/docs/agents/tasks.md) capability. Admission ensures a `flue:drive@v1` run, whose one bounded pass claims the queued submission and starts a `flue:attempt@v1` run for it; that run's handler awaits the full response — every model turn and tool call — and settles the submission. Admission always returns first; the response begins in the same invocation when the object is warm, or on the next alarm tick otherwise. The SDK owns the durable wake while a run is held (a claim backstop that survives eviction), replays the attempt run on a fresh isolate after an interruption, and enforces the submission's [durability timeout](/docs/guide/durability/#retry-budget-and-timeout) as the run's deadline. Three consequences: +Accepted work runs inside the Durable Object itself, as one durable state machine on the Agents SDK's [Tasks](https://github.com/cloudflare/agents/blob/main/docs/agents/tasks.md) engine. Every conversation has one `flue:conversation@v1` run at a fixed address: its `idle` phase parks on the run's mailbox — holding no alarm at all — until admission sends it a wake, then claims the next runnable submission from the durable queue; its `turn` phase awaits that submission's full response — every model turn and tool call — and settles it, and the machine returns to `idle`, where the next queued head is claimed in the same invocation. Admission always returns first; the response begins in the same invocation when the object is warm, or on the next wake otherwise. The SDK owns the durable claim while a turn is live (a backstop that survives eviction), replays the turn on a fresh isolate after an interruption, and bounds a turn that stops making progress with its transition watchdog; the submission's own [durability timeout](/docs/guide/durability/#retry-budget-and-timeout) aborts the turn's work from inside it. Three consequences: - **Platform observability sees agent work.** Workers Logs, Workers Traces, and invocation-wrapping integrations (the Sentry `wrap` pattern in [Extending Agents](#extending-agents-on-cloudflare)) attribute the whole response to the one invocation that ran it. [`createCloudflareTracing()`](#createcloudflaretracing) (installed by default) adds agent-level spans to those traces. See [Observability](/docs/guide/observability/#cloudflare) for the two views and [Deploy on Cloudflare](/docs/ecosystem/deploy/cloudflare/#observability) for enabling them. - **Scheduled callbacks share the alarm with running responses.** The SDK drives due work from one alarm loop and hands a long attempt off after a short dispatch budget, so `schedule()`/`scheduleEvery()` callbacks that come due mid-response still fire — delivery is durable, and timeliness is best-effort while the agent is busy. `queue()` is not alarm-driven and is unaffected. Steering is also unaffected: a message that arrives mid-response still joins it at the next turn boundary. diff --git a/apps/docs/src/content/docs/guide/durability.md b/apps/docs/src/content/docs/guide/durability.md index 7769b4be2..f5c9ff8b5 100644 --- a/apps/docs/src/content/docs/guide/durability.md +++ b/apps/docs/src/content/docs/guide/durability.md @@ -134,10 +134,10 @@ See the [Node.js target guide](/docs/guide/node-target/#state-and-durability) fo ### Cloudflare recovery -On Cloudflare, every agent conversation is a Durable Object with its own SQLite storage, so ownership is structural — the platform guarantees one live instance per conversation, and there is no lease protocol to operate. Execution runs on the Agents SDK's durable Tasks capability, and recovery is wake-driven: +On Cloudflare, every agent conversation is a Durable Object with its own SQLite storage, so ownership is structural — the platform guarantees one live instance per conversation, and there is no lease protocol to operate. Execution is one durable state machine per conversation on the Agents SDK's Tasks engine, and recovery is wake-driven: -- **Wake on start.** Whenever the Durable Object starts — after an eviction, a code deploy, or a platform reset — Flue ensures a drive run, which flags any attempt that was running when the previous instance died and reconciles it before new work starts. The SDK replays the interrupted attempt run itself on the same start, and that replay reconciles from the same evidence. -- **A durable claim.** While an attempt run is held, the SDK keeps a claim deadline in storage and wakes the object at it, so an interrupted submission recovers promptly even if no external request ever arrives. The submission's wall-clock timeout is the run's deadline: the SDK wakes at it, settles the run over an attempt that ignores its abort signal, and fences that attempt's later writes; the next drive then settles the submission through the timeout classification above. A signal-deaf attempt therefore settles at the timeout rather than after a fixed grace. +- **Wake on start.** Whenever the Durable Object starts — after an eviction, a code deploy, or a platform reset — Flue wakes the conversation machine if any submission is unsettled. Its `idle` pass reconciles first: an attempt that was running when the previous instance died is classified from the canonical stream and settled or given a replacement attempt before new work is claimed. A `turn` interrupted mid-response replays on the same start and reconciles from the same evidence. +- **A durable claim.** While a turn is live, the SDK keeps a claim deadline in storage and wakes the object at it, so an interrupted submission recovers promptly even if no external request ever arrives. The submission's wall-clock timeout aborts the turn's work from inside it; a turn that then stops making progress trips the SDK's transition watchdog, which abandons that attempt — its later writes to the conversation are refused — and returns the machine to `idle`, where the submission settles through the timeout classification above. Abort intents, attempt bookkeeping, and settlement records all live in the object's own storage, so an abort requested while the object was evicted is honored on the next wake. See the [Cloudflare target guide](/docs/guide/cloudflare-target/#durable-agent-execution) for the target's execution model. diff --git a/packages/runtime/src/cloudflare/agent-coordinator.ts b/packages/runtime/src/cloudflare/agent-coordinator.ts index b41e5a335..cc621e2cd 100644 --- a/packages/runtime/src/cloudflare/agent-coordinator.ts +++ b/packages/runtime/src/cloudflare/agent-coordinator.ts @@ -59,27 +59,29 @@ export const CLOUDFLARE_AGENT_INTERNAL_DISPATCH_PATH = '/__flue/internal/dispatc export const CLOUDFLARE_AGENT_INTERNAL_INSTANCE_INFO_PATH = '/__flue/internal/instance-info'; /** - * The two Agents SDK Task definitions the generated Durable Object class - * declares on `taskDefinitions` (see `flue-agent-class.ts`). The SDK persists - * only the name per run and resolves it against the class on every wake, so - * an in-flight run always finds the same handler. - * - * `flue:drive@v1` is the supervisor pass: reconcile durable submission state - * and start an attempt run for every claimable head. One run per object, - * joined (not duplicated) by everyone who needs the queue to progress. - * - * `flue:attempt@v1` is one submission's processing, awaited in the handler - * body. One run per submission; the SDK replays it after an interruption and - * enforces its deadline over a hung attempt. + * The one durable state machine that runs a conversation: an `idle` phase + * that waits on the mailbox for a submission or an abort, and a `turn` phase + * that processes one claimed submission to settlement. The run's address is + * fixed per conversation, it is never expected to complete, and the + * submission ledger stays the record of what was accepted and how it + * settled; the machine is its executor. */ -export const FLUE_DRIVE_TASK = 'flue:drive@v1'; -export const FLUE_ATTEMPT_TASK = 'flue:attempt@v1'; -const FLUE_DRIVE_RUN_ID = 'flue:drive'; +export const FLUE_CONVERSATION_TASK = 'flue:conversation@v1'; +const FLUE_CONVERSATION_RUN_ID = 'flue:conversation'; +/** How often a live turn refreshes the engine's transition watchdog. */ +const TURN_HEARTBEAT_MS = 30_000; -function attemptRunId(submissionId: string): string { - return `flue:submission:${submissionId}`; +/** Backoff between idle passes that found unsettled work nothing could claim yet. */ +function deferralBackoffMs(deferrals: number): number { + return Math.min(60_000, 1_000 * 2 ** Math.min(deferrals, 6)); } +type ConversationState = + | { readonly phase: 'idle'; readonly deferrals: number } + | { readonly phase: 'turn'; readonly submissionId: string; readonly attemptId: string }; + +const IDLE: ConversationState = { phase: 'idle', deferrals: 0 }; + import type { SqlStorage } from '../sql-storage.ts'; interface CloudflareAgentStorage { @@ -87,23 +89,27 @@ interface CloudflareAgentStorage { transactionSync?(closure: () => T): T; } -/** The slice of an Agents SDK Task run snapshot the coordinator reads. */ -interface CloudflareTaskRunSnapshot { - readonly state: 'pending' | 'running' | 'waiting' | 'completed' | 'failed' | 'cancelled'; -} +type CloudflareTaskRunState = + | 'pending' + | 'running' + | 'waiting' + | 'completed' + | 'failed' + | 'cancelled'; -/** - * The slice of the Agents SDK `Tasks` capability the coordinator drives. - * Structural, like the rest of this file: `@flue/runtime` never imports - * `agents`; the generated entry supplies the class. - */ +/** The slice of the Agents SDK `Tasks` capability this coordinator uses. */ interface CloudflareAgentTasks { run( definition: string, input: unknown, - options: { runId: string; retain: false; deadline?: number }, + options: { runId: string }, + ): Promise<{ accepted: boolean; state: CloudflareTaskRunState }>; + send( + runId: string, + payload: unknown, + options: { kind: string; requestId: string }, ): Promise<{ accepted: boolean }>; - get(runId: string): Promise; + reopen(runId: string): Promise; } interface CloudflareAgentInstance { @@ -122,15 +128,27 @@ interface CloudflareAgentInstance { readonly tasks: CloudflareAgentTasks; } -/** The slice of an Agents SDK `TaskStep` the Task handler bodies read. */ -export interface CloudflareTaskStep { - /** Aborts for the whole attempt: on `cancel()` and at the run deadline. */ +/** The slice of the Agents SDK machine context the conversation's phases use. */ +export interface CloudflareMachineContext { + /** Aborts for the whole invocation: on `cancel()` and when the watchdog fires. */ readonly signal: AbortSignal; + /** The abort mark inside `onCancel`; null in a phase handler. */ + readonly cancelling: string | null; + receiveAll(filter?: { within?: number }): Promise; + peekAll(): ReadonlyArray<{ readonly key: string }>; + withdraw(key: string): boolean; + heartbeat(): void; + aborted(reason?: string): unknown; } -/** Input of one `flue:attempt@v1` run. */ -export interface CloudflareAttemptTaskInput { - readonly submissionId: string; +/** The machine definition the generated agent class declares for the conversation. */ +export interface CloudflareConversationDefinition { + readonly initial: ConversationState; + readonly phases: { + readonly idle: (state: ConversationState, ctx: CloudflareMachineContext) => Promise; + readonly turn: (state: ConversationState, ctx: CloudflareMachineContext) => Promise; + }; + readonly onCancel: (state: ConversationState, ctx: CloudflareMachineContext) => Promise; } interface CloudflareAgentPreparedCoordinator { @@ -171,25 +189,17 @@ export interface CloudflareAgentRuntime { inherited: () => Promise | unknown, ): Promise; /** - * Handler of the `flue:drive@v1` Task: one bounded, storage-only - * supervisor pass that reconciles durable submission state and starts an - * attempt run for every claimable head, then completes. The single place - * submission attempts start; every other boundary only records durable - * intent and ensures a drive run exists. + * The conversation machine the generated class declares under + * `FLUE_CONVERSATION_TASK`: its `idle` phase waits for work and claims + * one submission, its `turn` phase processes it to settlement, and its + * `onCancel` abandons a hung turn to the ledger's reconciliation. */ - drive(instance: CloudflareAgentInstance, step: CloudflareTaskStep): Promise; + conversationDefinition(instance: CloudflareAgentInstance): CloudflareConversationDefinition; /** - * Handler of the `flue:attempt@v1` Task: process one submission to - * settlement in the handler body. The SDK replays it on a fresh isolate - * after an interruption (the body reconciles from durable evidence - * first) and enforces the submission's durability timeout as the run's - * deadline, settling over an attempt that ignores its signal. + * The SDK recorded a terminal failure of the conversation run — a fault, + * a missing definition after a deploy. The ledger still holds unsettled + * work: bring the run back and wake it so the idle pass reconciles. */ - attempt( - instance: CloudflareAgentInstance, - input: CloudflareAttemptTaskInput, - step: CloudflareTaskStep, - ): Promise; /** * The SDK recorded a terminal Task failure without running (or over) a * handler — a deadline, an exhausted budget, a missing definition. The @@ -240,11 +250,18 @@ export function createCloudflareAgentRuntime( onStart(instance, inherited) { return getCoordinator(instance).onStart(inherited); }, - drive(instance, step) { - return getCoordinator(instance).drive(step); - }, - attempt(instance, input, step) { - return getCoordinator(instance).attempt(input, step); + conversationDefinition(instance) { + // Declared from the generated class's field initializer, which runs + // before `attach` — so the coordinator is looked up when a phase + // runs, never when the definition is built. + return { + initial: IDLE, + phases: { + idle: (state, ctx) => getCoordinator(instance).idle(state, ctx), + turn: (state, ctx) => getCoordinator(instance).turn(state, ctx), + }, + onCancel: (state, ctx) => getCoordinator(instance).onCancel(state, ctx), + }; }, onTaskError(instance, error) { return getCoordinator(instance).onTaskError(error); @@ -330,144 +347,127 @@ class CloudflareAgentCoordinator { // ensure the drive run exists; joining an existing one is free. onStart(inherited: () => Promise | unknown): Promise { return this.runWithInstanceContext(async () => { - // A fresh isolate has no live attempt by definition, so unsettled - // work needs nothing beyond a drive: its reconcile pass classifies - // interrupted attempts directly. Ensure it before the (possibly - // extension-authored) inherited onStart — the durable driver must - // be in place even if extension startup throws. - await this.ensureDriveIfUnsettled(); + await this.wakeIfUnsettled('start'); await inherited(); }); } - /** - * The `flue:drive@v1` handler: one bounded supervisor pass. Reconcile - * durable state (settlement finalization, interrupted-attempt recovery, - * runnable claims) and start an attempt run per claim WITHOUT awaiting - * it — attempt runs settle their submissions themselves and ensure the - * next drive when queued work remains. The pass never waits on agent - * execution, so it completes in bounded time whatever any attempt is - * doing. + * Wait for work, then claim one submission. Mailbox items are wakes, not + * the queue: the ledger decides what runs next, so every item is taken + * and discarded before the ledger is read. With nothing unsettled the run + * parks with no alarm at all; with unsettled work nothing can claim yet + * — a materialization deferred, a settlement pending — it parks on a + * growing backoff that any send cuts short. */ - drive(step: CloudflareTaskStep): Promise { + idle(state: ConversationState, ctx: CloudflareMachineContext): Promise { return this.runWithInstanceContext(async () => { - if (step.signal.aborted) return; - if (!(await this.submissions.hasUnsettledSubmissions())) return; - // The reconcile pass is storage-only and runs under the - // `flue.coordinator` interception so tracing backends can group - // its platform-instrumented storage spans. Attempt runs start - // AFTER the interception settles, deliberately: a run warm-started - // inside the span's activation would re-parent its invoke_agent - // span under the coordinator span. A claim whose start is - // preempted here (crash, code-update reset) is a running row with - // no attempt run — the next drive's running-recovery loop - // reconciles it, and a durable abort in the claim-to-start gap - // resolves through the existing abortRequestedAt path. + for (const item of ctx.peekAll()) ctx.withdraw(item.key); + if (!(await this.submissions.hasUnsettledSubmissions())) { + await ctx.receiveAll(); + return IDLE; + } const claims = await interceptExecution( { type: 'coordinator', phase: 'reconcile' }, { instanceId: this.instance.name, agentName: this.agentName }, () => this.reconcileSubmissions(), ); - for (const claimed of claims) { - try { - await this.ensureAttemptRun(claimed); - } catch (error) { - this.logSubmissionReconciliationFailure(claimed, 'start_submission', error); - } - } - // observe() deliveries are fire-and-forget on the emit path; hand - // whatever this pass emitted (recovered settlements) to the - // platform so the invocation's end can't tear them down mid-POST. this.instance.ctx.waitUntil?.(drainGlobalEventDeliveries()); + const head = claims[0]; + if (head?.attemptId) { + this.startedAttempts.add(head.attemptId); + return { phase: 'turn' as const, submissionId: head.submissionId, attemptId: head.attemptId }; + } + const deferrals = state.phase === 'idle' ? state.deferrals + 1 : 1; + await ctx.receiveAll({ within: deferralBackoffMs(deferrals) }); + return { phase: 'idle' as const, deferrals }; }); } - /** - * The `flue:attempt@v1` handler: process one submission to settlement. - * - * First execution of a claim this isolate made: process it as-is. Any - * other entry — a replay on a fresh isolate, or after the previous - * execution ended — is an interrupted attempt: reconcile the row from - * durable evidence, which settles it or claims a replacement attempt to - * process. A row that is no longer running, or that another live attempt - * already owns, is left alone. Never throws for application failures: - * `processSubmission` settles them durably; a throw here is a platform - * failure the SDK replays. + * Process the claimed submission to settlement. The claim was made by the + * idle pass in this isolate; a turn re-dispatched without that memory is + * a replay after an interruption, and the ledger and canonical stream + * decide whether the submission settled, needs a replacement attempt, or + * is spent. */ - attempt(input: CloudflareAttemptTaskInput, step: CloudflareTaskStep): Promise { + turn(state: ConversationState, ctx: CloudflareMachineContext): Promise { return this.runWithInstanceContext(async () => { - const { submissionId } = input; - // Two passes at most: a lost reconcile race re-reads once, and a - // row that is then still unclaimed belongs to whoever won it. - for (let pass = 0; pass < 2; pass++) { - if (step.signal.aborted) return; - const row = await this.submissions.getSubmission(submissionId); - if (!row || row.status !== 'running' || !row.attemptId) return; - if (this.activeControllers.has(submissionId)) return; - let claimed: AgentSubmission | undefined; - if (this.startedAttempts.delete(row.attemptId)) { - claimed = row; - } else { - claimed = await this.reconcileInterruptedSubmission(row); - if (!claimed?.attemptId) continue; - this.startedAttempts.delete(claimed.attemptId); - } - await this.runAttempt(claimed, step.signal); - return; + if (state.phase !== 'turn') return IDLE; + const row = await this.submissions.getSubmission(state.submissionId); + if (row?.status !== 'running' || row.attemptId !== state.attemptId) return IDLE; + if (!this.startedAttempts.delete(state.attemptId)) { + const replacement = await this.reconcileInterruptedSubmission(row); + if (!replacement?.attemptId) return IDLE; + this.startedAttempts.add(replacement.attemptId); + return { + phase: 'turn' as const, + submissionId: replacement.submissionId, + attemptId: replacement.attemptId, + }; } + await this.runAttempt(row, ctx); + return IDLE; }); } - /** - * Run one claimed attempt in the current handler body, its flue abort - * controller linked to the Task's attempt-wide signal (an SDK - * cancellation or the run deadline unwinds it), and ensure the next - * drive when the submission settled with queued work behind it. Never - * rejects for application failures — `processSubmission` settles them - * durably; a platform failure propagates so the SDK replays the run. + * The abort protocol reached the conversation. A cancel of the run itself + * ends it; the transition watchdog or the memory breaker interrupting a + * hung turn abandons that attempt — its controller aborted, its writer + * rotated so a zombie's appends are refused — and the machine resumes at + * `idle`, where the ledger reconciles the abandoned attempt. */ - private async runAttempt(submission: AgentSubmission, taskSignal: AbortSignal): Promise { + onCancel(state: ConversationState, ctx: CloudflareMachineContext): Promise { + return this.runWithInstanceContext(async () => { + if (state.phase === 'turn') { + const controller = this.activeControllers.get(state.submissionId); + if (controller) { + controller.abort( + ctx.cancelling === 'cancel' ? new SubmissionAbortedError() : new SubmissionTimeoutError(), + ); + this.orphanEnforcedAttempt(state.submissionId, controller); + } + } + if (ctx.cancelling === 'cancel' || ctx.cancelling === 'seal') { + return ctx.aborted(ctx.cancelling); + } + return IDLE; + }); + } + private async runAttempt(submission: AgentSubmission, ctx: CloudflareMachineContext): Promise { const controller = new AbortController(); this.activeControllers.set(submission.submissionId, controller); - const onTaskAbort = () => controller.abort(submissionAbortReason(taskSignal.reason)); - if (taskSignal.aborted) onTaskAbort(); - else taskSignal.addEventListener('abort', onTaskAbort, { once: true }); + const onRunAbort = () => controller.abort(submissionAbortReason(ctx.signal.reason)); + if (ctx.signal.aborted) onRunAbort(); + else ctx.signal.addEventListener('abort', onRunAbort, { once: true }); + const deadline = submission.timeoutAt > 0 ? submission.timeoutAt : undefined; + const deadlineTimer = + deadline === undefined + ? undefined + : setTimeout( + () => controller.abort(new SubmissionTimeoutError()), + Math.max(0, deadline - Date.now()), + ); + // The engine's transition watchdog is the safety net for a turn that + // hangs past its own timeout; until then, a live turn keeps it fed. + const heartbeat = setInterval(() => { + if (deadline !== undefined && Date.now() >= deadline) return; + ctx.heartbeat(); + }, TURN_HEARTBEAT_MS); try { await this.processSubmissionEntry(submission, controller.signal); } finally { - taskSignal.removeEventListener('abort', onTaskAbort); + clearInterval(heartbeat); + if (deadlineTimer !== undefined) clearTimeout(deadlineTimer); + ctx.signal.removeEventListener('abort', onRunAbort); this.deleteControllerIfCurrent(submission.submissionId, controller); - try { - const settled = - (await this.submissions.getSubmission(submission.submissionId))?.status === 'settled'; - if (settled && (await this.submissions.hasUnsettledSubmissions())) { - await this.ensureDrive(); - } - } catch { - // Best-effort: an attempt that ends with its submission still - // unsettled (a failure inside settlement itself) is a running - // row the next drive reconciles. - } - // The attempt's settlement events (submission_settled and any - // subscriber bridge work they trigger) ride this waitUntil. this.instance.ctx.waitUntil?.(drainGlobalEventDeliveries()); } } - - /** - * A terminal Task failure the SDK recorded — a deadline settled over a - * hung attempt, an exhausted budget, a definition missing after a deploy. - * The submission it was processing is still a running row; ensure a - * drive so the reconcile pass classifies it. Application failures never - * reach here as Task failures: the attempt body settles them itself. - */ onTaskError(error: unknown): Promise { return this.runWithInstanceContext(async () => { if (!isTaskRecordedFailure(error)) return; - await this.ensureDriveIfUnsettled(); + await this.wakeIfUnsettled('recover'); }); } - onRequest(request: Request): Promise { return this.runWithInstanceContext(async () => { try { @@ -607,63 +607,32 @@ class CloudflareAgentCoordinator { } /** - * Ensure a drive run exists. One run id per object: a caller whose - * request lands while a drive is live joins it (`accepted: false`), and - * the SDK's durable acceptance is the wake that survives isolate death. - * A drive that completed is gone (`retain: false`), so the next ensure - * starts a fresh one. Warm-started in the caller's invocation whenever - * the object is past startup. + * The conversation's run: one fixed address, joined when it exists and + * brought back to `pending` when a fault ended it, so an admission never + * finds a dead executor. */ - private async ensureDrive(): Promise { - await this.tasks.run(FLUE_DRIVE_TASK, undefined, { - runId: FLUE_DRIVE_RUN_ID, - retain: false, + private async ensureConversation(): Promise { + const receipt = await this.tasks.run(FLUE_CONVERSATION_TASK, undefined, { + runId: FLUE_CONVERSATION_RUN_ID, }); - } - - private async ensureDriveIfUnsettled(): Promise { - if (!(await this.submissions.hasUnsettledSubmissions())) return false; - await this.ensureDrive(); - return true; - } - - /** - * Start (or join) the attempt run for one claimed submission. The claim's - * attempt id is registered before the run is accepted, because a warm - * start enters the handler body in this same invocation. The - * submission's durability timeout becomes the run deadline: the SDK - * wakes at it, settles the run over an attempt that ignores its signal, - * and fences the attempt's later Task writes; the drive then reconciles - * the row through its existing timeout classification. - */ - private async ensureAttemptRun(submission: AgentSubmission): Promise { - if (!submission.attemptId) return; - this.startedAttempts.add(submission.attemptId); - try { - await this.tasks.run( - FLUE_ATTEMPT_TASK, - { submissionId: submission.submissionId } satisfies CloudflareAttemptTaskInput, - { - runId: attemptRunId(submission.submissionId), - retain: false, - ...(submission.timeoutAt > 0 ? { deadline: submission.timeoutAt } : {}), - }, - ); - } catch (error) { - this.startedAttempts.delete(submission.attemptId); - throw error; + if (!receipt.accepted && (receipt.state === 'failed' || receipt.state === 'cancelled')) { + await this.tasks.reopen(FLUE_CONVERSATION_RUN_ID); } } - - /** Whether the SDK still holds a non-terminal attempt run for this submission. */ - private async hasLiveAttemptRun(submissionId: string): Promise { - const run = await this.tasks.get(attemptRunId(submissionId)); - return ( - run !== null && - (run.state === 'pending' || run.state === 'running' || run.state === 'waiting') + /** Wake the conversation: one mailbox item, deduplicated by its key. */ + private async wake(kind: 'submission' | 'abort' | 'wake', key: string): Promise { + await this.ensureConversation(); + await this.tasks.send( + FLUE_CONVERSATION_RUN_ID, + { kind, key }, + { kind, requestId: `${kind}:${key}` }, ); } - + private async wakeIfUnsettled(reason: string): Promise { + if (!(await this.submissions.hasUnsettledSubmissions())) return false; + await this.wake('wake', `${reason}:${Date.now()}`); + return true; + } /** * One reconcile pass: materialize unready submissions, finalize pending * settlements, recover interrupted attempts, enforce deadlines on live @@ -772,7 +741,6 @@ class CloudflareAgentCoordinator { // settlement CAS and attempt-id fences and it can no longer // append through the shared writer. try { - if (await this.hasLiveAttemptRun(submission.submissionId)) continue; const liveController = this.activeControllers.get(submission.submissionId); if (liveController) { liveController.abort( @@ -802,18 +770,21 @@ class CloudflareAgentCoordinator { this.logSubmissionReconciliationFailure(submission, 'reconcile_submission', error); } } - for (const submission of await this.submissions.listRunnableSubmissions()) { - // Cloudflare DOs are single-threaded per instance — leases are - // advisory-only. Set to 0 so reconciliation never misidentifies - // an active submission as expired. The Node coordinator uses real - // lease expiry with heartbeat renewal for multi-process safety. - const claimed = await this.submissions.claimSubmission({ - submissionId: submission.submissionId, - attemptId: generateAttemptId(), - ownerId: this.instance.ctx.id.toString(), - leaseExpiresAt: 0, - }); - if (claimed) toStart.push(claimed); + // One turn at a time: the first runnable head is claimed; the rest + // wait for the idle pass that follows this turn. + if (toStart.length === 0) { + for (const submission of await this.submissions.listRunnableSubmissions()) { + const claimed = await this.submissions.claimSubmission({ + submissionId: submission.submissionId, + attemptId: generateAttemptId(), + ownerId: this.instance.ctx.id.toString(), + leaseExpiresAt: 0, + }); + if (claimed) { + toStart.push(claimed); + break; + } + } } } catch (error) { console.error( @@ -981,8 +952,8 @@ class CloudflareAgentCoordinator { // durability timeout), after which the drive reconciles it aborted. for (const submissionId of affected) { this.activeControllers.get(submissionId)?.abort(new SubmissionAbortedError()); + await this.wake('abort', submissionId); } - await this.ensureDrive(); return true; } @@ -1058,7 +1029,7 @@ class CloudflareAgentCoordinator { const adoptedReceipt = async (submissionId: string) => { const reducedUid = (await loadReducedState()).uid; if (reducedUid === undefined) return undefined; - await this.ensureDrive(); + await this.wake('submission', submissionId); return { submissionId, offset: '-1', @@ -1143,7 +1114,7 @@ class CloudflareAgentCoordinator { ...(deduplicated ? { deduplicated: true as const } : {}), }; } finally { - await this.ensureDrive(); + await this.wake('submission', admitted.submissionId); } } @@ -1193,7 +1164,7 @@ class CloudflareAgentCoordinator { const adopted = await adoptKeyedSubmissionReplay(this.submissions, submissionInput); const adoptedUid = adopted ? (await loadReducedState()).uid : undefined; if (adopted && adoptedUid !== undefined) { - await this.ensureDrive(); + await this.wake('submission', adopted.submissionId); return Response.json({ submissionId: adopted.submissionId, acceptedAt: adopted.input.acceptedAt, @@ -1260,7 +1231,7 @@ class CloudflareAgentCoordinator { ...(deduplicated ? { deduplicated: true } : {}), }); } finally { - await this.ensureDrive(); + await this.wake('submission', submission.submissionId); } } catch (error) { // Structured body so the dispatch() caller's enqueue can rehydrate the diff --git a/packages/runtime/src/cloudflare/flue-agent-class.ts b/packages/runtime/src/cloudflare/flue-agent-class.ts index 8e18b0c97..a65496aff 100644 --- a/packages/runtime/src/cloudflare/flue-agent-class.ts +++ b/packages/runtime/src/cloudflare/flue-agent-class.ts @@ -21,13 +21,7 @@ * `resolveCloudflareExtension`: `base` reshapes the superclass, `wrap` * wraps the final class, and the wrapped class is what gets exported. */ -import { - type CloudflareAgentRuntime, - type CloudflareAttemptTaskInput, - type CloudflareTaskStep, - FLUE_ATTEMPT_TASK, - FLUE_DRIVE_TASK, -} from './agent-coordinator.ts'; +import { type CloudflareAgentRuntime, FLUE_CONVERSATION_TASK } from './agent-coordinator.ts'; import { type ExtensionClass, resolveCloudflareExtension } from './extension.ts'; type CloudflareAgentInstance = Parameters[0]; @@ -97,10 +91,9 @@ export function createFlueAgentClass(options: CreateFlueAgentClassOptions): Exte */ readonly taskDefinitions: Record = { ...inheritedTaskDefinitions(this), - [FLUE_DRIVE_TASK]: (_input: undefined, step: CloudflareTaskStep) => - runtime.drive(this as unknown as CloudflareAgentInstance, step), - [FLUE_ATTEMPT_TASK]: (input: CloudflareAttemptTaskInput, step: CloudflareTaskStep) => - runtime.attempt(this as unknown as CloudflareAgentInstance, input, step), + [FLUE_CONVERSATION_TASK]: runtime.conversationDefinition( + this as unknown as CloudflareAgentInstance, + ), }; onStart(props?: Record) {