Skip to content

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 own subAgentNamespace (idFromName(parent.sessionId)), so the parent and every companion must live in that one namespace. A companion DO with none logs companion.notice.no_namespace at 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_refs columns (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-settled is 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 blocking companion__spawnAgent answers { name, status: 'unreachable', message } and companion__waitForResult answers { name, status: 'unreachable', currentStatus, message }, both successful tool results. Before, both were tool errors, and the ref was written failed and the child aborted. Now the ref stays running until the child's own outcome arrives or the model calls companion__terminateChild.

  • A finished non-blocking companion is recorded completed on its parent without a parent tool call, its result is injected on the parent's next model call, and a same-name companion__spawnAgent re-consults it.

  • A continue over an undelivered result delivers it, then continues (MR B). A same-name companion__spawnAgent on a completed / failed companion, or a companion__sendMessage to a completed one, whose previous result is not marked delivered yet (the child ended during the model call) carries that result as priorResult beside 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: false answer included. Tell the model to read priorResult as 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 and companion__waitForResult mark it): a model that read the output through companion__getChildStatus, which does not mark it, gets it once more as priorResult.

  • Cadence. The blocking park and companion__waitForResult read 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 gain priorResult?: CompanionPriorResult ({ status: 'completed' | 'failed'; output?; errorDetail? }, CompanionPriorResultSchema). CompanionDeliveryOutcomeSchema and parseCompanionSendMessageResult accept it. The reason set is unchanged: a switch on reason stays over COMPANION_NOT_DELIVERED_REASONS.
  • /subagent/:type/status adds runId, runStatus, parentToolCallId and committedOutcome. Its runId is 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, the DOStubTransport remote sub-agent status poll included.
  • /status error (root and /subagent/:type/status) is sanitized, as errorDetail.message is (do-status-error-sanitized changeset). The DO's SSE fail frame 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 priorResult and 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__spawnAgent answers core CompanionSpawnResult on 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 reads reason and may spawn again). The reasons (COMPANION_NOT_SPAWNED_REASONS): child_busy (a 409 ALREADY_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 typed errorDetail; the continue's typed tool errors are gone) and child_terminated. A blocking spawn's failed answer now carries the child's errorDetail. Prompts or tests that matched { name, status: 'spawned' } without delivered, or the old tool errors, need updating.
  • The /start is bounded. CHILD_START_TIMEOUT_MS (60 s, internal; DOCompanionDispatchContext.startTimeoutMs for tests), like /resume. A child DO that does not answer in time yields child_start_unconfirmed; the consult stays on the ref unconfirmed and the next same-name spawn (or a getChildStatus / sendMessage on 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 / releasePriorResult on every store, SubSessionRef.heldPrior / outcomeGeneration; the companion-held-prior helpers (isHeldPriorDelivered, isCompanionAnswerCarryingPrior, findCommittedCompanionNotice, companionNoticeKey) and COMMON_METADATA_KEYS.COMPANION_NOTICE on every completion notice. A custom SessionStateStore must implement the two methods (contract: @helix-agents/core/testing, sub-session-operations.ts "the held prior"). createSpawnAgentTool().outputSchema is CompanionSpawnResultSchema.
  • runtime-js: CompanionNotifier.outcomeOf returns runId (required).
  • A custom /subagent route (a non-DO child answering a DO parent): answer inputCommitted: true on 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 409 ALREADY_RUNNING with cause: 'resumed_companion_ref_terminated' is answered child_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 spawnAgent answers are not on CompanionSpawnResult (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 /start times 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__spawnAgent whose parent is stopped answers { name, status: 'parent_stopped', kind } (kind: interrupt or abort). It is never committed to the transcript (the parent's step ends by its stop) and it writes no ref: the companion's ref stays running until the child's own committed outcome reaches the parent (its child-settled notice, then the verified read). Before, the park wrote the ref interrupted or failed from 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 rule companion__waitForResult already used; message is core interruptedCompanionGuidance (the child is resumable: use companion__sendMessage). Before, the blocking spawn kept waiting until the parent was stopped. On every runtime that parks through core, interrupted now carries that message.
  • companion__waitForResult and companion__terminateChild keep their answers; their stop posts name the ref's consult.
  • Prompts or tests that matched the old park answer (interrupted / failed after a parent stop) must accept parent_stopped.

Code that reads the companion wire ​

  • The stop body. POST /subagent/:type/interrupt|abort accepts parentToolCallId (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 409 state_not_stoppable and writes nothing. A custom child route that is not a Helix DO should answer the same.
  • DOStateStore.getStopCascadeRefs(sessionId, runId) returns StopCascadeRef[] (SubSessionRef plus consult: 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. beginChildConsult now writes the ref's parent_run_id (a re-consult belongs to the run that began it, O9) and restoreChildConsult puts the prior one back; no migration.
  • DOStopHost.cancelPendingEntry(reason, scope?) takes scope: { consult }: only an uncommitted entry registered for that consult is cancelled (registerExecution's new consult); 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 new stop-cascade alarm 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. ChildTerminalResult and CompanionSpawnResult gain parent_stopped { kind } (and CompanionSpawnResultSchema parses it); the interrupted arm gains optional message; CompanionToolDispatchDeps.waitForChildTerminal's input gains toolCallId; interruptedCompanionGuidance is exported. A custom runtime with its own waitForChildTerminal may ignore toolCallId.
  • New config. DurableObjectAgentConfig.childStopRetryBudgetMs (default 3 600 000) and companionStartTimeoutMs (default 60 000, CHILD_START_TIMEOUT_MS, formerly fixed); each a positive integer, else a typed validation_error at 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's stop.child_stop_* failures. Alert on stop.child_unconfirmed_given_up: it means a companion's ref was left running after the stop could not be confirmed for the whole budget.
  • Testing. startSeedRun (@helix-agents/core/testing) takes opts.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. After childStopRetryBudgetMs the stop is given up with the ref still running (a child gone for good keeps a live ref; a same-name spawn answers busy until companion__terminateChild, which succeeds only once the child DO confirms the abort: a typed 404 or state_not_stoppable does, an unreachable child fails the tool call).
  • A consult stop that reaches the child before its /start reached createSession / the run-start commit is answered 404 or state_not_stoppable and counted confirmed, so the later /start runs unstopped until the parent re-probes the unconfirmed consult (the next same-name spawn, getChildStatus or sendMessage).
  • 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__waitForResult can answer interrupted while a direct /resume of the child (which inherits parent) is live, because a live read keeps the recorded interrupted word (FU-DO-COMPANION-INTERRUPTED-DEBOUNCE-LIVE-RESUME).
  • A drive detached past stopDeadlineMs whose still-running tool re-consults the child after the next entry's run-start commit attributes the ref to the NEW run (beginChildConsult reads the current run): the same window as the ref-write fence of FU-DO-CHILD-STOP-RUN-FENCE. beginChildConsult also 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 (refusalLogged is never persisted then). If a RETRY's put throws, the old record keeps its past nextAt, 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 running even when the child is proven gone; the user's D6 amendment (a failed ref with framework_child_unreachable) is open (FU-DO-COMPANION-STOP-GIVE-UP-PROVEN-GONE).

Released under the MIT License.