Skip to content

feat(lifecycle): add composable Scheduler - #1897

Open
mattzcarey wants to merge 22 commits into
mainfrom
refactor/extract-agent-schedules
Open

feat(lifecycle): add composable Scheduler#1897
mattzcarey wants to merge 22 commits into
mainfrom
refactor/extract-agent-schedules

Conversation

@mattzcarey

@mattzcarey mattzcarey commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Add Scheduler, a reusable Lifecycle capability for persistent delayed, dated, cron, and interval callbacks, under agents/schedules.

A plain Lifecycle Object installs it with no wiring:

import { DurableObject } from "cloudflare:workers";
import { Lifecycle } from "agents/lifecycle";
import { Scheduler, type Schedule } from "agents/schedules";

export class ReminderObject extends DurableObject<Env> {
  readonly scheduler = new Scheduler(this);
  readonly lifecycle = Lifecycle.install(this).use(this.scheduler);

  async createReminder(message: string): Promise<string> {
    const schedule = await this.scheduler.set(300, "sendReminder", { message });
    return schedule.id;
  }

  sendReminder(payload: { message: string }, schedule: Schedule<{ message: string }>): void {
    console.log(schedule.id, payload.message);
  }
}

The host argument is optional and cannot lie: passing it types set()/every() against the host's methods and Lifecycle.use() verifies at install time that it is the same object that owns the Lifecycle, so compile-time callback typing and the runtime dispatch target can never diverge; omitting it gives string-typed scheduling against whichever host the Scheduler is installed on.

Agent constructs and installs the same primitive at this.scheduler, passing only policy (retry defaults, hung-interval timeout, error routing). Existing this.schedule(), scheduleEvery(), get/list/cancel, retries, observability, sub-agent routing, OOM handling, and callback behavior remain compatible delegators.

Capability services: the composition contract

LifecycleCapability grants every installed capability one standard service surface, and it is the whole contract — a capability configures nothing else:

  • storage — the Durable Object's storage; each capability owns its own tables.
  • ready() / starting() — startup coordination and state.
  • alarms.rearm() / alarms.disabled() — shared-alarm coordination and teardown state.
  • callbacks.has() / callbacks.invoke() — named host-callback dispatch through the host invocation boundary.
  • events.emit() — best-effort telemetry under the capability's stable identity.
  • routes — generic capability message routing between Lifecycles.

Host-specific bindings and protocol adapters stay explicit constructor dependencies. Scheduler is the first capability built purely on this surface; MCPClientManager now receives storage the same way.

Lifecycle-owned alarm

Lifecycle owns the single physical Durable Object alarm. A capability keeps its durable work in its own tables and optionally implements getNextAlarm() / onAlarm(); when its durable state changes it calls lifecycle.alarms.rearm(). Lifecycle serializes recalculation, reads contributions from all capabilities plus the host, and arms the earliest time. An exclusive contribution ({ time, exclusive: true }) replaces ordinary candidates while present — Agent uses this for pending teardown.

When the platform alarm fires, Lifecycle starts capabilities and the host if necessary, runs capability onAlarm() hooks in registration order, runs host onAlarm(), then rearms. Rearm requests during startup are coalesced. This is deliberately not a dependency on Scheduler: a future Fiber or MCP capability owns its own tables and projects its earliest required wake through the same contract.

Host adaptation happens only at a composition root

Three internal apertures adapt a Lifecycle to its host; a plain Lifecycle Object configures none of them:

  • event sink — Agent routes capability events into its existing observability interface; plain objects publish to the existing agents:* diagnostics channels.
  • route transport — Agent carries owner-scoped envelopes between facets through one generic RPC aperture; Scheduler routes facet CRUD and due-callback dispatch over it without facet-specific methods. Existing facet rows stay in the root table.
  • host invoker — Agent wraps capability-invoked user callbacks (scheduled methods today, future capability callbacks tomorrow) in its tracing invocation boundary, so they get the same span scope as every other Agent entry point.

