Skip to content
Open
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
383a465
feat(lifecycle): add composable scheduler
mattzcarey Aug 25, 2026
7108d59
feat(lifecycle): add capability event bus
mattzcarey Aug 25, 2026
82edca2
refactor(lifecycle): route installed capabilities
mattzcarey Aug 25, 2026
cc3ca36
refactor(lifecycle): add host-callback, startup, and teardown services
mattzcarey Aug 25, 2026
224bc1c
refactor(schedules): dissolve the Scheduler host integration into ser…
mattzcarey Aug 25, 2026
fd26deb
test(schedules): add capability-level coverage; rebind standalone MCP…
mattzcarey Aug 25, 2026
a012767
docs(examples): add the next/schedules example
mattzcarey Aug 25, 2026
19ef63f
Merge remote-tracking branch 'origin/main' into refactor/extract-agen…
mattzcarey Aug 25, 2026
3aee930
test(lifecycle): exercise the Scheduler capability through a real Dur…
mattzcarey Aug 25, 2026
162501a
fix(schedules): restore pre-refactor parity flagged by adversarial re…
mattzcarey Aug 25, 2026
299bda7
test(mcp): add a real-Durable-Object harness for MCP client capabilit…
mattzcarey Aug 25, 2026
682be7f
test: one shared workers project with module-mirrored capability tests
mattzcarey Aug 25, 2026
92a969a
refactor(mcp): make the client/server split real folder structure
mattzcarey Aug 25, 2026
35bb01e
docs(mcp): point source boundaries at the client/server folders
mattzcarey Aug 25, 2026
1892917
chore: format test coverage matrix
mattzcarey Aug 26, 2026
bad7df2
ci: raise the test-job timeout to 35 minutes
mattzcarey Aug 26, 2026
72a5c2e
fix(schedules): restore constructor-time schedule-table creation for …
mattzcarey Aug 26, 2026
f457ebb
fix(schedules): keep the stored schedule DDL byte-identical
mattzcarey Aug 26, 2026
8400a94
fix(tests): keep the shared test worker loadable outside the vitest pool
mattzcarey Aug 26, 2026
115b05c
Merge branch 'main' into refactor/extract-agent-schedules
mattzcarey Aug 26, 2026
0061494
feat(schedules): verify capability host identity at install time
mattzcarey Aug 26, 2026
bb6e84d
Merge remote-tracking branch 'origin/refactor/extract-agent-schedules…
mattzcarey Aug 26, 2026
8d3cd67
refactor(schedules): register scheduled callbacks on Scheduler
mattzcarey Aug 26, 2026
1b9cbb2
docs: mark Lifecycle and capability surfaces @experimental
mattzcarey Aug 26, 2026
77112dd
docs(scheduling): separate Scheduler and Agent API reference
mattzcarey Aug 26, 2026
eaf8d93
refactor(schedules): make Scheduler sync reads internal apertures
mattzcarey Aug 26, 2026
09cc884
refactor(schedules): loud-name Scheduler internal sync reads
mattzcarey Aug 26, 2026
3e8ee2f
docs(lifecycle): purge stale callbacks-service references
mattzcarey Aug 26, 2026
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
8 changes: 8 additions & 0 deletions .changeset/tender-waves-schedule.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"agents": minor
"@cloudflare/think": patch
---

Add `Scheduler`, a reusable Lifecycle capability for persistent delayed, dated, cron, and interval callbacks, under `agents/schedules`. `LifecycleCapability` supplies every capability with storage, readiness, startup state, alarm coordination, host-callback dispatch, best-effort events, and generic capability routing — Scheduler consumes only that standard surface plus policy options, so any host that installs it configures nothing else. Lifecycle owns the physical Durable Object alarm and routes matching capability messages between Agent facets through one internal transport aperture, preserving existing root-owned facet schedule rows without Scheduler-specific Agent RPC methods or an Agent adapter. `Agent` uses the same Scheduler behind its existing APIs and preserves callback context, observability, retries, OOM handling, and alarm behavior. MCP now receives storage from Lifecycle when installed. Explicit destruction disposes live capability resources once, then clears shared Durable Object storage with `deleteAll()`. Think workflow notifications now contribute their wake time through Lifecycle instead of writing the physical alarm directly. The previous `agents/schedule` parser entry point remains as a deprecated compatibility alias.

