feat(lifecycle): add composable Scheduler - #1897
Open
mattzcarey wants to merge 22 commits into
Open
Conversation
🦋 Changeset detectedLatest commit: bb6e84d The changes in this PR will be included in the next version bump. This PR includes changesets to release 3 packages
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 |
agents
@cloudflare/ai-chat
@cloudflare/channels
@cloudflare/codemode
hono-agents
@cloudflare/shell
@cloudflare/think
@cloudflare/voice
@cloudflare/worker-bundler
commit: |
mattzcarey
force-pushed
the
feat/standardise-agent-lifecycle
branch
3 times, most recently
from
August 25, 2026 11:14
08cfca1 to
1255c40
Compare
mattzcarey
force-pushed
the
refactor/extract-agent-schedules
branch
from
August 25, 2026 14:30
dc270cc to
f7ca736
Compare
mattzcarey
force-pushed
the
refactor/extract-agent-schedules
branch
from
August 25, 2026 15:23
f7ca736 to
383a465
Compare
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
marked this pull request as ready for review
August 26, 2026 14:09
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).
…' into refactor/extract-agent-schedules
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Add
Scheduler, a reusable Lifecycle capability for persistent delayed, dated, cron, and interval callbacks, underagents/schedules.A plain Lifecycle Object installs it with no wiring:
The
hostargument is optional and cannot lie: passing it typesset()/every()against the host's methods andLifecycle.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.Agentconstructs and installs the same primitive atthis.scheduler, passing only policy (retry defaults, hung-interval timeout, error routing). Existingthis.schedule(),scheduleEvery(), get/list/cancel, retries, observability, sub-agent routing, OOM handling, and callback behavior remain compatible delegators.Capability services: the composition contract
LifecycleCapabilitygrants 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;
MCPClientManagernow 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 callslifecycle.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 hostonAlarm(), 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:
observabilityinterface; plain objects publish to the existingagents:*diagnostics channels.There is no Agent-specific Scheduler adapter.
Domain boundaries
agents/schedules/schedule-timing: pure timing rules — parsingwheninputs and interval bounds into aScheduleTiming.set/every(typed),schedule/scheduleEvery(string-name),get,list,cancel, plus deprecated synchronous reads for Agent compatibility.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 callingrouteAgentRequest(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.tsgeneric,lifecycle.ts,scheduler.ts,mcp-client.ts), the lower-level sibling oftests/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:
tests/schedules/timing.test.ts.tests/schedules/capability.test.tsinstalls Scheduler onSchedulerHarnessObject, 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.evictDurableObject()).The standalone MCP manager suites (previously hand-rolled
DurableObjectStoragemocks 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 bareCapabilityHarnessObject, 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 plainDurableObject: typedset(), 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 underwrangler 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/mcpis no longer flat: the already-public./mcp/clientand./mcp/serversplit is now real folder structure (src/mcp/client/,src/mcp/server/, sharedtypes/rpcat 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.storageis removed; the manager receives storage from the Lifecycle it is installed on, so standalone construction with an explicitDurableObjectStorageis no longer supported.Scheduleobject as their second argument (previously the raw storage row, whosepayloadwas an unparsed JSON string)._cf_*ForFacetschedule RPC methods are replaced by the generic_cf_routeLifecycleaperture (facets always run the same deployed script).A brand-new Agent still creates
cf_agents_schedulessynchronously at construction —ensureScheduleTableis shared between Agent's schema initialization andScheduler.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):
sherif: greenexamples/next/schedulesexercised live underwrangler dev(create → alarm delivery with host context → cancel)