feat(agents): move state into an opt-in Lifecycle capability (agents/state) (#2179)
* feat(agents): move state into an opt-in Lifecycle capability (agents/state)
State was one method doing four jobs inside the Agent god-class —
validate, persist, broadcast, notify — with the state row, cache, and
schema all threaded through the class. It moves wholesale into a
StateManager capability that owns storage and change ordering, so any
Lifecycle host gets durable, validated state without inheriting Agent:
new StateManager({
resolveInitialState: () => ({ value: this.initialState }),
validateStateChange: (next, source) => this.validateStateChange(next, source)
})
The capability owns the cf_agents_state state row, lazy load with an
in-memory cache, initial-state seeding, and validated persistence. It
runs only the onStart hook (versioned schema init under its own
cf_agents:state_schema_version key) and reaches Lifecycle only for
storage — no alarm, no request path. It never touches connections:
after validate + persist it fires a typed onStateChanged emitter,
mirroring the MCP client's onServerStateChanged seam. The getter,
write path, and corrupt-row fallback move verbatim; the only changes
are ctx->lifecycle storage and the broadcast becoming the emitter.
Host-owned behavior is injected, not moved: validateStateChange stays
an overridable Agent method, and initialState is resolved lazily so a
subclass field — initialized after the base constructor — is read at
its final value.
Broadcast and the notification hook stay on the Agent as an
onStateChanged subscriber (_handleStateChanged): it broadcasts
CF_AGENT_STATE to protocol-enabled connections excluding the source
id, then runs onStateChanged/onStateUpdate off the invocation tail.
The onMessage state branch stays too — parse, readonly check, and
CF_AGENT_STATE_ERROR responses are WebSockets concerns; only its inner
write becomes #state.set(state, connection). Agent installs the
capability in the .use() chain and delegates state/setState to it.
The cf_agents_state table is shared: StateManager ensures it in
onStart and owns the state row, while Agent keeps its global
schema-version row in _ensureSchema and ensures the table there too —
each side idempotent, each tracking its own version, the same pattern
as Scheduler's ensureScheduleTable.
Agent's public API and wire protocol are unchanged — state,
setState(), onStateChanged, and the CF_AGENT_STATE frames behave
identically, and the full Agent state suite (22 cases) plus the schema
suite pass as-is. New capability suites install StateManager on a bare
Durable Object through withCapabilityHarness and cover persist/read,
initial-state seeding, falsy-value row existence, rehydration across a
simulated eviction, injected-validation rejection, and the
onStateChanged source-exclusion payload on both server and client
origins.
* fix(agents): retain async state change notifications
* fix(agents): remove unnecessary state capability plumbing
* refactor(agents): rename StateManager to State and seed initialState from Agent
- Rename the capability class to State (StateOptions), matching the other
capability names; Agent imports it as StateCapability since State is its
type parameter.
- Drop the lazy initialState getter: State takes plain static options.
Agent keeps initialState as a field and seeds it from the state getter,
so the capability no longer reaches back into the host.
- Remove the stray divider comment in onMessage.
* refactor(agents): give the State capability sole ownership of cf_agents_state
Agent kept its global schema version as a row in cf_agents_state, which
was the only reason it still created the table alongside the State
capability. The version now lives under the cf_agents:schema_version KV
key (read synchronously via storage.kv so the constructor can still gate
DDL), matching how every capability stores its own version. A DO created
under the old layout has the row read once, moved to the key, and deleted.
The legacy cf_state_was_changed cleanup moves into State's own v1
migration. Agent no longer touches the table outside that one-time read.
* refactor(agents): drop LifecycleServices.sql and trim the State surface
Capabilities already hold storage, and sql hangs off it, so every sibling
runs storage.sql.exec directly. State now does the same and the services
contract stays as it was. Fold the one-interface options module into
state/index.ts, drop the unused generic on StateChangeSource, and shorten
the Agent field comment.
* refactor(agents): rename Agent's State type parameter to TState
Frees the State name for the capability class so Agent imports it
directly instead of aliasing. Type parameters are positional for
consumers, so this is not a breaking change.
* refactor(agents): use # private fields in the State capability
Matches Streams, Tasks, Sessions, Scheduler, and Lifecycle, which use
ECMAScript private fields throughout.
* fix(state): persist before caching so a failed write cannot poison get()
Devin review on #2179: set() cached nextState before serializing and
writing it, so a JSON.stringify or SQLite failure left later get()
calls returning a value storage and onChanged never received.
* test(state): cover a failed write leaving the cache untouched
Also guard the async-hook branch with instanceof Promise so a hook that
returns a non-promise value at runtime cannot crash set().
* fix(state): normalize onChanged results with Promise.resolve
Handles thenables that are not instances of this realm's Promise while
still tolerating non-promise return values.
* fix(state): register the async onChanged continuation with the runtime
Devin on #2179: an onChanged hook that keeps working after it returns
could be abandoned once the invocation that set the state ended.
LifecycleServices gains waitUntil (ctx.waitUntil behind the narrow
services surface) and State.set registers the hook's continuation with
it, still without awaiting it.
* Revert "fix(state): register the async onChanged continuation with the runtime"
This reverts commit 04c54835163448aeaf3d7187356345ea6ad7f6c7.
---------
Co-authored-by: Antoni T <atokarski@cloudflare.com>
Co-authored-by: Matt Carey <mcarey@cloudflare.com> A
Antonio's committed
43a58a1014fbe6f1fe3a1fcc38ad08d53bb5b112
Parent: beff78a
Committed by GitHub <noreply@github.com>
on 9/12/2026, 12:53:25 AM