Compatibility notes: `MCPClientManagerOptions.storage` is removed — the manager receives storage from the Lifecycle it is installed on, so standalone construction with an explicit `DurableObjectStorage` is no longer supported. Scheduled callbacks now receive the documented parsed `Schedule` object as their second argument (previously the raw storage row, whose `payload` was an unparsed JSON string). The internal `_cf_*ForFacet` schedule RPC methods are replaced by the generic `_cf_routeLifecycle` capability aperture; facets always run the same deployed script, so no coordination is required.
2 changes: 1 addition & 1 deletion .github/workflows/pullrequest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ env:

jobs:
ci:
timeout-minutes: 20
timeout-minutes: 35
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
pull-requests: write

if: ${{ github.repository_owner == 'cloudflare' }}
timeout-minutes: 20
timeout-minutes: 35
runs-on: ubuntu-24.04

steps:
Expand Down
3 changes: 2 additions & 1 deletion design/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ Keep it concise. A few paragraphs is fine. These are records, not essays.

| File | Type | Scope |
| --------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `alarm-coordination.md` | design doc | Lifecycle-owned physical alarm, capability contributions, Scheduler, and Agent adaptation |
| `chat-shared-layer.md` | design doc | Chat shared layer — streaming, sanitization, and protocol primitives in agents/chat |
| `durable-streams-comparison.md` | analysis | Durable Streams (ElectricSQL) vs the Agents SDK — layer mapping, what each has, strategic takeaways |
| `think.md` | design doc | Think — chat agent base class, streaming, client tools, resumable streams, extensions |
Expand Down Expand Up @@ -99,7 +100,7 @@ Keep it concise. A few paragraphs is fine. These are records, not essays.
| `rfc-coding-agent.md` | RFC | `CodingAgent` — new `@cloudflare/coding-agent` package (extends AIChatAgent), CLI coding agents in Sandbox, pluggable engine (Cli/Harness), two-lifecycle durability (proposed) |
| `test-coverage-matrix.md` | design doc | Feature × test-layer coverage rollup, CI→layer mapping, skipped-test debt, nightly hygiene |
| `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, host context, identity, and always-hibernating WebSockets |
| `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 |

## Relationship to `/docs`
Expand Down
77 changes: 77 additions & 0 deletions design/alarm-coordination.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Alarm coordination

A Durable Object has one physical alarm timestamp. Lifecycle owns that platform
resource so capabilities do not overwrite one another's wake-ups.

## Domain model

- **Physical alarm** — the single timestamp stored by the Durable Object runtime.
Only Lifecycle reads or writes it.
- **Alarm contribution** — a capability's requested next wake time. It is a
projection of capability-owned durable state, not the work itself.
- **Alarm work** — durable records and processing owned by one capability. Each
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.
- **Host contribution** — temporary support for host work that has not yet been
extracted into a capability.

## How it works

A capability that needs future work implements `getNextAlarm()` and `onAlarm()`.
When it changes durable state, it calls `this.lifecycle.alarms.rearm()` through
the standard service surface supplied by `LifecycleCapability`.

Lifecycle serializes recalculation, reads every capability contribution plus the
host contribution, and sets the physical alarm to the earliest requested time.
An exclusive contribution replaces ordinary wake-time candidates while it
exists; it does not change hook order.

When the platform alarm fires, Lifecycle:

1. starts capabilities and the host if necessary;
2. runs capability `onAlarm()` hooks in registration order;
3. runs host `onAlarm()`;
4. recalculates and rearms the physical alarm.

A failed alarm hook stops the phase and leaves platform retry semantics intact.
Rearm requests made during startup are coalesced and applied after startup, so a
capability can create work in `onStart()` without deadlocking initialization.

## Capability independence

Alarm coordination does not create capability dependencies. Scheduler stores
named callback rows in `cf_agents_schedules`. A future Fiber capability should
store resumable jobs in its own tables and contribute the next recovery wake. An
MCP capability can do the same for reconnect state. Neither needs to insert a
Scheduler row or call Scheduler APIs.

A direct capability dependency remains valid when the domain itself requires
one. Sharing the physical alarm is not such a dependency.

## Agent integration

