Skip to content

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

Closed

Description

@MingkaiChen

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 RPC

stub.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) to ctx.facets.get() so the facet has its own ctx.id.name. Drops the __ps_name storage write and setName() 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:

"id DurableObjectId | string Optional — The ID the facet sees as its own ctx.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 DurableObjectId bound to the facet's own IoContext (preferred — matches the implicit contract that ctx.id is a per-DO Native value).
  • Or, at minimum, a DurableObjectId whose property access (.name, .toString(), .equals(...)) does NOT trip the cross-IoContext check 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_initAsFacet in the user's Agent subclass to skip this.name. Insufficient: partyserver's private #ensureInitialized (called from every entry point including alarms, fetches, and websocket handlers) still reads this.ctx.id.name directly to choose between #persistNameFallbackFromCtxId and the legacy storage-hydrate path. We can't override a private method, and the read itself is the throw site, so subclassing name to ignore ctx.id doesn'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-bound ctx.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's setName reads ctx.id.name BEFORE comparing against the supplied name, 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)

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

  1. threepointone commented on Apr 28, 2026

    @threepointone
    Contributor

    thanks for filing this. does downgrading fix it for you?

  2. threepointone commented on Apr 28, 2026

    @threepointone
    Contributor

    I'm releasing in 20 minutes some fixes, try those as well.

  3. MingkaiChen commented on Apr 28, 2026

    @MingkaiChen
    Author

    thanks for filing this. does downgrading fix it for you?

    Unfortunately, as noted in the issue, version 0.11.5 reproduces the same throw later in the boot sequence.

  4. threepointone commented on Apr 28, 2026

    @threepointone
    Contributor

    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.

  5. MingkaiChen commented on Apr 28, 2026

    @MingkaiChen
    Author

    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.

    Unfortunately, 0.11.8 indeed did not resolve the issue. I have included in the issue description a portion of my analysis of this issue and potential solutions.

  6. threepointone commented on Apr 28, 2026

    @threepointone
    Contributor

    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:

    1. Your Sub class definition (or a stripped-down version) — particularly:

      • the onStart() body, even just a paraphrase ("registers an MCP server, starts a fiber, calls parentAgent(Inbox), …")
      • whether you set any class fields or in-memory state in the constructor
      • whether you override alarm(), onConnect, onChatMessage, etc.
      • the type of the childConfig you pass to stub.configure(...) — specifically, does anything in there carry a Native handle (a Request, a ReadableStream, an AbortSignal, a callback that closes over a parent-IoContext object)?
    2. 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-up subAgent(...) call? parentPath.length on the failing _cf_initAsFacet's parentPath argument would answer this in one byte.

    3. 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?

    4. 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.

    5. Bundle hash sanity check. shasum dist/<your-bundle>.js of the worker you have deployed, just so we can rule out a build-pipeline transform that's mangling _cf_initAsFacet (e.g., aggressive minifier rewriting this.name access in a way that hits a different code path). Long shot, but cheap to check.

    If you can share even a paraphrased onStart() and rough childConfig shape (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" and compatibility_flags = ["nodejs_compat"], matching your wrangler.jsonc exactly.

    partyserver-fixture-facet-id-ioctx

    Plain partyserver, no agents. A FacetParent extends Server calls

    const facetId = ParentNs.idFromName(facetName);   // parent's IoContext
    this.ctx.facets.get(facetKey, () => ({ class: FacetChild, id: facetId }));

    — exactly the shape agents._cf_resolveSubAgent produces, just without the framework wrapping. The facet's RPC then exposes:

    • /partyserver/<name> — this.name, which is partyserver/src/index.ts:776's getter return this.ctx.id.name; ...
    • /raw/<name> — this.ctx.id.name directly inside the facet, bypassing partyserver's getter
    • /faulted/<name> — synthetic fault-injection (Proxy on ctx.id that 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 throw

    Deployed at: https://partyserver-fixture-facet-id-ioctx.threepointone.workers.dev

    variant observed
    /partyserver/test-facet 200 {"ok":true,"value":"test-facet"}
    /raw/test-facet 200 {"ok":true,"value":"test-facet"}
    /partyserver/<fresh-uuid> (forces factory) 200
    /raw/<fresh-uuid> (forces factory) 200
    5× /partyserver/<cached> from separate parent IoContexts 5× 200
    /faulted/<name> 500, error = verbatim production string

    agents-sub-agent-id-ioctx-repro

    Same shape, but using the agents framework — ParentAgent extends Agent, calling this.subAgent(ChildAgent, name) which goes through _cf_resolveSubAgent → _cf_initAsFacet exactly. Same three endpoints, ported.

    Deployed at: https://agents-sub-agent-id-ioctx-repro.threepointone.workers.dev

    variant observed
    /agents/test-facet 200
    /raw/test-facet 200
    /agents/<fresh-uuid> × 2 200, 200
    5× /agents/<cached> 5× 200
    5× /raw/<cached> 5× 200

    The agents code in this fixture is byte-identical to agents@0.11.7 for the relevant file:

    $ git diff 5aaf7c44..HEAD -- packages/agents/src/index.ts | wc -l
           0

    (5aaf7c44 is PR #1393, the 0.11.6 commit; HEAD is the 0.11.8-snapshot the fixture deploys. Everything since 0.11.6 is in agents/src/chat/* and tests — _cf_initAsFacet and _cf_resolveSubAgent haven't been touched.)

    What the disconfirmation rules out

    The two specific claims I pulled from the OP:

    (1) "the throw happens on the FIRST line of _cf_initAsFacet"

    (2) "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"

    are both disconfirmed by the deployed evidence above. Production workerd happily reads this.ctx.id.name from the facet, both via partyserver's getter and via direct property access. The if (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 the this.name read 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.name in try/catch + restore the __ps_name storage write in _cf_initAsFacet", and now that we know the throw isn't from ctx.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.

  7. threepointone commented on Apr 28, 2026

    @threepointone
    Contributor

    tldr, if you can make a repro, that would be super helpful

  8. MingkaiChen commented on Apr 29, 2026

    @MingkaiChen
    Author

    tldr, if you can make a repro, that would be super helpful

    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.

  9. MingkaiChen commented on Apr 29, 2026

    @MingkaiChen
    Author

    tldr, if you can make a repro, that would be super helpful

    I have a minimal public repro now:

    https://github.com/Gyges-Labs/cf-agents-1410-repro

    Commit tested: 807198c7664567c630b68fbd2651264360ac8dcb

    This repro removes all app-specific code. It is just:

    • ParentAgent extends Agent
    • ChildAgent extends Agent
    • GET /http-spawn as a control path
    • WS /ws as the failing path

    The important bit is that ParentAgent.onMessage() calls the same spawnChild() method that the HTTP control route calls.

    Relevant code:

    The repo uses:

    compatibility_date = "2026-04-01"
    compatibility_flags = ["nodejs_compat"]
    
    [[durable_objects.bindings]]
    name = "PARENT"
    class_name = "ParentAgent"
    
    [[migrations]]
    tag = "v1"
    new_sqlite_classes = ["ParentAgent", "ChildAgent"]

    How to reproduce

    Deploy the repo:

    bun install
    bun run deploy

    Then run the WebSocket path:

    bun -e '
    const ws = new WebSocket("wss://<your-worker>.<your-subdomain>.workers.dev/ws?parent=ws-repro-" + Date.now());
    ws.onmessage = (event) => console.log(event.data);
    ws.onopen = () => ws.send("spawn child from websocket onMessage");
    setTimeout(() => ws.close(), 10000);
    '

    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:

    Error: Cannot perform I/O on behalf of a different Durable Object. ...
        at async ParentAgent._cf_resolveSubAgent (index.js:47137:5)
        at async ParentAgent.subAgent (index.js:47106:12)
        at async ParentAgent.spawnChild (index.js:48405:21)
        at async ParentAgent.onMessage (index.js:48393:20)
        at async ParentAgent._tryCatch (index.js:46001:14)
    

    The HTTP/RPC control path calls the same spawnChild() method, but not from inside a WebSocket onMessage() 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.name inside a facet. The missing condition is the WebSocket message handler.

    The repro suggests the actual trigger is:

    1. agents handles a WebSocket message and invokes ParentAgent.onMessage(connection, message).
    2. User code calls this.subAgent(...) while still inside that WebSocket message turn.
    3. The current agent async context appears to still contain the parent WebSocket connection native object.
    4. subAgent() enters _cf_resolveSubAgent() and performs the internal child facet bootstrap.
    5. During _cf_initAsFacet, child-side Durable Object I/O happens while a native object from the parent DO’s WebSocket context is still present.
    6. workerd rejects that with the cross-DO Native I/O error.

    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 agents context handling around WebSocket calls and/or sub-agent facet bootstrap.

    Possible fix shape:

    • When withAgentContext() crosses from one Agent / DO / facet instance to another, do not preserve connection, request, or email native objects from the previous Agent context.
    • Or explicitly clear those native objects around the internal subAgent() / _cf_resolveSubAgent() / _cf_initAsFacet() bootstrap path.

    This repro should be much closer to the actual trigger than my original issue description.

  10. threepointone commented on Apr 30, 2026

    @threepointone
    Contributor

    this level of detail was extremely helpful, thank you so much! fixed in #1425. I'll do a release today.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions