Skip to content

The companion ref is the claim (CP-161) ​

This guide covers the release in which a persistent companion's spawnAgent is answered only after its child's run-start commit, and in which the parent's sub-session ref of the companion became the spawn's claim: one ref per name, moved only by compare-and-set writes scoped to the ref's outcome generation, and fenced on the parent run. It is aimed at people who implement their own SessionStateStore (or a wrapper around one), their own runtime, or their own companion dispatch deps.

Versions:

  • @helix-agents/core and every store package (store-memory, store-redis, store-postgres, store-cloudflare) take a minor (0.x packages: minor marks a breaking interface or observable behaviour change; read this guide before upgrading).
  • @helix-agents/runtime-cloudflare takes a major: the CF Durable Object store (DOStateStore) changed public signatures (below).
  • @helix-agents/runtime-js, runtime-temporal and runtime-dbos take a minor (observable behaviour; Temporal and DBOS also take the required companion dispatch deps below).
  • @helix-agents/ai-sdk takes a major too, with no change of its own: it lists @helix-agents/runtime-cloudflare as an (optional) peer dependency (workspace:^), and runtime-cloudflare's major moves it out of that peer range, so Changesets bumps the peer dependent a major as well (onlyUpdatePeerDependentsWhenOutOfRange). pnpm changeset status shows it; upgrade the two together.