There is no Agent-specific Scheduler adapter.

Domain boundaries

  • Lifecycle: physical alarm selection, serialization, dispatch, rearming, host-callback boundary, capability-event publication.
  • agents/schedules/schedule-timing: pure timing rules — parsing when inputs and interval bounds into a ScheduleTiming.
  • Scheduler: schedule schema/migration, CRUD with owner scoping, idempotency, retries, due-row processing, event production, next-wake contribution. Public surface: set/every (typed), schedule/scheduleEvery (string-name), get, list, cancel, plus deprecated synchronous reads for Agent compatibility.
  • Agent composition root: policy options and the three apertures above.
  • Host contribution: transitional alarm work not yet extracted into a capability (keep-alive, fiber recovery, facet checks, Think workflow notifications).

Only Lifecycle calls setAlarm() / deleteAlarm() in production package code.

Context

Capability hooks, alarm-contribution reads, and event delivery run outside ambient host context. User callbacks invoked through lifecycle.callbacks.invoke() run inside the host invocation context — Lifecycle Object context standalone, Agent context (with tracing scope) under Agent.

Test structure

Tests mirror source modules (src/<module>src/tests/<module>) inside one shared workers vitest project — the previous standalone Lifecycle project (own vitest config, wrangler, worker) is gone; its plain-worker routing is exercised by calling routeAgentRequest(request, env, { props }) directly.

  • tests/lifecycle/ — Lifecycle core, one file per functionality: runtime handlers, startup (including new failure-retry, use-after-start, duplicate-ID, and unbound-capability coverage), alarm arbitration, capability events, capability routing, host context, hibernating WebSockets, identity, disposal.
  • tests/<module>/capability.test.ts — each capability's contract tests (tests/schedules/, tests/mcp/client-capability.test.ts).
  • tests/capabilities/ — harness Durable Objects, one file per capability (harness.ts generic, lifecycle.ts, scheduler.ts, mcp-client.ts), the lower-level sibling of tests/agents/; see its AGENTS.md for the pattern.
  • tests/shared/ — generic drivers (captureDiagnosticsEvents, captureConsoleWarnings).

Testing

Capability tests follow one repeatable pattern designed for a growing roster of capabilities. Four layers, no module mocks, no fake services for capability behavior:

  1. Pure unitstests/schedules/timing.test.ts.
  2. Capability alonetests/schedules/capability.test.ts installs Scheduler on SchedulerHarnessObject, a minimal real Durable Object, and drives real Lifecycle startup, real storage, real platform alarms (runDurableObjectAlarm), host context inside callbacks, and the real diagnostics sink. This is the pattern for unit testing any capability.
  3. Lifecycle integration — alarm arbitration across contributors, phase order, context boundaries, routing, identity, hibernating WebSockets, eviction recovery (real evictDurableObject()).
  4. Host surface — the full Agent scheduling suites.

The standalone MCP manager suites (previously hand-rolled DurableObjectStorage mocks with string-matching fake SQL) now run on the same pattern: withCapabilityHarness() binds per-test-constructed managers to a real Lifecycle over real SQLite storage inside a bare CapabilityHarnessObject, with fresh managers over the same storage simulating hibernation wake-ups. Only explicit constructor dependencies (fetch stubs, OAuth provider fakes) remain mocked.

Example

examples/next/schedules — a server-only example installing Scheduler on a plain DurableObject: typed set(), cron and delayed reminders, list/cancel over HTTP, and a scheduled callback that runs with host context and records delivered reminders in the host's own table. Verified live under wrangler dev.

Package boundaries and module structure

  • agents/schedules — dependency-light Scheduler primitive and runtime types (no Zod).
  • agents/schedules/parser — Zod-based natural-language parsing helpers.
  • agents/schedule — deprecated parser compatibility alias.
  • src/mcp is no longer flat: the already-public ./mcp/client and ./mcp/server split is now real folder structure (src/mcp/client/, src/mcp/server/, shared types/rpc at the root). Public import paths are unchanged; only build entry points and dist layout moved.

Recovery and compatibility

Declared compatibility changes (also in the changeset):

  • 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 aperture (facets always run the same deployed script).
  • Capability events emitted during startup are buffered and delivered after startup completes.

A brand-new Agent still creates cf_agents_schedules synchronously at construction — ensureScheduleTable is shared between Agent's schema initialization and Scheduler.onStart — so pre-startup synchronous reads and deploy-rollback windows behave exactly as on main (regression-tested).

Preserved behavior includes one-shot/cron/interval semantics; idempotent creation and lost-alarm rearming; callback retries and platform-transient deferral; alarm memory-limit circuit breaking; hung-interval recovery and duplicate warnings; sub-agent owner isolation and root callback routing; schedule migration/data preservation; keep-alive, fiber, facet-run, Think notification, and deferred-destroy alarm arbitration; explicit top-level destroy cleanup; existing Agent observability overrides and diagnostics-channel routing.

Verification

On the final branch state (each suite run in isolation):

  • Agent Workers: 1,892/1,892 across 107 files (includes the new lifecycle-core, capability, and timing suites)
  • Think Workers: 886/886
  • AI Chat Workers: 655/655
  • repository typecheck: all projects (previously-broken standalone MCP test typings fixed)
  • package build, exports, formatting, lint, sherif: green
  • multi-agent adversarial review of the refactor diff (six lenses, two verifiers per finding): 16 candidate findings, 10 confirmed, all fixed — including reverting a constructor-time retry validation that would have bricked Durable Objects with historically tolerated invalid retry configs
  • examples/next/schedules exercised live under wrangler dev (create → alarm delivery with host context → cancel)

@changeset-bot

changeset-bot Bot commented Jul 8, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bb6e84d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
agents Minor
@cloudflare/think Patch
@cloudflare/agent-think Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jul 8, 2026

Copy link
Copy Markdown

Open in StackBlitz

agents

npm i https://pkg.pr.new/cloudflare/agents@1897

@cloudflare/ai-chat

npm i https://pkg.pr.new/cloudflare/agents/@cloudflare/ai-chat@1897

@cloudflare/channels

npm i https://pkg.pr.new/cloudflare/agents/@cloudflare/channels@1897

@cloudflare/codemode

npm i https://pkg.pr.new/cloudflare/agents/@cloudflare/codemode@1897

hono-agents

npm i https://pkg.pr.new/cloudflare/agents/hono-agents@1897

@cloudflare/shell

npm i https://pkg.pr.new/cloudflare/agents/@cloudflare/shell@1897

@cloudflare/think

npm i https://pkg.pr.new/cloudflare/agents/@cloudflare/think@1897

@cloudflare/voice

npm i https://pkg.pr.new/cloudflare/agents/@cloudflare/voice@1897

@cloudflare/worker-bundler

npm i https://pkg.pr.new/cloudflare/agents/@cloudflare/worker-bundler@1897

commit: bb6e84d

@mattzcarey
mattzcarey force-pushed the feat/standardise-agent-lifecycle branch 3 times, most recently from 08cfca1 to 1255c40 Compare August 25, 2026 11:14
Base automatically changed from feat/standardise-agent-lifecycle to main August 25, 2026 11:27
@mattzcarey
mattzcarey force-pushed the refactor/extract-agent-schedules branch from dc270cc to f7ca736 Compare August 25, 2026 14:30
@mattzcarey mattzcarey changed the title refactor(agents): extract schedules into agents/schedules feat(lifecycle): add composable Scheduler Aug 25, 2026
@mattzcarey
mattzcarey force-pushed the refactor/extract-agent-schedules branch from f7ca736 to 383a465 Compare August 25, 2026 15:23
Capabilities now receive lifecycle.callbacks (named host-callback dispatch
through one overridable invocation boundary), lifecycle.starting(), and
lifecycle.alarms.disabled() alongside storage, readiness, events, and
routes. bindLifecycleCapability() is public so capability unit tests can
bind fake services through the same seam Lifecycle.use() uses. Also removes
the unreachable local-dispatch branch from routes.to().
…vices

