Skip to content

Migrate facet bootstrap to partyserver setName() (drops direct __ps_name storage write) #1385

Description

@threepointone

Context

partyserver 0.5.0 made this.name resolve from ctx.id.name natively, eliminating the need for setName() round-trips for normal idFromName()/getByName() DOs. That change initially missed the case where ctx.id.name is undefined — which is exactly what facets get, since they're spawned via ctx.facets.get(...) rather than idFromName().

This package's _cf_initAsFacet() works around the gap by writing __ps_name directly to storage and calling __unsafe_ensureInitialized(), relying on partyserver's internal #hydrateNameFromStorage() to pick the record back up. That's been a layering violation — the magic key __ps_name is partyserver's internal storage layout, not a public contract.

partyserver 0.5.1 fixed the regression by restoring the legacy storage hydrate inside #ensureInitialized() (gated so the happy path still pays zero storage reads). The current _cf_initAsFacet() pattern works correctly against 0.5.1 with no code changes here.

partyserver 0.5.2 (cloudflare/partykit#383, pending merge) goes further and makes setName(name) itself persist __ps_name to storage when ctx.id.name is undefined. That makes setName() the sanctioned bootstrap primitive for non-idFromName DOs, and lets us drop the magic-string storage write here.

What this issue tracks

Two coordinated steps:

Step 1 (required): Bump partyserver to ^0.5.1 (or 0.5.2)

We're currently pinned to partyserver: ^0.4.1 (packages/agents/package.json:devDependencies). ^0.4.1 is locked to 0.4.x by npm semver — we won't pick up 0.5.x automatically. The 0.5.x line has:

  • this.name from ctx.id.name — available in the constructor, in class field initializers, and on every entry point. Major capability addition.
  • @cloudflare/workers-types peer dep bumped to ^4.20260424.1. Confirm any local @cloudflare/workers-types versions are compatible.
  • setName() / _initAndFetch() are @deprecated for normal callers but explicitly retained as the supported API for framework bootstrap (us). The setName(name) signature now throws if name !== ctx.id.name.
  • Routing changed: routePartykitRequest no longer issues a setName()/_initAndFetch() RPC before fetch(). Props (when supplied) travel as the x-partykit-props request header instead of an RPC argument.
  • Storage: __ps_name is no longer auto-written by partyserver itself; existing storage records are read back as a fallback (which is what kept facets working in 0.5.1).

Bumping requires:

  • Update packages/agents/package.json devDependencies.partyserver to ^0.5.2 (once 0.5.2 is published) or ^0.5.1 (works today, just leaves Step 2 for later).
  • Update peer ranges if needed.
  • Run the full test suite, especially tests/sub-agent.test.ts and tests/sub-agent-routing.test.ts to verify facets still bootstrap correctly.
  • If anything reads connection.server instead of this.name, note that connection.server is @deprecated in 0.5.x.

Step 2 (optional, requires 0.5.2): Migrate _cf_initAsFacet() from direct storage write to setName()

Current code

packages/agents/src/index.ts around line 3721:

async _cf_initAsFacet(
  name: string,
  parentPath: ReadonlyArray<{ className: string; name: string }> = []
): Promise<void> {
  this._isFacet = true;
  this._parentPath = parentPath;
  // Persist the facet flag, name, and ancestor chain in parallel —
  // all three writes are independent and fire against the same
  // storage. \`__ps_name\` is the same key \`Server#setName()\` writes,
  // so \`#hydrateNameFromStorage()\` picks it up without a round-trip.
  await Promise.all([
    this.ctx.storage.put("cf_agents_is_facet", true),
    this.ctx.storage.put("__ps_name", name),
    this.ctx.storage.put("cf_agents_parent_path", parentPath)
  ]);
  await this.__unsafe_ensureInitialized();
}

Proposed migration

async _cf_initAsFacet(
  name: string,
  parentPath: ReadonlyArray<{ className: string; name: string }> = []
): Promise<void> {
  this._isFacet = true;
  this._parentPath = parentPath;
  // Persist facet-specific state in parallel; setName handles the
  // partyserver name (in-memory + __ps_name storage + onStart trigger)
  // sequentially after.
  await Promise.all([
    this.ctx.storage.put("cf_agents_is_facet", true),
    this.ctx.storage.put("cf_agents_parent_path", parentPath)
  ]);
  await this.setName(name);
}

Notes:

  • setName(name) for facets (where ctx.id.name === undefined) does the storage write internally AND calls #ensureInitialized() which runs onStart(). Equivalent to the old two-step.
  • The Promise.all shrinks from 3 writes to 2; setName() runs sequentially after because it also runs onStart().
  • For non-facet DOs (ctx.id.name defined), setName() is a no-op on storage — same behavior as today.
  • Partial-failure semantics are slightly improved: if storage write fails, #_name stays unset and a retry re-attempts (instead of leaving in-memory and storage divergent).

Stale comments to update

Same file, around line 3329-3344:

/**
 * Override PartyServer's onAlarm hook as a no-op.
 * Agent handles alarm logic directly in the alarm() method override,
 * but super.alarm() calls onAlarm() after #ensureInitialized(),
 * so we suppress the default "Implement onAlarm" warning.
 */
onAlarm(): void {}

/**
 * Method called when an alarm fires.
 * ...
 * Calls super.alarm() first to ensure PartyServer's #ensureInitialized()
 * runs, which hydrates this.name from storage and calls onStart() if needed.
 * ...
 */
async alarm() {
  await super.alarm();
  ...
}

The "hydrates this.name from storage" wording is still technically accurate post-0.5.x (the storage hydrate now lives inside #ensureInitialized(), gated on !ctx.id.name && !#_name). But it's clearer to phrase it as "resolves this.name (from ctx.id.name, or the legacy storage record for facets)" — the storage path is the secondary fallback now, not the primary mechanism.

Files to audit

Within packages/agents/:

  1. src/index.ts — primary target (_cf_initAsFacet at ~line 3721; comments at ~lines 3329-3344). Also check the three other __unsafe_ensureInitialized() call sites at lines 5271, 5280, 5292 — those are RPC-bridge calls and should keep working unchanged.
  2. package.jsondevDependencies.partyserver bump.
  3. src/tests/sub-agent.test.ts and src/tests/sub-agent-routing.test.ts — verify facet bootstrap, RPC invocation, and cold-wake-via-fetch all still work end to end.
  4. src/tests/alarms.test.ts — verify super.alarm() still hydrates this.name correctly for both regular agents and facets.
  5. AGENTS.md / README.md / CHANGELOG.md — release notes if any user-visible behavior shifts (it shouldn't, but worth a sanity pass).

Suggested execution order in a Cursor session

  1. Bump partyserver to ^0.5.1 first. Run the test suite as-is. Everything should pass — partyserver 0.5.1 is wire-compatible with the current _cf_initAsFacet pattern via the storage hydrate fallback.
  2. Wait for partyserver 0.5.2 to publish (or pin to a workspace path during dev). Bump to ^0.5.2.
  3. Apply the _cf_initAsFacet migration above. Update the alarm() docstring.
  4. Run the full test suite again, especially the facet/sub-agent paths.
  5. Manual smoke test: spin up a parent agent with a child facet, exercise (a) initial facet creation, (b) facet RPC invocation via getSubAgentByName, (c) facet HTTP routing via _cf_forwardToFacet, (d) facet alarm firing, (e) facet cold-wake (kill the worker, send a request, ensure this.name resolves).

Things NOT to change

  • The three __unsafe_ensureInitialized() calls at lines 5271, 5280, 5292 — those are user-RPC bridges that need to keep ensuring onStart() has run. Unchanged behavior.
  • The other Agents-specific storage keys (cf_agents_is_facet, cf_agents_parent_path) — those are Agents's own and stay as direct writes.
  • Any of the parent-side subAgent() / _cf_forwardToFacet() logic — that's unchanged.

Long-term

Cloudflare runtime team is exploring exposing ctx.id.name (or an equivalent) for facets natively. Once that lands, setName() for facets becomes a no-op (the runtime carries the name, just like it does for idFromName DOs), and we can simplify _cf_initAsFacet further to just write the agent-specific keys. Tracking that as a separate workerd-side conversation.

References

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