Repository navigation
Bug: _cf_initAsFacet throws "Cannot perform I/O on behalf of a different Durable Object" on first this.name read after the #1393 / 0.11.6 fix #1410
Description
Versions in production: agents@0.11.7 · partyserver@0.5.5 · @cloudflare/workers-types@4.20260426.1 · wrangler@^4.83.0 · compatibility_date = "2026-04-01" · compatibility_flags = ["nodejs_compat"]. (Deploy / account / request identifiers redacted; happy to share via Cloudflare support if a reviewer needs to pull internal traces.)
new_sqlite_classes correctly includes both the parent class and every sub-agent class (verified — matches the examples/multi-ai-chat shape documented in the closing comments of #1385).
Summary
PR #1393 ("Migrate facet bootstrap to explicit FacetStartupOptions.id", shipped in agents@0.11.6) replaces the old __ps_name storage-write workaround with the documented facet API shape ctx.facets.get(key, () => ({ class: Cls, id: parentNs.idFromName(name) })), relying on each facet getting its own ctx.id.name. In production (compatibility_date = "2026-04-01", no experimental flag) the very first synchronous read of this.name inside the facet's _cf_initAsFacet body fails with:
Error on server: Error: Cannot perform I/O on behalf of a different
Durable Object. I/O objects (such as streams, request/response bodies,
and others) created in the context of one Durable Object cannot be
accessed from a different Durable Object in the same isolate. This is a
limitation of Cloudflare Workers which allows us to improve overall
performance. (I/O type: Native)
wallTimeMs ≈ 11–16, cpuTimeMs ≈ 5–8 — too short for any user code; the throw happens on the FIRST line of _cf_initAsFacet. The failure is class-agnostic — it reproduces across every sub-agent class we tried, all on the same parent.
The library-side fix (#1393) is correct in intent, but the workerd-side complement is missing in our deploy region: the DurableObjectId passed via FacetStartupOptions.id is materialized in the parent's IoContext, and the runtime is not re-seating it into the facet's IoContext before the facet's _cf_initAsFacet runs. Result: the very property access PR #1393 relies on (this.ctx.id.name) is the one workerd rejects.
Reproduction
Minimal case — an Agent subclass parent that calls subAgent(Sub, name) from inside a tool execute. The parent class is bound as a Durable Object namespace; Sub is in new_sqlite_classes but not bound. The tool body matches the standard sub-agent flow:
const stub = await this.subAgent(Sub, childName);
await stub.configure(childConfig); // user-defined RPC
await stub.chat(task, callback); // user-defined RPCstub.configure is the call that arrives at the facet's _cf_initAsFacet and throws — the configure RPC body never runs because the agents-side init step in front of it fails first.
The same call sequence "appeared" to work end-to-end against agents@0.11.5 test rigs that did not actually exercise _cf_initAsFacet's name check; the new check at the head of _cf_initAsFacet makes the existing IoContext-binding bug now visible on every spawn attempt.
Stack trace pointers (links to source on main)
The throw site, with GitHub source-side links:
packages/agents/src/index.ts:3745-3775 — _cf_initAsFacet:
async _cf_initAsFacet(
name: string,
parentPath: ReadonlyArray<{ className: string; name: string }> = []
): Promise<void> {
if (this.name !== name) throw new Error(`Facet bootstrap mismatch: ...`);
// ... _isFacet / _parentPath set, ctx.storage.put(...), __unsafe_ensureInitialized()
}this.name resolves to partyserver's name getter at cloudflare/partykit packages/partyserver/src/index.ts:776-783:
get name(): string {
const ctxName = this.ctx.id.name; // ← THROW SITE
if (ctxName !== undefined) return ctxName;
if (this.#_name) return this.#_name;
throw new Error(`Attempting to read .name on ${this.#ParentClass.name} ...`);
}The id used for the facet was constructed inside packages/agents/src/index.ts:3925-4032 — specifically L4003-L4007:
const facetId = parentNs.idFromName(name);
const stub = ctx.facets.get(facetKey, () => ({
class: Cls as DurableObjectClass,
id: facetId
}));parentNs.idFromName(name) is invoked in the parent's IoContext (the ctx and ctx.exports here belong to the parent DO). The DurableObjectId it returns is therefore a Native handle bound to the parent's IoContext. When workerd later instantiates the facet and the facet reads this.ctx.id.name, the IoContext mismatch trips the check.
Live Workers Logs evidence
Three runs of subAgent(Sub, name) against the same parent DO instance, on the same deploy, against three different sub-agent classes (referred to as Sub-A, Sub-B, Sub-C below). All log entries from each run share the same requestId (the RPC invocation), in order. Identifiers obfuscated.
| Run | sub-agent class | rpcMethod | wallMs | cpuMs | level | message |
|---|---|---|---|---|---|---|
| 1 | Sub-A | _cf_initAsFacet |
16 | 8 | error | _cf_initAsFacet |
| 1 | Sub-A | _cf_initAsFacet |
— | — | error | Error on server: Error: Cannot perform I/O on behalf of a different Durable Object … (I/O type: Native) |
| 1 | Sub-A | _cf_initAsFacet |
— | — | error | Override onError(error) to handle server errors |
| 2 | Sub-B | _cf_initAsFacet |
11 | 5 | error | (same shape) |
| 3 | Sub-C | _cf_initAsFacet |
14 | 5 | error | (same shape) |
We also reproduced this across two earlier agents@0.11.7 deploys (one prior agents@0.11.5 deploy, one with a post-agents@0.11.6 bump) before doing a full bun.lock + node_modules wipe and a clean reinstall — the failure persists identically across all three deploys. The wall-time of ~11–16 ms with ~5–8 ms CPU rules out any user code path: the throw is on the synchronous this.name access at the head of the RPC. Across runs the error has the same workerd fingerprint discriminator (b5fa2b4f...) — i.e. workerd considers all three to be the same root cause.
Why this contradicts the #1393 release-notes claim
The agents 0.11.6 release notes (release page) state:
"Migrate facet (sub-agent) bootstrap to the documented Cloudflare facet API: pass
id: parentNs.idFromName(name)toctx.facets.get()so the facet has its ownctx.id.name. Drops the__ps_namestorage write andsetName()bootstrap from_cf_initAsFacet."
The library-side change is correct — FacetStartupOptions.id IS the documented contract. The Dynamic Workers facet docs at https://developers.cloudflare.com/dynamic-workers/usage/durable-object-facets/ describe id:
"
idDurableObjectId | stringOptional — The ID the facet sees as its ownctx.id. If omitted, the facet inherits the parent Durable Object's ID."
The doc does NOT specify the IoContext ownership of the resulting ctx.id on the facet's side. Empirically (and judging from the "Native" discriminator in the workerd error message), the DurableObjectId retains its parent-IoContext binding when handed to the facet, so any access on ctx.id.* from inside the facet's RPC methods crashes.
What we expect / minimal fix shape
When workerd instantiates a facet with a parent-supplied FacetStartupOptions.id, the id value visible on the facet's ctx.id should be:
- Either a fresh
DurableObjectIdbound to the facet's ownIoContext(preferred — matches the implicit contract thatctx.idis a per-DO Native value). - Or, at minimum, a
DurableObjectIdwhose property access (.name,.toString(),.equals(...)) does NOT trip the cross-IoContextcheck on the facet side.
Either form makes partyserver's name getter — the very method PR #1393 designed around — work, and unblocks every downstream consumer of subAgent().
Workarounds we considered and why they don't work in JS
- Override
_cf_initAsFacetin the user'sAgentsubclass to skipthis.name. Insufficient:partyserver's private#ensureInitialized(called from every entry point including alarms, fetches, and websocket handlers) still readsthis.ctx.id.namedirectly to choose between#persistNameFallbackFromCtxIdand the legacy storage-hydrate path. We can't override a private method, and the read itself is the throw site, so subclassingnameto ignorectx.iddoesn't help. - Downgrade to
agents@0.11.5. Reproduces the same throw later in the boot sequence (the facet still ends up holding a parent-boundctx.id; the only thing that changed in Migrate facet bootstrap to explicit FacetStartupOptions.id #1393 was making the throw fire one stack frame earlier). - Hand-rolled
setName(name)from inside_cf_initAsFacet.partyserver'ssetNamereadsctx.id.nameBEFORE comparing against the suppliedname, so it trips the same check.
We don't see a path to fix this from JS alone without forking partyserver. The fix has to be in workerd (or in the ctx.facets.get(...) runtime implementation).
Related issues / PRs (for reviewers)
- #1393 (merged in 0.11.6): introduced the explicit-
idfacet bootstrap that this issue depends on. Closes Migrate facet bootstrap to partyserver setName() (drops direct __ps_name storage write) #1385. - #1385 (closed by Migrate facet bootstrap to explicit FacetStartupOptions.id #1393): "Migrate facet bootstrap to partyserver setName() (drops direct __ps_name storage write)". The reporter explicitly noted partyserver 0.5.0 made
this.nameresolve fromctx.id.namenatively but that facets getctx.id.name === undefinedby default — the migration plan was to flip facets toctx.id.name !== undefinedviaidFromName. This issue is the follow-up showing that flippingctx.id.nameto a non-undefined value introduces the cross-IoContextaccess regression. - #1330 (merged in 0.11.5): unrelated earlier cross-DO I/O fix that replaced the parent-constructed
Request+stub.fetch()handshake with the RPC-based_cf_initAsFacet. Kept here only to head off conflation: that fix targeted a DIFFERENT Native handle (Request), notDurableObjectId. - #1329 (closed, fixed by fix(agents): sub-agent cross-DO I/O + drop "experimental" compat flag #1330): the original cross-DO I/O bug report. Different
I/O type(Request body vs. ourNative/ DurableObjectId). - #1380 ("wire up this.ctx.id.name in partyserver", closed): the partyserver-side change that made
nameresolve fromctx.id.name. The combination of wire up this.ctx.id.name in partyserver #1380 + Migrate facet bootstrap to explicit FacetStartupOptions.id #1393 is what makes this bug observable — pre-wire up this.ctx.id.name in partyserver #1380,partyserver'snamegetter went through storage and never touchedctx.id.
Suggested next steps (writer's note, optional)
If a workerd-side IoContext rebind is months away, an interim library workaround in partyserver would be: in name's getter, wrap the this.ctx.id.name read in a try/catch that on Native-type IoContext errors falls through to the existing this.#_name / storage paths. That would let _cf_initAsFacet populate #_name manually (__ps_name storage write), restoring the pre-#1393 behavior under the hood while keeping the documented surface unchanged. Happy to send a PR if that direction makes sense.
If it would help, I can attach the full Workers Logs query JSON, the exact wrangler.toml minus secrets, and a reduced-test-case worker zip — happy to share via Cloudflare support so identifiers stay private.
Activity
threepointone commentedon Apr 28, 2026
thanks for filing this. does downgrading fix it for you?
threepointone commentedon Apr 28, 2026
I'm releasing in 20 minutes some fixes, try those as well.
MingkaiChen commentedon Apr 28, 2026
Unfortunately, as noted in the issue, version 0.11.5 reproduces the same throw later in the boot sequence.
threepointone commentedon Apr 28, 2026
I did a release, you can try it out by updating to latest of everything and let me know if it works. it likely won't. I'm working on a proper fix now anyway, should be an hour or so away.
MingkaiChen commentedon Apr 28, 2026
Unfortunately,
0.11.8indeed did not resolve the issue. I have included in the issue description a portion of my analysis of this issue and potential solutions.threepointone commentedon Apr 28, 2026
Thanks for the unusually well-researched report — the stack pointers, log table, and the relationship to #1393 / #1385 / #1380 made it possible to model this end-to-end without back-and-forth. That said, after building two minimal fixtures from your description and deploying both to real production workerd on the same compat date as your
wrangler.jsonc, neither reproduces the throw. So before we land any library fix, we need a couple more details from your side to find the actual trigger.What would help most
In rough decreasing order of how much each would narrow the suspect list:
Your
Subclass definition (or a stripped-down version) — particularly:onStart()body, even just a paraphrase ("registers an MCP server, starts a fiber, callsparentAgent(Inbox), …")alarm(),onConnect,onChatMessage, etc.childConfigyou pass tostub.configure(...)— specifically, does anything in there carry a Native handle (aRequest, aReadableStream, anAbortSignal, a callback that closes over a parent-IoContext object)?Whether your parent itself is a facet — i.e. is the parent calling
subAgent(Sub, ...)itself the top-level Agent for a given DO id, or is it itself the result of a higher-upsubAgent(...)call?parentPath.lengthon the failing_cf_initAsFacet'sparentPathargument would answer this in one byte.Cold-wake vs first-spawn timing. Does the throw fire on the very first
subAgent(Sub, name)call against a freshly-named parent DO that has never existed before? Or only after the parent has been around for some non-zero time / has been evicted at least once?Whether our deployed fixture URLs (below) also error from your account / region. If they 500 when curled from an environment routed to whichever workerd version you're hitting, that suggests a workerd-build skew on your side rather than something specific to your code. If they 200 for you too, the trigger is definitely in your app.
Bundle hash sanity check.
shasum dist/<your-bundle>.jsof the worker you have deployed, just so we can rule out a build-pipeline transform that's mangling_cf_initAsFacet(e.g., aggressive minifier rewritingthis.nameaccess in a way that hits a different code path). Long shot, but cheap to check.If you can share even a paraphrased
onStart()and roughchildConfigshape (item 1), I'll add that to the deployed fixture and redeploy. As soon as it starts 500'ing on/agents/<name>, we have a bisection target and a path to the actual fix.Why we need this — the deployed evidence so far
Both fixtures are on
compatibility_date = "2026-04-01"andcompatibility_flags = ["nodejs_compat"], matching yourwrangler.jsoncexactly.partyserver-fixture-facet-id-ioctxPlain
partyserver, noagents. AFacetParent extends Servercalls— exactly the shape
agents._cf_resolveSubAgentproduces, just without the framework wrapping. The facet's RPC then exposes:/partyserver/<name>—this.name, which ispartyserver/src/index.ts:776's getterreturn this.ctx.id.name; .../raw/<name>—this.ctx.id.namedirectly inside the facet, bypassing partyserver's getter/faulted/<name>— synthetic fault-injection (Proxy onctx.idthat throws the verbatim(I/O type: Native)error message); only used to sanity-check that the error-rendering path renders the production wording when something does throwDeployed at: https://partyserver-fixture-facet-id-ioctx.threepointone.workers.dev
/partyserver/test-facet200{"ok":true,"value":"test-facet"}/raw/test-facet200{"ok":true,"value":"test-facet"}/partyserver/<fresh-uuid>(forces factory)200/raw/<fresh-uuid>(forces factory)200/partyserver/<cached>from separate parent IoContexts200/faulted/<name>500, error = verbatim production stringagents-sub-agent-id-ioctx-reproSame shape, but using the agents framework —
ParentAgent extends Agent, callingthis.subAgent(ChildAgent, name)which goes through_cf_resolveSubAgent→_cf_initAsFacetexactly. Same three endpoints, ported.Deployed at: https://agents-sub-agent-id-ioctx-repro.threepointone.workers.dev
/agents/test-facet200/raw/test-facet200/agents/<fresh-uuid>× 2200,200/agents/<cached>200/raw/<cached>200The agents code in this fixture is byte-identical to
agents@0.11.7for the relevant file:$ git diff 5aaf7c44..HEAD -- packages/agents/src/index.ts | wc -l 0(
5aaf7c44is PR #1393, the 0.11.6 commit;HEADis the 0.11.8-snapshot the fixture deploys. Everything since 0.11.6 is inagents/src/chat/*and tests —_cf_initAsFacetand_cf_resolveSubAgenthaven't been touched.)What the disconfirmation rules out
The two specific claims I pulled from the OP:
are both disconfirmed by the deployed evidence above. Production workerd happily reads
this.ctx.id.namefrom the facet, both via partyserver's getter and via direct property access. Theif (this.name !== name)guard introduced by #1393 is not on the failing path for the model we built.That's a strong negative signal — strong enough to suggest the actual trigger is something specific to your sub-agent class or your spawn sequence that our fixtures don't model. The error message you're seeing in production is real (the
(I/O type: Native)wording is a verbatim workerd string we can't fake), but the line of code producing it isn't thethis.nameread at the top of_cf_initAsFacet.Where this leaves things
We're not going to ship the partyserver / agents defensive fixes I'd outlined for this report yet — they would have been "wrap
ctx.id.namein try/catch + restore the__ps_namestorage write in_cf_initAsFacet", and now that we know the throw isn't fromctx.id.name, that fix wouldn't help your actual code path. Better to keep the surface clean and find the real trigger first. Once item 1 above is in hand we can cut a fix in days.threepointone commentedon Apr 28, 2026
tldr, if you can make a repro, that would be super helpful
MingkaiChen commentedon Apr 29, 2026
Thanks for your prompt reply! I have observed some more complex and strange behavior / errors. I will consolidate them into a demo repo and get back to you as soon as possible.
MingkaiChen commentedon Apr 29, 2026
I have a minimal public repro now:
https://github.com/Gyges-Labs/cf-agents-1410-repro
Commit tested:
807198c7664567c630b68fbd2651264360ac8dcbThis repro removes all app-specific code. It is just:
ParentAgent extends AgentChildAgent extends AgentGET /http-spawnas a control pathWS /wsas the failing pathThe important bit is that
ParentAgent.onMessage()calls the samespawnChild()method that the HTTP control route calls.Relevant code:
ParentAgent.onMessage():https://github.com/Gyges-Labs/cf-agents-1410-repro/blob/807198c7664567c630b68fbd2651264360ac8dcb/src/index.ts#L29-L54
ParentAgent.spawnChild():https://github.com/Gyges-Labs/cf-agents-1410-repro/blob/807198c7664567c630b68fbd2651264360ac8dcb/src/index.ts#L56-L82
The failing
this.subAgent(ChildAgent, childName)call:https://github.com/Gyges-Labs/cf-agents-1410-repro/blob/807198c7664567c630b68fbd2651264360ac8dcb/src/index.ts#L60
ChildAgent.ping()only touches its own storage:https://github.com/Gyges-Labs/cf-agents-1410-repro/blob/807198c7664567c630b68fbd2651264360ac8dcb/src/index.ts#L95-L106
Routes:
https://github.com/Gyges-Labs/cf-agents-1410-repro/blob/807198c7664567c630b68fbd2651264360ac8dcb/src/index.ts#L109-L136
Wrangler config:
https://github.com/Gyges-Labs/cf-agents-1410-repro/blob/807198c7664567c630b68fbd2651264360ac8dcb/wrangler.toml
The repo uses:
How to reproduce
Deploy the repo:
Then run the WebSocket path:
Expected failing output:
{ "ok": true, "phase": "after-subAgent", "result": { "ok": false, "error": { "name": "Error", "message": "Cannot perform I/O on behalf of a different Durable Object. I/O objects (such as streams, request/response bodies, and others) created in the context of one Durable Object cannot be accessed from a different Durable Object in the same isolate. This is a limitation of Cloudflare Workers which allows us to improve overall performance. (I/O type: Native)" } } }In my deployed run, the stack was:
The HTTP/RPC control path calls the same
spawnChild()method, but not from inside a WebSocketonMessage()handler:curl "https://<your-worker>.<your-subdomain>.workers.dev/http-spawn?parent=http-control-$(date +%s)"That path succeeds for me and returns a valid child response from
ChildAgent.ping().Updated analysis
Your earlier fixtures were right to disconfirm my original narrower theory that the issue is simply
ctx.id.name/this.nameinside a facet. The missing condition is the WebSocket message handler.The repro suggests the actual trigger is:
agentshandles a WebSocket message and invokesParentAgent.onMessage(connection, message).this.subAgent(...)while still inside that WebSocket message turn.connectionnative object.subAgent()enters_cf_resolveSubAgent()and performs the internal child facet bootstrap._cf_initAsFacet, child-side Durable Object I/O happens while a native object from the parent DO’s WebSocket context is still present.That also explains why an HTTP/RPC fixture does not reproduce the issue: it does not carry the parent WebSocket connection object through the current agent context.
So I think the fix is probably not in user code and not in the app-specific child class. It is likely in the
agentscontext handling around WebSocket calls and/or sub-agent facet bootstrap.Possible fix shape:
withAgentContext()crosses from one Agent / DO / facet instance to another, do not preserveconnection,request, oremailnative objects from the previous Agent context.subAgent()/_cf_resolveSubAgent()/_cf_initAsFacet()bootstrap path.This repro should be much closer to the actual trigger than my original issue description.
threepointone commentedon Apr 30, 2026
this level of detail was extremely helpful, thank you so much! fixed in #1425. I'll do a release today.
chore(agents): remove redundant _suppressProtocolBroadcasts facet gua…