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/coreand 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-cloudflaretakes a major: the CF Durable Object store (DOStateStore) changed public signatures (below).@helix-agents/runtime-js,runtime-temporalandruntime-dbostake a minor (observable behaviour; Temporal and DBOS also take the required companion dispatch deps below).@helix-agents/ai-sdktakes a major too, with no change of its own: it lists@helix-agents/runtime-cloudflareas 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 statusshows 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
spawnedbefore 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__spawnAgentrefused 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
| Write | Who | Shape | Refused |
|---|---|---|---|
| Claim | a first spawn | addSubSessionRefs(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 step | not refused: the answer says who holds the name. Another call's LIVE ref → the spawn answers child_busy before any child IO |
| Reset | a respawn of a terminal ref at g | updateSubSessionRef(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 call | SubSessionRefConflictError (op reset), nothing written |
| Release | an 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 reclaim | updateSubSessionRef(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 write | the 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(anyexpectop: a reset, a release, the reclaim): the same atomic step first requires that run to be the parent session's current live run (corecompanionParentRunFenceRefusal), elseRunSupersededError(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 stampsStartRun.parent.toolCallIdon 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, elseRunStartRejectedError('parent_run_superseded'), nothing written. Not part ofstartDigest.
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
addSubSessionRefsresolvesAddSubSessionRefResult[], 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 }).updateSubSessionRefresolvesUpdateSubSessionRefOutcome(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), theexpectCAS (status, generation and, whenexpect.parentToolCallIdis given, the ref's call: pass the storedparentToolCallIdtosubSessionRefExpectRefusal), theatGenerationstaleness andterminatedrules, the reopen increment, and theparentToolCallIdstamp. 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) andparentRunFence, both after the idempotent-replay decision. A store that cannot read the parent's runs in the child's atomic step (the CF DO'sDOStateStore) must refuse both fields withUnsupportedOperationError, 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.*) andrunStartOperationTests(commit.start-companion-ref-*,commit.start-parent-run-fence), both part ofstateStoreContractTests. A store that refusesresumedCompanionRefandparentRunFencepassesresumedCompanionRef: 'refused'tostateStoreContractTests(it covers both fields); called directly,subSessionRefOperationTeststakes{ parentRunFence: 'refused' }.
If you build companion dispatch deps or a runtime
CompanionToolDispatchDeps.assertParentRunOwned(the run-owns-session check) andparentRunIdare required: the dispatcher asserts before every child mutation, and only the parent run's ownRunSupersededErrorescapesexecuteCompanionToolDispatch(isOwnRunSupersededError).releaseSpawnClaimrequiresparentToolCallId(the call the claim or reset was made for; the reclaim passes the stranded call), andreleaseSpawnClaimForRefusedChildtakes the refused child's ownparentToolCallId.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.entryKindandrefGenerationare passed to your spawn dep: a definite non-start owes the release at that generation (corecompanionSpawnNonStartRefUpdate). A continue dep reports the generation its reopen opened (SpawnPersistentChildResult.refGeneration), andrecordedConsultOutcomethe generation it read, so the blocking park's write is scoped.StopPersistentChildtakes a fourth argument,target: { consult, generation }: stop only the run serving that consult, and record the stop before the ref is writtenterminated.- A tool must never construct its own run's
RunSupersededError.
The CF Durable Object
DOStateStoresignatures (breaking):beginChildConsult(parentSessionId, subSessionId, consult, fence: { parentRunId })(the fence is required),addSubSessionRefsandupdateSubSessionRefanswer as above. It refusesresumedCompanionRefandparentRunFence(UnsupportedOperationError).- The DO-internal header
x-helix-parent-ref-generation(PARENT_REF_GENERATION_HEADER, parsed only byparentRefGenerationHeaderSchema(): canonical decimal) rides every companion/start. The child DO refuses a/startbelow the highest generation it recorded (409, causestale_parent_ref_generation) and records one inside its run-start claim block.createRemoteAgentWorkerHandlerstrips 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):wipeSessionSyncraises it to max(the wiped refs' generations) + 1 before deleting them, in the same transaction, andaddSubSessionRefsinserts a new ref at the floor (and reports it).DurableObjectAgentBasepassesctx.storage.kv; a DO built without it keeps the floor at 0 (a wipe that deletes refs logsdo.sub_session_ref_floor_unavailableat error), which is only right for a DO that hosts no companion parent. A delayed/startof 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/startis refused before the name is freed.
What your users see
companion__spawnAgentanswers{ 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).