Agent installs the public Scheduler primitive with policy options only —
retry defaults, hung-interval timeout, and an error observer. Everything else
Scheduler needs (storage, rearm, teardown state, startup state, host-callback
dispatch) arrives through the standard capability services, so there is no
Agent-specific Scheduler adapter. Lifecycle's generic routing surface carries
owner-scoped Scheduler messages to the root and dispatches due callbacks to
facets through one internal Agent transport aperture. Existing facet schedule
rows remain in the root Scheduler table; no data migration or
Scheduler-specific Agent RPC methods are required.

Scheduler publishes telemetry through Lifecycle's event bus. Plain Lifecycle
Objects use the existing diagnostics-channel sink; Agent adapts the same bus to
its existing observability implementation at its composition root.

Agent's host contribution currently covers deferred destruction, keep-alive,
fiber recovery, facet-run checks, and Think's transitional workflow-notification
wake. Those concerns can become capabilities independently without changing the
alarm contract or Scheduler.

## Tradeoffs

Every recalculation queries each contributor, so `getNextAlarm()` should be a
small indexed read with no network I/O. In return, capabilities retain clear
storage ownership and cannot silently clobber each other's alarms.
91 changes: 91 additions & 0 deletions design/durable-object-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,89 @@ Failures stop the phase and propagate. Failed startup remains retryable. Native
RPC methods explicitly call `lifecycle.start()` because native Durable Object
RPC bypasses Lifecycle handlers.

## Alarm ownership and scheduling

Lifecycle owns the one physical Durable Object alarm. A capability can return
its next requested epoch time from `getNextAlarm()` and request recalculation
through `this.lifecycle.alarms.rearm()`. Lifecycle serializes
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.

A host can also implement `getNextAlarm()` for work not yet extracted into a
capability. Exclusive contributions replace ordinary wake-time candidates,
which supports teardown without teaching other capabilities about destroy
semantics; they do not alter alarm hook order. Agent currently uses the host
contribution for deferred destruction, keep-alive, fiber recovery, and facet-run
checks. These can move into separate capabilities without changing Scheduler or
alarm selection.

`Scheduler` is a plain Lifecycle primitive. Its `onStart` hook owns schedule
schema migration, `onAlarm` owns due-row processing, and `getNextAlarm`
contributes its earliest runnable row or hung-interval recheck. Agent constructs
the same Scheduler exposed at `Agent.this.scheduler`; existing Agent scheduling
methods remain compatibility delegators.

Capabilities extending `LifecycleCapability` receive storage, readiness,
startup state, alarm coordination, host-callback dispatch, events, and generic
capability routing. That service surface is the whole contract: Scheduler
consumes only standard services plus its own policy options, and a future
capability composes the same way with no host adapter. Lifecycle routes an
envelope to the matching capability ID at the destination. Agent supplies an
internal facet transport through one generic RPC aperture, so Scheduler routes
owner-scoped CRUD and callbacks without facet-specific methods or an Agent
adapter. Existing facet rows stay in the root Scheduler table.

Host adaptation happens only at a composition root, through three internal
apertures: the capability event sink, the routed-capability transport, and the
host-callback invoker. Agent uses them to route events into its observability
interface, carry envelopes between facets, and wrap capability-invoked user
callbacks in its tracing invocation boundary. A plain Lifecycle Object uses
the defaults and configures nothing.

Capabilities publish best-effort telemetry through
`this.lifecycle.events.emit()`. Lifecycle sends plain Lifecycle Object events to
the existing diagnostics channels. Agent adapts the terminal sink to its
existing observability implementation. The bus is not durable; a capability
that requires guaranteed delivery owns an outbox.

Alarm contribution, capability hooks, and capability-event delivery run outside
ambient host context.
Scheduled methods are user callbacks, so Scheduler invokes them in Lifecycle
Object or Agent context as appropriate.

## Testing capabilities

Capabilities are testable at every layer without module mocks or fake
services. Harness Durable Objects live one-per-capability in `tests/capabilities/`, with
their pool-only drivers in `tests/shared/` (see `tests/capabilities/AGENTS.md`), so every capability follows the same pattern:

1. **Pure domain logic** (timing parsers, selection rules) lives in
dependency-free modules and is unit tested directly, e.g.
`tests/schedules/timing.test.ts`.
2. **The capability in isolation, on a real Durable Object** —
`withCapabilityHarness()` binds per-test-constructed capabilities to a
real Lifecycle over real SQLite storage inside a bare harness object
(the MCP client suites), while a capability whose tests need real
platform dispatch gets a dedicated harness object with runtime handlers
installed — `SchedulerHarnessObject` driven through real alarms in
`tests/schedules/capability.test.ts`. The Workers vitest pool
makes real objects cheap, so there is no fake-services seam to keep in
sync with Lifecycle semantics.
3. **Lifecycle integration** proves cross-capability behavior: alarm
arbitration across contributors, phase order, context boundaries, and
eviction recovery — the per-functionality files in `tests/lifecycle/`.
4. **Host surface** tests exercise the capability through the complete
`Agent` class and its public API — `tests/schedule.test.ts`, the MCP
agent suites, and the think/ai-chat packages.

A new capability should arrive with layers 2 and 3; layer 1 applies when it
owns non-trivial pure rules.

## Host context

Lifecycle owns the AsyncLocalStorage read by `getCurrentAgent()`. The accessor
Expand All @@ -51,6 +134,13 @@ any inherited current-Agent context before invoking capability startup,
request, or alarm hooks, so behavior does not depend on the entrypoint that
triggered the phase.

User callbacks are the exception: when a capability invokes a named host
method through `this.lifecycle.callbacks.invoke()`, Lifecycle establishes the
host invocation context around that call. This is the one boundary a host
composition root may wrap — Agent substitutes its tracing invocation scope —
so every capability that dispatches user callbacks inherits the host's
invocation semantics without capability-specific hooks.

## Agent entry surfaces

The `Agent` class has entry surfaces beyond Lifecycle, including native Durable
Expand Down Expand Up @@ -79,4 +169,5 @@ a migration fallback. It never writes a duplicate name.

## History

- [Alarm coordination](./alarm-coordination.md)
- [Durable Object lifecycle composition](./rfc-durable-object-lifecycle.md)
20 changes: 12 additions & 8 deletions design/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,22 +35,26 @@ The tradeoff is one additional import and direct peer installation. That is pref

## Source boundaries

All server-side modules live in `src/mcp/server/`; the client manager and its
supporting modules live in `src/mcp/client/`; `types.ts`, `rpc.ts`, and
`abort.ts` are shared at the module root.

### Stateless

- `server.ts` — public, tree-shakeable entry.
- `handler-stateless.ts` — Worker route, CORS, Host/Origin policy, auth context, and dispatch between Stateless and Legacy compatibility requests.
- `handler-legacy-compat.ts` — per-request SDK v2 transport for Legacy compatibility, including fail-fast reverse requests and close tracking. SSE keepalives are owned by the SDK transport.
- `server/index.ts` — public, tree-shakeable entry (`agents/mcp/server`).
- `server/handler-stateless.ts` — Worker route, CORS, Host/Origin policy, auth context, and dispatch between Stateless and Legacy compatibility requests.
- `server/handler-legacy-compat.ts` — per-request SDK v2 transport for Legacy compatibility, including fail-fast reverse requests and close tracking. SSE keepalives are owned by the SDK transport.

### Legacy

- `legacy-agent.ts` — deprecated, feature-frozen `McpAgent` implementation.
- `handler-legacy.ts` — explicit SDK v1 handler.
- `worker-transport.ts` — SDK v1 Worker transport with session persistence; SDK v1 owns its SSE keepalives.
- `transport.ts` and `event-store.ts` — sessionful McpAgent transport and replay support. The WebSocket-to-SSE bridge retains the only Agents-owned MCP keepalive timer.
- `server/legacy-agent.ts` — deprecated, feature-frozen `McpAgent` implementation.
- `server/handler-legacy.ts` — explicit SDK v1 handler.
- `server/worker-transport.ts` — SDK v1 Worker transport with session persistence; SDK v1 owns its SSE keepalives.
- `server/transport.ts` and `server/event-store.ts` — sessionful McpAgent transport and replay support. The WebSocket-to-SSE bridge retains the only Agents-owned MCP keepalive timer.

### Compatibility

- `handler-compat.ts` — historical overloaded `createMcpHandler`; functions remain available from `agents/mcp` without making the Stateless entry retain Legacy modules.
- `server/handler-compat.ts` — historical overloaded `createMcpHandler`; functions remain available from `agents/mcp` without making the Stateless entry retain Legacy modules.
- `index.ts` — compatibility barrel. It contains no implementation.

## Lifecycles
Expand Down
Loading
Loading