Upgrade: the CF Durable Object companion consult protocol (DO companion lifecycle MR A)
A Cloudflare Durable Object parent now records a persistent companion's outcome only from the child's own committed run for the consult the parent's ref tracks (CP-39, CP-125). It is a major release of @helix-agents/runtime-cloudflare (LLM-facing tool answers change, and a DO-SQLite migration runs), a minor release of @helix-agents/core and @helix-agents/runtime-js, and a patch of @helix-agents/agent-server.
Design: 2026-10-15-do-companion-lifecycle-design.md. Reference: the CF DO runtime page.
Deploying
- One
subAgentNamespace. A companion notifies its parent through its ownsubAgentNamespace(idFromName(parent.sessionId)), so the parent and every companion must live in that one namespace. A companion DO with none logscompanion.notice.no_namespaceat warn and cannot notify: its parent learns the outcome only when the model touches that child. - Migration. DO-SQLite migration 17 adds four nullable
sub_session_refscolumns (consult_tool_call_id,child_run_id,outcome,verify_owed_at). It runs on the DO's first request after the deploy. There is no legacy-data handling. - The new DO route
POST /companion/child-settledis DO to DO. It is not in the worker handler's public allowlist; do not expose it.
What the model sees now (prompts and tests that match tool results)
An unreachable child. After
companionPollFailureWindowMs(30 s) of failed reads of a child DO, a blockingcompanion__spawnAgentanswers{ name, status: 'unreachable', message }andcompanion__waitForResultanswers{ name, status: 'unreachable', currentStatus, message }, both successful tool results. Before, both were tool errors, and the ref was writtenfailedand the child aborted. Now the ref staysrunninguntil the child's own outcome arrives or the model callscompanion__terminateChild.A finished non-blocking companion is recorded
completedon its parent without a parent tool call, its result is injected on the parent's next model call, and a same-namecompanion__spawnAgentre-consults it.A continue over an undelivered result delivers it, then continues (MR B). A same-name
companion__spawnAgenton acompleted/failedcompanion, or acompanion__sendMessageto acompletedone, whose previous result is not marked delivered yet (the child ended during the model call) carries that result aspriorResultbeside its own answer and runs the new consult:typescript// companion__spawnAgent (non-blocking) { name, status: 'spawned', delivered: true, priorResult: { status, output?, errorDetail? } } // companion__spawnAgent (blocking): the new round's word, the earlier one beside it { name, status: 'completed', output, priorResult: { status, output?, errorDetail? } } // companion__sendMessage { delivered: true, priorResult: { status, output?, errorDetail? } }Every arm may carry it, a
not_spawned/delivered: falseanswer included. Tell the model to readpriorResultas the earlier round's result; nothing has to be re-sent. runtime-js and CF Workflows answer the same. "Undelivered" means "not marked delivered" (the notifier, a blocking park andcompanion__waitForResultmark it): a model that read the output throughcompanion__getChildStatus, which does not mark it, gets it once more aspriorResult.Cadence. The blocking park and
companion__waitForResultread the child every 5 s (they read every 250 ms before); a recorded outcome wakes them sooner.
Code that reads the companion wire
CompanionDeliveryOutcome(core): both arms gainpriorResult?: CompanionPriorResult({ status: 'completed' | 'failed'; output?; errorDetail? },CompanionPriorResultSchema).CompanionDeliveryOutcomeSchemaandparseCompanionSendMessageResultaccept it. Thereasonset is unchanged: aswitchonreasonstays overCOMPANION_NOT_DELIVERED_REASONS./subagent/:type/statusaddsrunId,runStatus,parentToolCallIdandcommittedOutcome. ItsrunIdis now the persisted current run's, and a failed read of these fields answers 503 instead of a 200 from the status row alone. This reaches every caller, theDOStubTransportremote sub-agent status poll included./statuserror(root and/subagent/:type/status) is sanitized, aserrorDetail.messageis (do-status-error-sanitizedchangeset). The DO's SSEfailframe and stream metadata still carry the raw text (FU-RAW-ERROR-TEXT-SURFACES).
Known limits
- A replay answers from the recorded outcome only when it reuses the call id. A DO recovery re-calls the model, and a real provider mints a new tool-call id, so a recovered parent's re-emitted spawn is a new call: "already running" for a live child, or a continue of a completed one, which delivers an undelivered outcome as
priorResultand runs the child again. FU-DO-COMPANION-ORPHANED-CONSULT-ADOPTION (MR C) adopts the orphaned consult so the child runs once. - Temporal and DBOS (parked) neither hold nor deliver a prior result: a continue there still starts the new consult over an undelivered result (FU-SPAWN-CONTINUE-ENTRY-OUTCOME-PARITY, narrowed to them). CF Workflows delivers it (below).
MR B: entry outcomes, the bounded /start, deliver-then-continue
Released with MR A. A major of @helix-agents/runtime-cloudflare (LLM-facing answers; DO-SQLite migration 18), a minor of @helix-agents/core, @helix-agents/runtime-js and the four stores (store-memory, store-redis, store-postgres V19, store-cloudflare V23), a patch of @helix-agents/runtime-temporal (type-only).
What the model sees now
companion__spawnAgentanswers coreCompanionSpawnResulton the DO (and runtime-js; CF Workflows for a continue). A non-blocking spawn or continue reports its ENTRY outcome:{ name, status: 'spawned', delivered: true }once the child's run start committed, else{ name, status: 'not_spawned', delivered: false, reason, errorDetail? }, a successful tool result (the model readsreasonand may spawn again). The reasons (COMPANION_NOT_SPAWNED_REASONS):child_busy(a 409ALREADY_RUNNING; the advisory tool error "Cannot start child … a run for this child is still in progress" is gone),child_start_unconfirmed(no answer within the bound, a transport failure, a non-conforming 2xx, or a refusal the child sent after its run-start commit),child_entry_failed(any other refusal, with its typederrorDetail; the continue's typed tool errors are gone) andchild_terminated. A blocking spawn'sfailedanswer now carries the child'serrorDetail. Prompts or tests that matched{ name, status: 'spawned' }withoutdelivered, or the old tool errors, need updating.- The
/startis bounded.CHILD_START_TIMEOUT_MS(60 s, internal;DOCompanionDispatchContext.startTimeoutMsfor tests), like/resume. A child DO that does not answer in time yieldschild_start_unconfirmed; the consult stays on the ref unconfirmed and the next same-name spawn (or agetChildStatus/sendMessageon that ref) re-probes it with a verified read — confirmed, abandoned on proof it never started, or left — instead of respawning. Nothing is wiped on uncertainty. A first spawn's definite non-start leaves the name re-spawnable; a failed continue never wipes a completed child. priorResult(above): a continue over an undelivered result delivers it beside its own answer, where before it cleared it and the model never saw it.
Code that reads the companion wire
- Core:
CompanionSpawnResult/CompanionSpawnResultSchema/parseCompanionSpawnResult(the one parse at a runtime boundary),CompanionEntryOutcome,companionSpawnResultFromEntry,companionDeliveryFromEntry;CompanionToolDispatchDeps.holdPriorResult;SessionStateStore.holdPriorResult/releasePriorResulton every store,SubSessionRef.heldPrior/outcomeGeneration; thecompanion-held-priorhelpers (isHeldPriorDelivered,isCompanionAnswerCarryingPrior,findCommittedCompanionNotice,companionNoticeKey) andCOMMON_METADATA_KEYS.COMPANION_NOTICEon every completion notice. A customSessionStateStoremust implement the two methods (contract:@helix-agents/core/testing,sub-session-operations.ts"the held prior").createSpawnAgentTool().outputSchemaisCompanionSpawnResultSchema. - runtime-js:
CompanionNotifier.outcomeOfreturnsrunId(required). - A custom
/subagentroute (a non-DO child answering a DO parent): answerinputCommitted: trueon a non-2xx sent after the entry's run-start commit (RemoteAgentErrorResponse.inputCommitted), so the parent keeps the consult unconfirmed instead of restoring the previous one over a run that exists; a 409ALREADY_RUNNINGwithcause: 'resumed_companion_ref_terminated'is answeredchild_terminated.
Deploying
- Migrations. DO-SQLite 18, Postgres V19 and D1 V23 add the nullable held-prior columns (
held_prior,outcome_generation,held_generation); Redis gains hash fields. They run on the DO's first request / the store's migration runner after the deploy. No legacy-data handling (NULL= nothing held). - Client and server deploy together (the LLM-facing shapes change).
Known limits
- Temporal and DBOS neither hold nor deliver a prior result, and their
spawnAgentanswers are not onCompanionSpawnResult(Temporal's model sees core's spawn handoff: FU-COMPANION-SPAWN-HANDOFF-TYPED; FU-SPAWN-CONTINUE-ENTRY-OUTCOME-PARITY). - A CF Workflows FIRST spawn still answers
{ name, status: 'spawning' | 'spawned' }(FU-CFW-COMPANION-FIRST-SPAWN-NOT-SPAWNED). - A recovered DO parent's fresh-id spawn delivers the recorded verdict and runs the child again (FU-DO-COMPANION-ORPHANED-CONSULT-ADOPTION, MR C); a respawn whose
/starttimes out can re-deliver an inline-delivered word once, a visible duplicate, never a loss (FU-DO-COMPANION-INLINE-DELIVERY-UNRECORDED, MR C).
MR C1: stop truth
A parent's stop of a persistent companion no longer writes the companion's ref from the parent's intention, and the child stops only the run that serves the parent's consult (spec 2026-10-17 §1; D6, D7, I-C8, I-C9). It is a major of @helix-agents/runtime-cloudflare (LLM-facing answers and the stop wire change) and a minor of @helix-agents/core. runtime-js changes only through core: its blocking park's interrupted answer now carries message.
What the model sees now
- A blocking
companion__spawnAgentwhose parent is stopped answers{ name, status: 'parent_stopped', kind }(kind:interruptorabort). It is never committed to the transcript (the parent's step ends by its stop) and it writes no ref: the companion's ref staysrunninguntil the child's own committed outcome reaches the parent (its child-settled notice, then the verified read). Before, the park wrote the refinterruptedorfailedfrom the parent's intention and answered that, whatever the child then did. - A blocking spawn whose child was interrupted on its own answers
{ name, status: 'interrupted', message }after three consecutive observations (D7), the rulecompanion__waitForResultalready used;messageis coreinterruptedCompanionGuidance(the child is resumable: usecompanion__sendMessage). Before, the blocking spawn kept waiting until the parent was stopped. On every runtime that parks through core,interruptednow carries thatmessage. companion__waitForResultandcompanion__terminateChildkeep their answers; their stop posts name the ref's consult.- Prompts or tests that matched the old park answer (
interrupted/failedafter a parent stop) must acceptparent_stopped.
Code that reads the companion wire
- The stop body.
POST /subagent/:type/interrupt|abortacceptsparentToolCallId(LifecycleSignalRequestSchema). The parent sends only that, never a run id: the child cancels an uncommitted entry of that consult first, then resolves its current run and records the stop only when it serves the consult (RunMetadata.parent.toolCallId, persisted, so a submit continuation and an eviction are covered); otherwise it answers 409state_not_stoppableand writes nothing. A custom child route that is not a Helix DO should answer the same. DOStateStore.getStopCascadeRefs(sessionId, runId)returnsStopCascadeRef[](SubSessionRefplusconsult: string | null, the consult the ref tracks), so the cascade posts consult-scoped stops. It reaches the children the run started, reopened or re-consulted, and those an earlier run left running.beginChildConsultnow writes the ref'sparent_run_id(a re-consult belongs to the run that began it, O9) andrestoreChildConsultputs the prior one back; no migration.DOStopHost.cancelPendingEntry(reason, scope?)takesscope: { consult }: only an uncommitted entry registered for that consult is cancelled (registerExecution's newconsult); an entry with no consult or another consult is left alone. Without a scope it cancels as before.- The owed stop records. An unconfirmed companion stop is a DO KV record
__helix_child_stop_owed:<runId>:<childSessionId>(ChildStopOwedSchema), retried by the newstop-cascadealarm subscriber. The cascade is the stop mark's only writer and writes none while a child is owed, including a child a later run re-consulted: that record is done only once the later consult is confirmed started or the ref is no longer live, and until then the stop of the record's own consult is posted again. - Core spawn types.
ChildTerminalResultandCompanionSpawnResultgainparent_stopped { kind }(andCompanionSpawnResultSchemaparses it); theinterruptedarm gains optionalmessage;CompanionToolDispatchDeps.waitForChildTerminal's input gainstoolCallId;interruptedCompanionGuidanceis exported. A custom runtime with its ownwaitForChildTerminalmay ignoretoolCallId. - New config.
DurableObjectAgentConfig.childStopRetryBudgetMs(default 3 600 000) andcompanionStartTimeoutMs(default 60 000,CHILD_START_TIMEOUT_MS, formerly fixed); each a positive integer, else a typedvalidation_errorat construction. - New log events.
stop.child_stop_refused(error, once per owed child),stop.child_unconfirmed_given_up(error),stop.child_stop_no_namespace(error),settle.stop_cascade_children_owed(info),settle.stop_cascade_owed_read_failed(warn: the run's owed records could not be read, so the cascade stays owed with no mark),settle.stop_cascade_owed_clear_failed(warn: a clear after the mark failed; the alarm's mark check clears them),stop.child_stop_run_unsettled(warn),stop.child_consult_missing(error: a live companion ref with no consult, posted nothing; unreachable in normal operation, since every DO ref insert writes its consult), and the alarm'sstop.child_stop_*failures. Alert onstop.child_unconfirmed_given_up: it means a companion's ref was leftrunningafter the stop could not be confirmed for the whole budget. - Testing.
startSeedRun(@helix-agents/core/testing) takesopts.parent.
Deploying
- No migration. DO-SQLite stays at 18; the owed records live in DO KV.
- The parent and its companions deploy together. An old child ignores
parentToolCallId(the body schema strips unknown keys) and stops its current run as before, so a new parent's stop of an old child is not consult-scoped; an old parent sends none, so a new child keeps the run-targeted rules for it.
Known limits
- A stop whose child stays unreachable leaves the ref
running(D6): only the child's own outcome ends it. AfterchildStopRetryBudgetMsthe stop is given up with the ref stillrunning(a child gone for good keeps a live ref; a same-name spawn answers busy untilcompanion__terminateChild, which succeeds only once the child DO confirms the abort: a typed 404 orstate_not_stoppabledoes, an unreachable child fails the tool call). - A consult stop that reaches the child before its
/startreachedcreateSession/ the run-start commit is answered 404 orstate_not_stoppableand counted confirmed, so the later/startruns unstopped until the parent re-probes the unconfirmed consult (the next same-name spawn,getChildStatusorsendMessage). - A cascade whose READS failed (the stop record, the child refs, the remote transports) has no alarm: the next wake re-runs it.
- A remote ephemeral child's failed stop post is still logged and counted done, nothing retries it (FU-DO-CASCADE-REMOTE-STOP-UNCONFIRMED).
- The blocking park and
companion__waitForResultcan answerinterruptedwhile a direct/resumeof the child (which inheritsparent) is live, because aliveread keeps the recordedinterruptedword (FU-DO-COMPANION-INTERRUPTED-DEBOUNCE-LIVE-RESUME). - A drive detached past
stopDeadlineMswhose still-running tool re-consults the child after the next entry's run-start commit attributes the ref to the NEW run (beginChildConsultreads the current run): the same window as the ref-write fence of FU-DO-CHILD-STOP-RUN-FENCE.beginChildConsultalso attributes to the CURRENT run, so a run superseded between its ownership check and the begin attributes the consult to its successor. - An owed record whose KV put throws (a storage outage) has two cases. If the FIRST owe's put throws there is no record: nothing paces that child until a wake re-runs the cascade, and a refusal can be logged again on the next attempt (
refusalLoggedis never persisted then). If a RETRY's put throws, the old record keeps its pastnextAt, so it is immediately due and the alarm re-fires unpaced, a hot retry, until storage recovers. - A confirmed post whose owed-record delete fails keeps a past
nextAt, so the alarm re-posts the child (an idempotent answer) until the delete lands. - The cascade re-run after a settled record re-posts a child that just confirmed; if that redundant post comes back unconfirmed it opens a NEW record with a fresh
owedSince, one more retry budget. - A run left with only given-up tombstones, whose cascade re-run did not write the mark, gets no alarm (the narrowed FU-DO-STOP-CASCADE-OWED-NO-ALARM half): the next wake re-runs it.
- A given-up companion stop keeps the ref
runningeven when the child is proven gone; the user's D6 amendment (afailedref withframework_child_unreachable) is open (FU-DO-COMPANION-STOP-GIVE-UP-PROVEN-GONE).