Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/young-buses-replay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"agents": patch
---

Add `agents/fibers`: durable, replayable background execution as a Lifecycle capability (experimental).

One `Fibers` instance per Durable Object owns any number of named Fiber definitions created with `fibers.create(name, run)`. A run survives process loss and deployments by replaying its handler from the top: completed `step.do()` steps return journaled results, `step.sleep()` / `step.sleepUntil()` consult persisted deadlines, and execution continues from the first unfinished step under generation fencing. Steps carry per-attempt retry and timeout policy, stable idempotency keys for external deduplication, and `step.status()` progress with a replay live gate that never re-publishes old progress as new.

Runs are durably accepted (`fiber.run()` returns a receipt; idempotency keys join existing runs), inspectable (`get`, `getByIdempotencyKey`, `list`), cooperatively cancellable, and retained until deleted. The capability follows the Lifecycle alarm-contribution model: it stores run deadlines in its own tables, contributes the earliest through `getNextAlarm()`, and never touches the physical alarm, so it composes with the Scheduler and other capabilities on one shared alarm. Design record: `design/rfc-fibers.md`.
1 change: 1 addition & 0 deletions design/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ Keep it concise. A few paragraphs is fine. These are records, not essays.
| `mcp.md` | design doc | Stateless, Legacy compatibility, Legacy sessionful, client, package boundary, and conformance architecture |
| `durable-object-lifecycle.md` | design doc | Lifecycle Objects, capability and host phases, Scheduler/alarm ownership, host context, identity, and always-hibernating WebSockets |
| `rfc-durable-object-lifecycle.md` | RFC | Constructor-composed Durable Object lifecycle with reusable components and always-hibernating WebSockets |
| `rfc-fibers.md` | RFC | Fibers — durable replayable execution as one Lifecycle capability: named definitions, journaled steps, sleeps, optional recovery callback (accepted, amended) |

## Relationship to `/docs`

Expand Down
4 changes: 4 additions & 0 deletions design/alarm-coordination.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ resource so capabilities do not overwrite one another's wake-ups.
capability keeps its own schema, retry policy, and recovery semantics.
- **Scheduler** — the capability for persistent named callbacks. It is one alarm
contributor, not the general alarm service.
- **Fibers** — the capability for durable replayable execution. Every
non-terminal run carries an authoritative `next_at` deadline (acceptance,
sleeps, retries, and claim backstops all write it), and the capability
contributes the minimum as one more ordinary contribution.
- **Host contribution** — temporary support for host work that has not yet been
extracted into a capability.

Expand Down
9 changes: 6 additions & 3 deletions design/durable-object-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,11 @@ recalculation, chooses the earliest contribution, runs every capability's
`onAlarm()` followed by the host's `onAlarm()`, then recalculates once more.

Capabilities own their durable work. Scheduler stores named callback rows in
its table; a future Fiber capability can store resumable jobs in its own table;
an MCP capability can store reconnect state in its own table. They coordinate
only through Lifecycle's alarm contract and do not depend on Scheduler.
its table; the Fibers capability stores replayable runs and step journals in
its own tables and contributes its earliest run deadline the same way
([rfc-fibers.md](./rfc-fibers.md)); an MCP capability can store reconnect
state in its own table. They coordinate only through Lifecycle's alarm
contract and do not depend on Scheduler.

A host can also implement `getNextAlarm()` for work not yet extracted into a
capability. Exclusive contributions replace ordinary wake-time candidates,
Expand Down Expand Up @@ -177,3 +179,4 @@ a migration fallback. It never writes a duplicate name.

- [Alarm coordination](./alarm-coordination.md)
- [Durable Object lifecycle composition](./rfc-durable-object-lifecycle.md)
- [Fibers: durable replayable execution as a Lifecycle capability](./rfc-fibers.md)
2,739 changes: 2,739 additions & 0 deletions design/rfc-fibers.md

Large diffs are not rendered by default.

168 changes: 168 additions & 0 deletions docs/agents/fibers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
# Fibers

> **Experimental.** Everything exported from `agents/fibers` may change
> between releases while the durable execution surface stabilizes.

`agents/fibers` adds durable, replayable background work to a [Lifecycle
Object](./lifecycle.md). One `Fibers` capability owns any number of named
Fiber definitions. A run of a definition survives process loss, deployments,
and hibernation: completed steps return journaled results, sleeps consult
persisted deadlines, and execution continues from the first unfinished step.