Scheduler now consumes only the standard capability services plus policy
options (retry, hungScheduleTimeoutSeconds, onError). The 15-member
SchedulerIntegration adapter, its WeakMap installer, and createScheduler()
are gone: callback invocation goes through Lifecycle's host-callback
boundary (Agent overrides it once, at its composition root, to apply its
tracing invocation scope), teardown checks read alarms.disabled(), and the
non-idempotent-onStart warning is universal Scheduler behavior driven by
lifecycle.starting(). A pure schedule-timing module replaces the four
duplicated insert branches with one parse-then-insert path, and the public
surface shrinks to schedule/set, scheduleEvery/every, get, list, cancel
plus the deprecated synchronous reads. Also removes a dead storage guard in
the MCP client capability.
… tests

New layers under the Lifecycle test pyramid: pure schedule-timing unit
tests, and a Scheduler capability suite that binds fake Lifecycle services
over real Durable Object storage via bindLifecycleCapability(). The
onStart-warning probes now assert the observable console warning instead of
Scheduler internals. Standalone MCPClientManager tests bind the same fake
services instead of the removed storage option, fixing the 145 test
failures and type errors the capability migration left behind.
A server-only example installing Scheduler on a plain DurableObject:
typed set() creation, cron and delayed reminders, list/cancel over HTTP,
and a scheduled callback that runs with host context and records delivered
reminders in the host's own table. Fills the slot the examples/next catalog
reserved for the schedules capability.
…able Object

bindLifecycleCapability() returns to being internal — exporting it publicly
was a test seam leaking into the API. The capability suite now installs
Scheduler on SchedulerHarnessObject, a minimal real Durable Object, and
drives real Lifecycle startup, real storage, real platform alarms
(runDurableObjectAlarm), host context inside callbacks, and the real
diagnostics event sink. The fake-services binder remains as an in-package
test shim only for the legacy mock-storage MCP manager suites.
…view

- Retry defaults are resolved but no longer validated in the Scheduler
  constructor: a historically tolerated invalid static retry config must
  not start throwing in the Agent constructor and brick every entry point
  of the Durable Object. Invalid defaults surface per execution as
  schedule:error, as before; per-schedule retry overrides stay validated.
- One-shot idempotent dedup accepts any truthy value again (historic
  behavior), not only literal true.
- Scheduler storage failures throw the exported SqlError again; the class
  moved to sql-error.ts so agents/schedules can share it without a cycle.
- The startup schedule() warning message now says 'during startup' (the
  window deliberately covers all startup hooks, not just onStart), and the
  convention-based underscore-callback exemption is gone — the one internal
  startup caller passes an explicit idempotent choice instead.
- The capability test's cron advance assertion tolerates a minute-boundary
  landing on the current second; stale 'Lifecycle controller' wording fixed
  in docs.
…y tests

McpTestHarnessObject is a bare Durable Object; withMcpHarness() runs a test
body inside a fresh instance where each created MCPClientManager is bound
through a real Lifecycle to real SQLite storage. Managers can be created
repeatedly over the same storage to simulate hibernation wake-ups.
Structure requested in review prep:
- The standalone Lifecycle vitest project (own vitest.config, wrangler,
  worker, env types) is gone; everything runs in the shared workers project
  and the plain-worker main-module routing is exercised by calling
  routeAgentRequest(request, env, { props }) directly.
- tests/lifecycle/ holds one file per Lifecycle functionality: runtime
  handlers, startup, alarm arbitration, capability events, capability
  routing, host context, hibernating WebSockets, identity, disposal — plus
  new coverage for startup-failure retry (RetryableStartObject),
  use-after-start, duplicate capability IDs, and uninstalled-capability
  service access.
