Skip to content

feat(agents): self-initialize on every RPC entry surface#1932

Open
mattzcarey wants to merge 2 commits into
mainfrom
feat/rpc-self-init
Open

feat(agents): self-initialize on every RPC entry surface#1932
mattzcarey wants to merge 2 commits into
mainfrom
feat/rpc-self-init

Conversation

@mattzcarey

@mattzcarey mattzcarey commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Motivation

Several native Durable Object RPC entry points on an Agent depend on the caller having resolved the stub through getAgentByName() / getServerByName(), whose setName() round-trip is what runs onStart() before the first call. A surface reached on a stub that skipped that round-trip could execute before onStart() completed — seeing un-hydrated facet state, an un-restored MCP client, or a schema that a hook expected to exist.

This makes every RPC entry surface self-sufficient: it initializes itself before running, so init ordering no longer rides on the resolution-time round-trip. That is also the prerequisite for later dropping the round-trip on prop-less resolution.

This is pure hardening — no public API changes and no change to documented resolution semantics. getAgentByName / getServerByName keep their existing behavior (including the props round-trip).

The guarantee

Every RPC entry surface now runs onStart() before executing, mirroring the guard that _cf_invokeAgentPath already used:

  • _onEmail
  • the sub-agent bridges _cf_invokeSubAgent / _cf_invokeSubAgentPath
  • the schedule / fiber dispatch callbacks _cf_dispatchScheduledCallback / _cf_checkRunFibersForFacet
  • the WebSocket-forwarding facet handlers _cf_handleSubAgentWebSocket{Connect,Message,Close}
  • every root facet RPC method: schedules (_cf_scheduleForFacet, _cf_scheduleEveryForFacet, _cf_cancelScheduleForFacet, _cf_getScheduleForFacet, _cf_listSchedulesForFacet, _cf_cleanupFacetPrefix), keepAlive (_cf_acquireFacetKeepAlive, _cf_releaseFacetKeepAlive), facet-run registration (_cf_registerFacetRun, _cf_unregisterFacetRun), _cf_destroyDescendantFacet, _cf_broadcastToSubAgent, and the sub-agent connection routing methods (_cf_subAgentConnectionMetas, _cf_sendToSubAgentConnection, _cf_closeSubAgentConnection, _cf_setSubAgentConnectionState).

User-defined RPC methods on cold stubs also initialize before running, via a synchronous fast path in the _autoWrapCustomMethods wrapper (withAgentContext).

MCP storage helpers (McpAgent.getInitializeRequest / setInitializeRequest / stream-id helpers, onSSEMcpMessage) are intentionally left unchanged: they are only ever reached through getAgentByName(..., { props }), which keeps its setName round-trip in this change, so they are always invoked on an already-initialized stub.

Re-entrancy and correctness design

The wrapper must add init without regressing local-call semantics. It handles:

  • Synchronous returns preserved. The warm path is a synchronous _selfInitCompleted flag check; an already-initialized wrapped method called locally returns its value synchronously (it does not start returning a promise). Cold entry is inherently async — it only happens across an RPC boundary.
  • Re-entrancy during onStart(). The framework's onStart() runs inside the agent's ALS context, so a wrapped method it calls locally takes the existing agent === this fast path and runs directly — it never re-triggers init (which would deadlock the in-flight init or double-run onStart()).
  • Concurrent cold RPCs init exactly once. The in-flight init promise is memoized, so two concurrent cold RPCs share a single onStart() run rather than double-triggering it.
  • Init failure does not hang. On failure the memo is cleared and the error is surfaced to the caller, so the next call retries rather than replaying a cached rejection. partyserver catches an onStart() throw inside blockConcurrencyWhile and rethrows it afterward, so the DO is not reset and the next entry re-runs onStart().
  • Hibernation-safe. Both signals are in-memory only, so a woken (cold) instance re-initializes, which is correct.
  • Raw-id addressing unchanged. Self-init is gated on name-addressed DOs (ctx.id.name defined). A raw-id DO (newUniqueId() / idFromString() without a setName() bootstrap) has no resolvable name — the case partyserver's name getter throws on — so the wrapper preserves the historical behavior of invoking the method without forcing init there.

Internal zero-round-trip resolution

