feat(lifecycle): add composable Scheduler - #1897
Merged
Merged
Conversation
🦋 Changeset detectedLatest commit: 7359ea6 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
Callbacks are now registered in the Scheduler constructor
(new Scheduler({ callbacks })), and set()/every() type both the name and
payload against that registration — the typed scheduling surface and the
runtime dispatch target are the same object by construction. The stringly
schedule()/scheduleEvery() verbs and the install-time host-identity check
are gone.
With host-method dispatch no longer a generic need, the Lifecycle
callbacks service (has/invoke/run) shrinks to runInHostContext() — the one
boundary for running capability-held user callbacks inside the host
invocation context. Agent keeps its historical name-based scheduling API
through a Scheduler-specific composition-root resolver
(setSchedulerCallbackResolver), so this.schedule(60, "methodName") still
dispatches to Agent methods inside the traced host boundary.
The agents/lifecycle entry point and the capabilities built on it (Scheduler, MCPClientManager installed directly as a capability) may change between releases while the composition surface stabilizes. Uses the repo's existing @experimental convention; Agent's established APIs (this.schedule() and friends, agent.mcp) are unaffected. Also refreshes Scheduler's class doc for the registered-callbacks constructor.
The API reference listed Agent's schedule()/scheduleEvery()/get/list/ cancel methods directly under the Scheduler constructor heading, reading as methods on the Scheduler — which no longer has stringly verbs at all. Group the reference into 'Scheduler primitive' (constructor, set, every, get/list/cancel) and 'Agent methods' (the stable delegating surface).
The synchronous getSchedule()/getSchedules() on Scheduler exist only to back Agent's deprecated sync compat surface — the primitive never shipped them, so born-deprecated was the wrong label. Retag @internal and move them into the host-owned policy aperture section beside cleanupRoutePrefix(), keeping the primitive's contract at set/every/get/list/cancel. Agent's deprecated methods are unchanged.
Rename the @internal sync compat reads to __DO_NOT_USE_WILL_REMOVE__getSchedule(s), matching the existing __DO_NOT_USE_WILL_BREAK__ convention, so the published types make their status unmissable. Agent's deprecated getSchedule()/getSchedules() delegators are unchanged.
The registered-callbacks migration replaced the Lifecycle callbacks service with runInHostContext and moved Agent's name fallback into a composition-root resolver, but comments and docs still described the old model in nine places: the LifecycleHostInvoker JSDoc (pointing at the removed LifecycleServices.callbacks), the setLifecycleHostInvoker and Agent composition-root comments, the Scheduler module header and executeCallback doc, the SchedulerCallbacks and callbacks-option docs (claiming a generic host-method fallback the Scheduler does not perform), the schedules example README, and the capability-harness driver doc (which also pointed at a nonexistent scheduler-harness.ts). Comment/doc changes only.
This was referenced Aug 26, 2026
Six-lens consistency review of the full diff surfaced the remaining scope-fuzz; all mechanical, no behavior changes except loud-renames: - agents/schedules now exports SchedulerHandlers, SchedulerPayload, and SchedulerEventType (they appear in Scheduler's public signatures) and carries a scoped @experimental module banner; SchedulerCallbacks, SchedulerHandlers, SchedulerPayload tagged @experimental; Agent's lifecycle/scheduler properties tagged @experimental. - The permanent Agent-only host hooks on Scheduler are loud-named __DO_NOT_USE_WILL_BREAK__cleanupRoutePrefix/handleAlarmMemoryLimit, matching the repo convention (they are permanent internals, unlike the WILL_REMOVE sync-read shims). - unstable_getSchedulePrompt/unstable_scheduleSchema moved off the new agents/schedules/parser entry back to the deprecated agents/schedule compat entry, so the new surface ships no born-deprecated aliases. - Scheduler messages stop recommending Agent-only method names: the stale-one-shot warning is host-neutral and the sync-read errors give both Agent and standalone guidance; constructor JSDoc no longer promises a host-method fallback. - Deleted the phantom ScheduleStorageRow.retry field, two stale @template tags, and a stale resolveRetryConfig comment. - Tests: env imported from cloudflare:workers in capability-harness; console-capture.ts moved beside the fixtures that import it (tests/shared stays pool-only); stale lifecycle.test.ts pointer fixed; the legacy-parser tests-d check now actually asserts. - Docs: exclusive-contribution paragraph moved into Shared alarm ownership; example imports getCurrentAgent from agents/lifecycle; next README marks mcp-client Available; changeset names the experimental Agent properties.
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:
Scheduled callbacks are registered on the Scheduler itself, so
set()/every()type both the callback name and the payload against the registration and dispatch runs the registered function — the typed scheduling surface and the runtime dispatch target are the same object by construction. Callbacks run inside the host invocation context (host available throughgetCurrentAgent()).Agentconstructs and installs the same primitive atthis.scheduler, passing only policy (retry defaults, hung-interval timeout, error routing); a composition-root resolver keeps its historical name-basedthis.schedule(60, "methodName")dispatching to Agent methods. Existingthis.schedule(),scheduleEvery(), get/list/cancel, retries, observability, sub-agent routing, OOM handling, and callback behavior remain compatible delegators.Stability
agents/lifecycleand the capability surfaces built on it (Scheduler,MCPClientManageras an installed capability) are marked@experimental— the composition surface may change between releases. Agent's established APIs (this.schedule()and friends,agent.mcp) are stable and unaffected.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.runInHostContext()— the one boundary for running capability-held user callbacks inside the host invocation context.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.new Scheduler({ callbacks, ...policy }),set/every(typed against the registration),get,list,cancel; internal synchronous reads back Agent's deprecated sync compat surface.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 run through
lifecycle.runInHostContext()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).Scheduler(new in this PR) registers callbacks in its constructor; Agent's name-based scheduling methods are unaffected._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:
sherif: greenexamples/next/schedulesexercised live underwrangler dev(create → alarm delivery with host context → cancel)