feat(agents): self-initialize on every RPC entry surface#1932
Open
mattzcarey wants to merge 2 commits into
Open
feat(agents): self-initialize on every RPC entry surface#1932mattzcarey wants to merge 2 commits into
mattzcarey wants to merge 2 commits into
Conversation
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 detectedLatest commit: 938a63b The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 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 |
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).
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.
Motivation
Several native Durable Object RPC entry points on an
Agentdepend on the caller having resolved the stub throughgetAgentByName()/getServerByName(), whosesetName()round-trip is what runsonStart()before the first call. A surface reached on a stub that skipped that round-trip could execute beforeonStart()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/getServerByNamekeep their existing behavior (including thepropsround-trip).The guarantee
Every RPC entry surface now runs
onStart()before executing, mirroring the guard that_cf_invokeAgentPathalready used:_onEmail_cf_invokeSubAgent/_cf_invokeSubAgentPath_cf_dispatchScheduledCallback/_cf_checkRunFibersForFacet_cf_handleSubAgentWebSocket{Connect,Message,Close}_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
_autoWrapCustomMethodswrapper (withAgentContext).MCP storage helpers (
McpAgent.getInitializeRequest/setInitializeRequest/ stream-id helpers,onSSEMcpMessage) are intentionally left unchanged: they are only ever reached throughgetAgentByName(..., { props }), which keeps itssetNameround-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:
_selfInitCompletedflag 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.onStart(). The framework'sonStart()runs inside the agent's ALS context, so a wrapped method it calls locally takes the existingagent === thisfast path and runs directly — it never re-triggers init (which would deadlock the in-flight init or double-runonStart()).onStart()run rather than double-triggering it.onStart()throw insideblockConcurrencyWhileand rethrows it afterward, so the DO is not reset and the next entry re-runsonStart().ctx.id.namedefined). A raw-id DO (newUniqueId()/idFromString()without asetName()bootstrap) has no resolvable name — the case partyserver'snamegetter 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
propsresolve their stub directly (a new internalgetAgentStubByNamehelper that mirrorsgetServerByName's jurisdiction / locationHint handling but issues zero RPCs) instead of paying thesetNameround-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)The public
getAgentByName/getServerByNameare untouched.Tests
New
src/tests/self-init.test.ts(with test agents insrc/tests/agents/self-init.ts):env.NS.get(idFromName(...))initializes (onStartran, schema created inonStartis usable,this.namecorrect);onStartcalling its own wrapped method does not deadlock or double-init (onStartruns exactly once);_onEmailon a cold stub (routed viarouteAgentEmail, which now resolves zero-RTT) initializes before dispatch.The init-failure behavior is documented inline rather than asserted: an
onStart()that rejects inside partyserver'sblockConcurrencyWhilemakes 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-existinggetServerByNameround-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
agentspackage test suite passes (2279 tests), pluspnpm run check.