- Capability contract tests mirror their source module:
  tests/schedules/{capability,timing}.test.ts and
  tests/mcp/client-capability.test.ts.
- tests/capabilities/ is the lower-level sibling of tests/agents/: harness
  Durable Objects one file per capability (harness.ts generic bare-DO
  installer, lifecycle.ts, scheduler.ts, mcp-client.ts), documented in its
  AGENTS.md. Generic drivers (captureDiagnosticsEvents,
  captureConsoleWarnings) live in tests/shared/.
- The three standalone MCP manager suites run on withMcpHarness — real
  Lifecycle over real SQLite storage in a bare CapabilityHarnessObject —
  replacing the hand-rolled mock storage and the deleted
  bindTestLifecycleServices shim entirely; the TestMCPClientManager subclass
  installs through the harness instead of a prototype swap.
- env imports come from cloudflare:workers (cloudflare:test's env is
  deprecated).
src/mcp was flat despite the public API already naming ./mcp/client and
./mcp/server. The module now mirrors that boundary: client/ (manager,
connection, storage, catalog, invoker, rpc restore, runtime, transports,
OAuth provider, errors, x402), server/ (stateless entry, handlers, legacy
McpAgent, transports, event store, auth context, utils), with shared
types/rpc/abort and the compatibility barrel at the root. Files moved with
git mv so history follows; every module's exports are unchanged — public
import paths are identical and only build entry points and dist layout
moved (package.json exports updated in lockstep).
The affected-test matrix outgrew the 20-minute budget: this branch adds the
lifecycle/capability suites and the Scheduler feature tests, and the last
green run on the old budget finished at 11 minutes with a much smaller
suite. The previous head's run failed on the (now-fixed) MCP suites, so
today's run was the first to execute the full grown matrix — it was
cancelled by the job timeout at 20m16s with the three largest suites still
running.
…Agent

origin/main created cf_agents_schedules inside Agent's constructor-time
_ensureSchema; the extraction moved creation into Scheduler.onStart, which
runs during async lifecycle startup. On a brand-new agent that regressed
synchronous pre-startup reads ('no such table') and opened a
permanent-loss window if a fresh DB's first wake crashed after the schema
version write but before startup, then rolled back to main (whose
version-gated DDL would never run again). The DDL now lives in one shared
ensureScheduleTable() called from both Agent's _ensureSchema and
Scheduler.onStart, with a regression test for fresh-agent sync reads. The
changeset now declares the PR's intended compatibility changes explicitly
(MCP storage option removal, parsed Schedule callback argument, internal
facet-RPC replacement).
The extracted ensureScheduleTable had de-indented the CREATE TABLE
template; sqlite_master stores statement text verbatim and the schema DDL
snapshot test pins it. Restore the historical whitespace so existing and
fresh databases carry identical stored DDL.
The capability fixture files mixed harness Durable Object classes with
their cloudflare:test-importing drivers, so worker.ts transitively pulled
cloudflare:test — a module that only exists inside the vitest pool. The
React project boots that worker under wrangler unstable_dev, so its global
setup failed and every browser test burned its full retry budget, which is
what pushed CI's agents:test past the job timeout. Harness classes stay in
tests/capabilities/ (worker-safe, documented rule); withCapabilityHarness
and withMcpHarness join the other pool-only drivers in tests/shared/.
@mattzcarey
mattzcarey marked this pull request as ready for review August 26, 2026 14:09

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

The Scheduler's host argument previously anchored set()/every() typing but
was unused at runtime, so a scheduler constructed for one object and
installed on another type-checked against the first and dispatched on the
second. The anchor can no longer lie: a LifecycleCapability may declare the
host it was constructed for, and Lifecycle.use() throws when it differs
from the Lifecycle's own host. The argument is now also optional — omit it
for string-typed scheduling with no anchor to diverge; pass it for typed
callbacks plus the install-time identity guarantee (SchedulerCallbacks is
the permissive default host type for the bare form).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant