fix(agents): initialize cold native RPC calls - #2005
Conversation
…bject RPC methods run on a cold instance. Fixes #1990. ## Why - Native Durable Object RPC invokes methods directly, bypassing the PartyServer entry points that normally run `onStart()`. A method that wakes an evicted Agent can therefore observe missing session state, stale caches, unrestored MCP connections, or incomplete recovery. - `getAgentByName()` cannot provide a lasting guarantee because stubs outlive the in-memory instances they address. Eviction or deployment can make any later RPC the next cold-start entry point. - Requiring every inherited and application-defined RPC method to call `__unsafe_ensureInitialized()` would expose framework lifecycle concerns to users and leave room for inconsistent coverage. - Eagerly starting the lifecycle from the base constructor would run before derived fields and constructors finish. Instead, this change extends the existing method-context wrapper, arms it after construction, and initializes only when a public application RPC is the cold entry point. ## Architectural Changes Before: ```text native RPC -> application method | +-> may observe pre-onStart state ``` After: ```text native RPC -> Agent invocation wrapper -> PartyServer initialization -> application method | +-> lifecycle/control-plane methods retain their existing dispatch ``` - Agent tracks whether RPC initialization is constructing, pending, starting, or started so startup extension points can call public methods without recursively initializing. - Prototype discovery now wraps inherited Agent and application methods while preserving callable metadata and the nearest property descriptor. - PartyServer lifecycle and control-plane methods, plus underscore-prefixed framework internals, remain outside the application RPC wrapper. ## Code Changes - `agents` initializes cold public application RPC calls through `__unsafe_ensureInitialized()` and delays arming the guard until derived construction completes. - `agents` preserves synchronous constructor-local calls, inherited descriptor shadowing, and existing WebSocket `@callable` behavior. - `@cloudflare/think` marks its startup chain as in progress while it initializes session, workspace, message, protocol, and recovery state before invoking extension points. - The OAuth provider fixtures now use named IDs because their application RPCs enter Agent startup. An unbootstrapped `newUniqueId()` has no resolvable PartyServer name and is not a valid initialized Agent instance. - Patch changesets cover both `agents` and `@cloudflare/think`. ## Compatibility - Native Durable Object RPC does not require `@callable`. The decorator remains the explicit allowlist for RPC exposed through Agent WebSocket clients. - Agents addressed through `idFromName()` or `getByName()` initialize without an extra handshake. - A raw `newUniqueId()` or `idFromString()` instance must call `setName()` before its first application RPC. Once bootstrapped, PartyServer persists the name and recovers it after eviction. Previously, name-independent methods could appear to work by running against uninitialized Agent state.
🦋 Changeset detectedLatest commit: 379b5ec 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 |
| if (this._rpcInitializationState !== "pending") { | ||
| return method.apply(this, args); | ||
| } | ||
|
|
||
| // A native Durable Object RPC invokes the method directly, bypassing | ||
| // PartyServer.fetch() and the other entry points that normally run | ||
| // onStart(). Initialize inside the invocation context so methods called | ||
| // by onStart take the synchronous branch above rather than recursively | ||
| // trying to initialize the same Agent. | ||
| return this.__unsafe_ensureInitialized().then(() => | ||
| method.apply(this, args) | ||
| ); | ||
| } | ||
| ); | ||
| ) as ReturnType<T>; |
There was a problem hiding this comment.
🟡 Non-async agent methods can hand back an unfinished value on the very first call after wake-up
On the first call to a public agent method after the object wakes up, the framework hands back a pending value instead of the real result (.then(...) cast with as ReturnType<T> at packages/agents/src/index.ts:1626-1630), so callers of methods that were previously instant get a placeholder they must wait on.
Impact: Code inside the agent (or in the same isolate) that uses the result of a normally-instant method directly can silently work with an unusable placeholder value the first time the agent is touched after a restart.
Wrapper turns synchronous methods into promise-returning ones while initialization is pending
withAgentContext previously always returned method.apply(this, args) directly, so a synchronous public method kept synchronous semantics. The new branch returns this.__unsafe_ensureInitialized().then(() => method.apply(...)) whenever _rpcInitializationState === "pending", i.e. on the first invocation after construction/eviction that is not already inside the agent's invocation context. Because the declared signature is (...args) => ReturnType<T>, the implementation needs an explicit as ReturnType<T> cast (packages/agents/src/index.ts:1630) to hide the mismatch from the type checker.
This matters more than before because _autoWrapRpcMethods (packages/agents/src/index.ts:3653-3696) no longer excludes Agent/Server base methods, so inherited synchronous helpers such as getMcpServers() — which the framework itself consumes synchronously, e.g. mcp: this.getMcpServers() inside JSON.stringify at packages/agents/src/index.ts:2744-2748 — are now wrapped. Any caller that reaches such a method while the state is still "pending" and without the agent already being the current agent receives a promise where a plain value is expected, and the value is serialized/used as an empty object rather than the intended data.
A safer shape would be to only apply the initialization branch to methods that are known to be async (or to keep the synchronous fall-through when method is not an async function), and to avoid the as ReturnType<T> cast so the type system reflects the actual contract.
Was this helpful? React with 👍 or 👎 to provide feedback.
agents
@cloudflare/ai-chat
@cloudflare/codemode
create-think
hono-agents
@cloudflare/shell
@cloudflare/think
@cloudflare/voice
@cloudflare/worker-bundler
commit: |
This PR initializes Agent and Think instances before native Durable Object RPC methods run on a cold instance. Fixes #1990.
Why
onStart(). A method that wakes an evicted Agent can therefore observe missing session state, stale caches, unrestored MCP connections, or incomplete recovery.getAgentByName()cannot provide a lasting guarantee because stubs outlive the in-memory instances they address. Eviction or deployment can make any later RPC the next cold-start entry point.__unsafe_ensureInitialized()would expose framework lifecycle concerns to users and leave room for inconsistent coverage.Architectural Changes
Before:
After:
Code Changes
agentsinitializes cold public application RPC calls through__unsafe_ensureInitialized()and delays arming the guard until derived construction completes.agentspreserves synchronous constructor-local calls, inherited descriptor shadowing, and existing WebSocket@callablebehavior.@cloudflare/thinkmarks its startup chain as in progress while it initializes session, workspace, message, protocol, and recovery state before invoking extension points.newUniqueId()has no resolvable PartyServer name and is not a valid initialized Agent instance.agentsand@cloudflare/think.Compatibility
@callable. The decorator remains the explicit allowlist for RPC exposed through Agent WebSocket clients.idFromName()orgetByName()initialize without an extra handshake.newUniqueId()oridFromString()instance must callsetName()before its first application RPC. Once bootstrapped, PartyServer persists the name and recovers it after eviction. Previously, name-independent methods could appear to work by running against uninitialized Agent state.