Because every reachable surface is now self-sufficient, the internal resolution sites that only ever invoke self-initializing methods and never pass props resolve their stub directly (a new internal getAgentStubByName helper that mirrors getServerByName's jurisdiction / locationHint handling but issues zero RPCs) instead of paying the setName round-trip:

  • _rootAlarmOwner (used by ~9 schedule/keepAlive/broadcast call sites)
  • parentAgent (top-level and facet-proxy branches)
  • AgentWorkflow's agent resolution (top-level and facet origins)
  • email routing

The public getAgentByName / getServerByName are untouched.

Tests

New src/tests/self-init.test.ts (with test agents in src/tests/agents/self-init.ts):

  • a user-defined RPC method invoked on a cold stub obtained via raw env.NS.get(idFromName(...)) initializes (onStart ran, schema created in onStart is usable, this.name correct);
  • onStart calling its own wrapped method does not deadlock or double-init (onStart runs exactly once);
  • two concurrent cold RPCs init exactly once;
  • a synchronous wrapped method called after init (both directly and locally from another method) returns synchronously, not a promise;
  • _onEmail on a cold stub (routed via routeAgentEmail, which now resolves zero-RTT) initializes before dispatch.

The init-failure behavior is documented inline rather than asserted: an onStart() that rejects inside partyserver's blockConcurrencyWhile makes the runtime log an "uncaught (in promise)" that the workers test pool reports as an unhandled error and fails the run. That artifact reproduces identically on the pre-existing getServerByName round-trip path, so it is a test-harness limitation, not a behavior this change introduces. The behavior itself (RPC rejects, does not hang, retries) was verified manually.

Full agents package test suite passes (2279 tests), plus pnpm run check.


Open in Devin Review

RPC entry points previously depended on the caller resolving the stub
through getAgentByName(), whose setName() round-trip is what ran onStart()
before the first call. A surface reached on a stub that skipped that
round-trip could execute before onStart() completed.

Every RPC entry surface now runs onStart() before executing, mirroring the
existing _cf_invokeAgentPath guard: _onEmail, the sub-agent bridges, the
schedule/fiber dispatch callbacks, the WebSocket-forwarding facet handlers,
and every root facet RPC method. User-defined RPC methods on cold stubs
also initialize first, via a synchronous fast path in the auto-wrapping
layer that preserves synchronous return values once warm, never re-enters
init while onStart() is in flight (re-entrant local calls take the
agent-context fast path), memoizes a single init across concurrent cold
RPCs, and self-inits only for name-addressed DOs so raw-id addressing is
unchanged.

Because every reachable surface is now self-sufficient, the internal
resolution sites that only invoke self-initializing methods (_rootAlarmOwner,
parentAgent, AgentWorkflow's agent resolution, and email routing, none of
which pass props) resolve their stub directly and skip the extra round-trip.
The public getAgentByName / getServerByName behavior is unchanged.
@changeset-bot

changeset-bot Bot commented Jul 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 938a63b

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

This PR includes changesets to release 2 packages
Name Type
agents Minor
@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

@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

Adding self-init to `_cf_cleanupFacetPrefix` regressed one path: on a
non-facet (top-level) agent, `deleteSubAgent()` runs that method locally
on `this`. If user code calls `deleteSubAgent()` inside `onStart()`, the
agent is mid-init, and the new `__unsafe_ensureInitialized()` re-enters
partyserver's init — whose status check does not short-circuit during
`onStart()` — nesting `blockConcurrencyWhile()` and throwing
"blockConcurrencyWhile() calls are nested too deeply", which aborts init.

Split `_cf_cleanupFacetPrefix` into the guarded RPC entry (still runs
init for zero-RTT stub callers) and an unguarded `_cleanupFacetPrefixImpl`
holding the body. Local `this.` callers (`deleteSubAgent`,
`_cf_dispatchScheduledCallback`, `_cf_destroyDescendantFacet`) call the
impl directly so they never re-trigger init; cross-DO `root.` calls keep
the guard.

Also closes the test gap the change exposed:
- Cold-entry tests for the internal `_cf_*` self-init surfaces
  (`_cf_scheduleForFacet`, `_cf_dispatchScheduledCallback`,
  `_cf_broadcastToSubAgent`), asserting `onStart()`'s durable side effect
  via a `runInDurableObject` read that does not itself trigger init.
  Mutation-checked: each fails if its guard is dropped.
- Regression test driving `deleteSubAgent()` from `onStart()` on a
  non-facet agent.
- Clarify that the concurrent cold-RPC test asserts idempotence, not the
  `_selfInitPromise` memo (the DO input gate serializes the two cold
  entries in-isolate, so a genuine interleaving cannot be reproduced).
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