Fibers never touch the Durable Object's physical alarm. The capability
contributes its earliest deadline and `Lifecycle` arms one shared alarm, so
Fibers, the [Scheduler](./scheduling.md), and other capabilities coexist on
the same object.

## Install and define

Construct the capability with the host, register definitions in field
initializers, and install it with the lifecycle:

```ts
import { DurableObject } from "cloudflare:workers";
import { Fibers } from "agents/fibers";
import { Lifecycle } from "agents/lifecycle";

interface ReportInput {
reportId: string;
topic: string;
}

interface ReportResult {
reportId: string;
objectKey: string;
}

export class ReportObject extends DurableObject<Env> {
readonly fibers = new Fibers(this);

readonly buildReport = this.fibers.create<ReportInput, ReportResult>(
"build-report@v1",
async (input, step) => {
await step.status("Researching");

const research = await step.do(
"research",
{ retries: { limit: 4, delay: "2 seconds", backoff: "exponential" } },
({ signal }) => this.research(input.topic, { signal })
);

await step.sleep("editorial-delay", "30 seconds");
await step.status("Publishing");

const objectKey = `reports/${input.reportId}.json`;
await step.do("publish", ({ idempotencyKey }) =>
this.publish(objectKey, research, { idempotencyKey })
);

return { reportId: input.reportId, objectKey };
}
);

readonly lifecycle = Lifecycle.install(this).use(this.fibers);
}
```

Definitions are registered in memory on every Durable Object wake; storage
persists only the definition name. Register definitions synchronously during
construction — `create()` throws once the lifecycle has started. Version the
name (`"build-report@v2"`) instead of changing an in-flight definition's step
layout.

## Starting runs

```ts
const receipt = await this.buildReport.run(input, {
idempotencyKey: `report:${input.reportId}`
});
```

`run()` durably accepts the work and returns a receipt without waiting for
completion. The same `idempotencyKey` (or a caller-selected `runId`) joins
the existing run instead of creating a second one; `accepted: false` on the
receipt marks that join. Pass `metadata` to retain JSON alongside the run and
`retain: false` to remove the record after successful completion.

Inputs, step results, metadata, and final results must be JSON-serializable
and at most 1 MiB serialized.

## The step API

| Method | Behavior |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `step.do(name, config?, cb)` | Run a named step once; journaled results replay without re-executing. `config` sets `retries` and `timeout`. |
| `step.sleep(name, duration)` | Persist a wake deadline and suspend; no isolate stays resident while waiting. |
| `step.sleepUntil(name, when)` | Sleep until a wall-clock time. |
| `step.status(message)` | Update observable progress; replays stay silent over old ground. |
| `step.idempotencyKey(name)` | The stable external deduplication key `step.do(name, …)` receives. |

Each `do` attempt receives `{ attempt, idempotencyKey, signal }`. The signal
aborts on cancellation and on the attempt timeout (default 5 minutes); a
callback that ignores it still loses the attempt, and a stale attempt's late
writes are rejected.

A callback that throws retries on a durable delay (default: 5 attempts,
exponential backoff). Throw `NonRetryableError` to fail the run immediately.

## Replay semantics

On every execution attempt the handler runs again from its first line.
Therefore:

- put every externally visible side effect inside a `step.do()`;
- keep code between steps deterministic and cheap — capture `Date.now()` or
randomness as a step result before branching on it;
- give loop steps stable names (`` `import:${index}` ``);
- treat execution as at-least-once: an interrupted step runs again, so pass
the attempt's `idempotencyKey` to external systems that support
deduplication.

If a replay observes a journal its code cannot have written — a known step
name under a different kind, or a name used twice — the run fails with a
`FiberReplayDivergedError` or `DuplicateFiberStepError` rather than guessing.
A run whose definition name is no longer registered after a deployment fails
with a `MissingFiberDefinitionError`; it is never silently deleted or run
against a different handler.

## Inspection and control

```ts
const snapshot = await this.buildReport.get(receipt.runId);
const joined = await this.buildReport.getByIdempotencyKey("report:42");
const recent = await this.fibers.list({ definition: "build-report@v1" });
await this.fibers.cancel(receipt.runId, "superseded");
await this.fibers.delete({ settledBefore: new Date(Date.now() - 86_400_000) });
```

A snapshot is discriminated by `state`:

| State | Meaning |
| ----------- | ------------------------------------------------------------------ |
| `pending` | Accepted, first attempt not yet claimed. |
| `running` | An attempt is executing (`attempt`, `startedAt`, `statusMessage`). |
| `waiting` | Parked on a durable deadline (`reason` is `sleep` or `retry`). |
| `completed` | Settled with `result`. |
| `failed` | Settled with a safe `error` projection. |
| `cancelled` | Settled by cancellation, with its optional `reason`. |