No migration. The ref columns the claim uses (outcome_generation, parent_tool_call_id, the DO's consult columns) already exist. Deploy client and server together; a CF Durable Object parent and its companion children must run the same version (the DO-internal generation header below).

What was wrong ​

  • A companion's first spawn answered spawned before its child's start ran, so a refused start surfaced later as an untyped notice, or never.
  • Two same-name spawns (in one step, or in two runs of the parent) each wrote the shared ref: the loser's write landed on the winner's child (a stale outcome, a delete of a live session, an outcome never delivered).
  • A superseded parent run kept mutating the successor's child, and a run that died after its claim stranded the name (companion__spawnAgent refused it as already running forever).

The contract ​

The ref generation ​

SubSessionRef.outcomeGeneration (0 until the first reopen) is written only by the store: every write that moves a NON-live ref to a live status increments it (a reset, a continue's or resume's reopen). It identifies one entry of the child: every outcome write of that entry is scoped to it.

The four writes ​

WriteWhoShapeRefused
Claima first spawnaddSubSessionRefs(parent, [ref]) → { inserted: true, generation } (the generation the new ref reads at: 0, or the CF DO's floor, below) or { inserted: false, ref } (the stored ref, as a read returns it), decided in the insert's atomic stepnot refused: the answer says who holds the name. Another call's LIVE ref → the spawn answers child_busy before any child IO
Reseta respawn of a terminal ref at gupdateSubSessionRef(parent, { status: 'running', completionDelivered: false, parentToolCallId: call, expect: { op: 'reset', statuses: ['completed', 'failed', 'terminated'], generation: g, parentRunId } }) → the ref lands at g + 1, stamped with the callSubSessionRefConflictError (op reset), nothing written
Releasean entry's own definite non-start, a superseded run's own claim / reset, a throw before the spawn dep, a CF Workflows child refused by its parentRunFence (releaseSpawnClaimForRefusedChild), the reclaimupdateSubSessionRef(parent, { ...the release's fields, expect: { op: 'release', statuses: ['running', 'paused_awaiting_client'], generation: g, parentRunId?, parentToolCallId: call } }) (core releaseSpawnClaim, which requires the call): a first spawn's ref failed + completedAt + completionDelivered: true (the refused child's release too, since D1: it did no work, so no report reaches the model; logged companion.spawn.refused_child_report_dropped); a respawn's exact restore (its old status, completion time, delivered mark and call)SubSessionRefConflictError (op release): another entry moved the ref since, or another call opened it; nothing written, never a newer generation
Outcome writethe child's word, a delivered mark, a lazy sync, an orphan compensation, CF Workflows' stop cascade (CP-159's local-child terminal and stopOwed writes)updateSubSessionRef(parent, { status, ..., atGeneration: g }) (core writeChildOutcome)answered, never thrown: { written: false, reason: 'stale_generation', actualGeneration } (another generation) or { written: false, reason: 'terminated' } (the parent terminated this generation: an outcome write never replaces terminated)

A write with expect is a CAS on status and generation together (core subSessionRefExpectRefusal); expect and atGeneration are never combined. parentToolCallId, when given, is written in the same atomic step.

A claim's generation is the one the store reports. The claim's holder scopes everything to it: the spawn dep's refGeneration, its release, the lost-reply release (a claim insert whose reply was lost is re-read and released at the generation it reads at). Never assume a claim is at generation 0.

A release names its call (SubSessionRefExpect.parentToolCallId, optional on the type, always passed by core's releaseSpawnClaim): the same CAS also requires the ref's parentToolCallId (the call that opened its current generation) to be that call, else SubSessionRefConflictError (expected.parentToolCallId and actual.parentToolCallId then name both calls), nothing written. Why: a respawn of a parent deletes its refs (core deleteSession), and the parent's new incarnation claims the name again at generation 0; a late child of the OLD incarnation's call (CF Workflows, refused by its parentRunFence) must not release the new claim. The guard relies on tool-call ids being unique per call (provider ids are); a custom LLM adapter or a test script that reuses one id across a parent's incarnations weakens it.

The parent-run fences ​

  • SubSessionRefExpect.parentRunId (any expect op: a reset, a release, the reclaim): the same atomic step first requires that run to be the parent session's current live run (core companionParentRunFenceRefusal), else RunSupersededError (state_run_superseded), nothing written.
  • ResumedCompanionRef.expect ({ statuses, generation, parentRunId? }) on a continue's or resume's run-start commit: the reopen is a CAS on the ref the parent read (RunStartRejectedError('resumed_companion_ref_moved')) and fenced on the parent run (RunStartRejectedError('parent_run_superseded')); the reopen stamps StartRun.parent.toolCallId on the ref.
  • StartRun.parentRunFence ({ parentSessionId, parentRunId }) on a CF Workflows spawn child's run-start commit: the parent run that created the child must still be the parent's current live run, read in the commit's atomic step, else RunStartRejectedError('parent_run_superseded'), nothing written. Not part of startDigest.

The reclaim ​

A successor run's spawn that meets ANOTHER call's live claim of its name may release it when it is stranded (core isStrandedSpawnRef): a local, persistent running ref, whose call has no result (or only a synthetic not-executed one), that is not one of the current run's own calls, and that no child run serves (no run, or a terminal run of another call). The write is the release above with expect.parentRunId = the reclaiming run; the spawn then proceeds as a respawn. Its safety rests on two store guards a custom store must keep: a late child of the claiming run is refused by parentRunFence, and that run's own ref writes by the parent-run fence. Its runtime preconditions are listed in the core reference (reclaimStrandedSpawnRef).

If you implement a store ​

  • addSubSessionRefs resolves AddSubSessionRefResult[], per ref, in input order, decided in the insert's atomic step (a re-read after the insert, in the same transaction or batch, for { inserted: false, ref }).
  • updateSubSessionRef resolves UpdateSubSessionRefOutcome (required): return your store's own answer, never { written: true } for a write you did not make, so a refusal stays observable.
  • Apply in ONE atomic step: the parent-run fence (any expect.parentRunId), the expect CAS (status, generation and, when expect.parentToolCallId is given, the ref's call: pass the stored parentToolCallId to subSessionRefExpectRefusal), the atGeneration staleness and terminated rules, the reopen increment, and the parentToolCallId stamp.
  • addSubSessionRefs' { inserted: true, generation } is the generation a read of the new ref returns (0 for a store whose insert writes no generation).
  • On a run-start commit: resumedCompanionRef.expect (CAS + fence + stamp) and parentRunFence, both after the idempotent-replay decision. A store that cannot read the parent's runs in the child's atomic step (the CF DO's DOStateStore) must refuse both fields with UnsupportedOperationError, never write the run without them.
  • Keep the one-live-run invariant: a run-start commit supersedes every other live run of the session (run.superseded-on-entry, run.single-live).
  • Run the contract suites: subSessionRefOperationTests (claim.*, reset.*, release.*, outcome.*, settle.*, reclaim.*) and runStartOperationTests (commit.start-companion-ref-*, commit.start-parent-run-fence), both part of stateStoreContractTests. A store that refuses resumedCompanionRef and parentRunFence passes resumedCompanionRef: 'refused' to stateStoreContractTests (it covers both fields); called directly, subSessionRefOperationTests takes { parentRunFence: 'refused' }.

If you build companion dispatch deps or a runtime ​

  • CompanionToolDispatchDeps.assertParentRunOwned (the run-owns-session check) and parentRunId are required: the dispatcher asserts before every child mutation, and only the parent run's own RunSupersededError escapes executeCompanionToolDispatch (isOwnRunSupersededError).
  • releaseSpawnClaim requires parentToolCallId (the call the claim or reset was made for; the reclaim passes the stranded call), and releaseSpawnClaimForRefusedChild takes the refused child's own parentToolCallId.
  • CompanionToolDispatchDeps.claimGeneration (optional): the generation a claim of this parent inserts at now. A dep whose store inserts above 0 (the CF DO's floor) must supply it, so the dispatcher reads a same-call replay of a claim at that generation as the claim's, never as a reset's replay. Absent: 0.
  • SpawnPersistentChildInput.entryKind and refGeneration are passed to your spawn dep: a definite non-start owes the release at that generation (core companionSpawnNonStartRefUpdate). A continue dep reports the generation its reopen opened (SpawnPersistentChildResult.refGeneration), and recordedConsultOutcome the generation it read, so the blocking park's write is scoped.
  • StopPersistentChild takes a fourth argument, target: { consult, generation }: stop only the run serving that consult, and record the stop before the ref is written terminated.
  • A tool must never construct its own run's RunSupersededError.

The CF Durable Object ​

  • DOStateStore signatures (breaking): beginChildConsult(parentSessionId, subSessionId, consult, fence: { parentRunId }) (the fence is required), addSubSessionRefs and updateSubSessionRef answer as above. It refuses resumedCompanionRef and parentRunFence (UnsupportedOperationError).
  • The DO-internal header x-helix-parent-ref-generation (PARENT_REF_GENERATION_HEADER, parsed only by parentRefGenerationHeaderSchema(): canonical decimal) rides every companion /start. The child DO refuses a /start below the highest generation it recorded (409, cause stale_parent_ref_generation) and records one inside its run-start claim block. createRemoteAgentWorkerHandler strips it at the public boundary; an app that forwards raw DO paths must strip it too.
  • The parent-side generation floor (DOStateStoreOptions.kv). The child's record is DO-wide and never cleared, and a child DO (${parent}-agent-${name}) outlives its parent's respawn wipe, which deletes the parent's refs. So the parent DO keeps a floor in its own KV (helix:companion:child-ref-generation-floor): wipeSessionSync raises it to max(the wiped refs' generations) + 1 before deleting them, in the same transaction, and addSubSessionRefs inserts a new ref at the floor (and reports it). DurableObjectAgentBase passes ctx.storage.kv; a DO built without it keeps the floor at 0 (a wipe that deletes refs logs do.sub_session_ref_floor_unavailable at error), which is only right for a DO that hosts no companion parent. A delayed /start of the parent's old incarnation is still refused: its generation is below the floor.
  • POST /companion/parent-ref-fence (DO-to-DO only, not a worker action): the parent fences an unconfirmed consult whose child answered 404, so its late /start is refused before the name is freed.

What your users see ​

  • companion__spawnAgent answers { name, status: 'spawned', delivered: true } only once the child's run-start committed, else { name, status: 'not_spawned', delivered: false, reason, errorDetail? } (child_busy, child_start_unconfirmed, child_entry_failed, child_terminated), a successful tool result.
  • A same-name spawn while another call holds the name answers child_busy. A name a dead run stranded is freed by the next spawn of it (CF Workflows). A store error before the spawn dep (a failed assert, a lost claim or reset reply) is released by the call itself on every runtime that spawns through core's dispatcher (runtime-js, the CF DO, CF Workflows, Temporal; not DBOS, which runs its own spawn handler).
  • On CF Workflows, a FIRST spawn's child refused because its parent run moved on is released failed, delivered: a refused child did no work, so no next-turn report reaches the model (D1; logged at info, companion.spawn.refused_child_report_dropped). A respawn's refused child restores the ref exactly. A child that actually ran keeps its report.

Not yet ​

runtime-js, Temporal and DBOS do not reclaim a claim their spawner died holding (FU-SPAWNER-STRANDS-CLAIM-OTHER-RUNTIMES: CP-190, CP-191, CP-192); Temporal and DBOS run their own companion paths (FU-TEMPORAL-DBOS-COMPANION-CLAIM-CALLERS). Design: docs/superpowers/specs/2026-10-19-cp161-companion-first-spawn-entry-design.md in the repository (§16 and earlier "As built" sections).

Released under the MIT License.