Cancellation is cooperative: a parked run settles immediately, a live attempt
is aborted through its signal and settles at its next step boundary. An
external effect already accepted cannot be undone.

## Choosing an API

| Requirement | Use |
| ---------------------------------------------------------------- | -------------------------------------- |
| Normal request handling or short async work | ordinary `await` |
| Wake a named callback at a time or cron cadence | [scheduling](./scheduling.md) |
| Durable object-local background work with steps, retries, sleeps | a Fiber |
| Cross-service orchestration with a managed dashboard | [Cloudflare Workflows](./workflows.md) |

## Current limits

The first release is deliberately narrow: no custom recovery callback beside
the run handler, no `waitForCompletion` mode on `run()`, no automatic
`this.fibers` on `Agent`, and no runs on routed sub-agents. The design and
its planned phases are recorded in
[`design/rfc-fibers.md`](https://github.com/cloudflare/agents/blob/main/design/rfc-fibers.md).
1 change: 1 addition & 0 deletions docs/agents/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ The differentiator is not "we have durable state" — it is what happens when a
## Reference

- [Durable Object Lifecycle](./lifecycle.md) - Compose reusable durable components outside the Agent base class
- [Fibers](./fibers.md) - Durable, replayable background work with journaled steps and durable sleeps (experimental)
- TODO: [API Reference](./api-reference.md) - Complete API documentation
- TODO: [FAQ / How is this different from Durable Objects?](./faq.md)
- TODO: [Resources & Further Reading](./resources.md)
Expand Down
5 changes: 5 additions & 0 deletions packages/agents/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@
"import": "./dist/client.js",
"require": "./dist/client.js"
},
"./fibers": {
"types": "./dist/fibers/index.d.ts",
"import": "./dist/fibers/index.js",
"require": "./dist/fibers/index.js"
},
"./lifecycle": {
"types": "./dist/lifecycle/index.d.ts",
"import": "./dist/lifecycle/index.js",
Expand Down
1 change: 1 addition & 0 deletions packages/agents/scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const entries = [
"src/observability/ai/index.ts",
"src/schedules/index.ts",
"src/schedules/parser.ts",
"src/fibers/index.ts",
"src/codemode/ai.ts",
"src/experimental/memory/session/index.ts",
"src/experimental/memory/utils/index.ts",
Expand Down
55 changes: 55 additions & 0 deletions packages/agents/src/fibers/duration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Duration parsing for the Fibers capability. Durations appear in step
* retry delays, per-attempt timeouts, and durable sleeps.
*/

/** Units accepted in a {@link FiberDurationString}. */
export type FiberDurationUnit = "second" | "minute" | "hour" | "day" | "week";

/**
* A human-readable duration such as `"10 seconds"` or `"1 day"`.
*
* @experimental The API surface may change before stabilizing.
*/
export type FiberDurationString = `${number} ${FiberDurationUnit}${"" | "s"}`;

const UNIT_MILLISECONDS: Record<FiberDurationUnit, number> = {
second: 1000,
minute: 60 * 1000,
hour: 60 * 60 * 1000,
day: 24 * 60 * 60 * 1000,
week: 7 * 24 * 60 * 60 * 1000
};

const DURATION_PATTERN = /^(\d+(?:\.\d+)?)\s+(second|minute|hour|day|week)s?$/;

/**
* Parse a duration into whole milliseconds.
*
* @param duration - Milliseconds, or a duration string such as `"10 seconds"`.
* @param context - Name of the option being parsed, used in error messages.
* @returns The duration in milliseconds, floored to an integer.
* @throws Error when the duration is negative, not finite, or unparseable.
*/
export function parseFiberDuration(
duration: number | FiberDurationString,
context: string
): number {
if (typeof duration === "number") {
if (!Number.isFinite(duration) || duration < 0) {
throw new Error(
`Invalid ${context}: expected a non-negative number of milliseconds, got ${duration}`
);
}
return Math.floor(duration);
}
const match = DURATION_PATTERN.exec(duration.trim());
if (!match) {
throw new Error(
`Invalid ${context}: expected milliseconds or a duration like "10 seconds", got ${JSON.stringify(duration)}`
);
}
const amount = Number(match[1]);
const unit = match[2] as FiberDurationUnit;
return Math.floor(amount * UNIT_MILLISECONDS[unit]);
}
Loading
Loading