Upgrade: a parent owes its remote child the stop (CP-217)
A parent's stop of a remote child is now at-least-once. Every stop a parent sends a remote child names the delegation it stops. A stop the child does not confirm is a durable debt on the parent's ref, retried with backoff until the child confirms it or the retry window ends. Before, a stop that missed its child was sent once and never again, so the child ran on, and billed, to its own end.
It is a major release of @helix-agents/core (the RemoteAgentTransport stop signature, the required SubSessionStopOwed members, the store interface's expectStopOwed), @helix-agents/runtime-js, @helix-agents/runtime-cloudflare and @helix-agents/agent-server. The store packages (@helix-agents/store-memory, @helix-agents/store-redis, @helix-agents/store-postgres, @helix-agents/store-cloudflare) take a minor: they are still 0.x, where a breaking change is a minor bump. Their change is breaking all the same (each implements expectStopOwed and stores the new debt members), so upgrade them with core.
Design: 2026-10-26-cp217-remote-stop-owed-design.md. Behaviour: remote agents guide, "Interrupt Propagation".
Deploying
- Parents and producers deploy together. A remote stop now carries its delegation, and a producer's remote-protocol stop routes (agent-server's
/interrupt//abort, the CF DO's/subagent/:type/interrupt|abort) refuse a stop that names none with a 400. An old parent's stop is refused by a new producer. - No migration.
stopOwedwas already stored as one JSON value on every store; the new members ride in it. There is no legacy-data handling: a debt an older release wrote (withouttarget) reads as corrupt and is dropped with a warn. - The CF DO's owed companion stops move. MR C1's per-child KV records (
__helix_child_stop_owed:*) are no longer read or written; a record left by an older release stays in the DO's storage, unused.
API changes
The transport's stop
// Before
interrupt(sessionId: string, reason?: string): Promise<void>;
abort(sessionId: string, reason?: string): Promise<void>;
// After
interrupt(sessionId: string, stop: RemoteStopRequest): Promise<void>;
abort(sessionId: string, stop: RemoteStopRequest): Promise<void>;
// RemoteStopRequest = { reason: string; target: { dispatchId: string } | { parentToolCallId: string } }- A custom
RemoteAgentTransportsends the body{ sessionId, reason, dispatchId }(orparentToolCallId) and throws the child's typed answer: map a non-2xx answer through coreremoteProtocolError. A parent readsstate_not_stoppable,state_stop_target_mismatchand a no-session answer as a CONFIRMED stop; any other throw leaves it owed.HttpRemoteAgentTransportthrew a plainErrorfor every non-2xx before, so a typed answer from an ended child read as a failure. - core
stopRemoteChild(transport, childSessionId, kind, { reason, target }, opts)takes the stop object as its 4th argument. Its result gainstarget_mismatchandgone, both confirmed.
The stop routes name exactly one delegation (breaking)
// core
type StopDelegation =
| { dispatchId: string } // a remote / ephemeral child: checked against its stored ChildBinding
| { parentToolCallId: string }; // a persistent companion: checked against its run's RunMetadata.parent- agent-server's
/interrupt//abortand the CF DO's/subagent/:type/interrupt|abortrequire exactly one ofdispatchIdandparentToolCallIdin the body. Neither or both is a 400 (agent-serverINVALID_REQUEST, the DOvalidation_error), and nothing is recorded. The CF DO's native/interrupt//abortaccept at most one. These are the parent → child routes. An operator that stopped a session by id through them moves to the operator routes, which take no delegation: agent-serverPOST /chat/{id}/interrupt|abort(nochatHandlerneeded) orserver.interruptAgent(sessionId)/abortAgent, and the CF DO's native/interrupt//abort(runIdoptional). See the remote agents guide. - Nothing is inferred from a missing record. A dispatch stop of a session with no binding, or a consult stop of a run that records no consult (a direct entry), stops nothing and answers
state_stop_target_mismatch. So a remote child is stoppable by its parent only once it was started with its binding (child identity, !535): parents and producers deploy together.
The child's answer
- A stop naming a delegation the child's current run does not serve writes nothing and answers 409
{ error, code: 'state_stop_target_mismatch' }(on the CF DO's/subagentroutes, the envelope with thaterrorCode). A CF DO companion's consult stop of another consult's run answeredstate_not_stoppablebefore. - A remote / ephemeral child is named by its dispatch (
dispatchId, checked against its storedChildBinding); a persistent companion by its consult (parentToolCallId, checked against its run'sRunMetadata.parent). - core
stopRuntakesdelegation, andStopAnswergains{ status: 'target_mismatch' }(an exhaustive switch over it needs the new arm).stopSessionand runtime-jsexecutor.stoppassdelegationthrough. - A remote child's stop is decided by its binding, so agent-server records nothing extra on the child's run: the binding child-identity MR 1 stores at
/startis the record. agent-server's/interrupt//aborttake exactly one ofdispatchIdandparentToolCallId(400INVALID_REQUESTfor neither or both). - core
childStopDelegation(ref)builds a ref's target (a companion's consult, any other child's dispatch), andstopDelegationSchema()parses one.
The debt
interface SubSessionStopOwed {
kind: StopKind;
at: number;
target: StopDelegation; // new, required
reason: string; // new, required
owedByRunId: string; // new, required
attempts?: number;
nextAttemptAt?: number;
abandonedAt?: number;
refusalLogged?: true; // new
}- A custom store implements
UpdateSubSessionRef.expectStopOwed({ kind, at, target, attempts } | null): a compare-and-set on the stored debt, decided withexpectStatusin the same atomic step, refusing withSubSessionRefConflictError(op'stamp'). Use corestopOwedMatches(stored, expected). A stored debt that core'ssubSessionStopOwedSchemarejects is no debt for the compare-and-set too (expectStopOwed: nulllands over it), asreadStoredStopOwedreads it; a store that checks in its own language (SQL, Lua) must apply the same rule (contract casestop-owed.corpus-parity, given a raw-cell writer). Contract:core/src/testing/sub-session-operations.ts("a debt write is a compare-and-set on the owed stop it read"). - A debt is independent of the ref's status: any status may carry one.
The schedule
- core
OwedStopSchedule { baseMs, maxMs, windowMs }, defaultDEFAULT_OWED_STOP_SCHEDULE: the first retry 30 s after the failed stop, doubling, capped at 1 h, abandoned 24 h after the debt began. Configure it withJSAgentExecutor'sowedStopScheduleoption orDurableObjectAgentConfig.owedStopSchedule. - Removed:
DurableObjectAgentConfig.childStopRetryBudgetMsand MR C1's KV-record helpers (oweChildStop,settleChildStop,listRunChildStops,clearRunChildStops,DEFAULT_CHILD_STOP_RETRY_BUDGET_MS,rerunStopCascade). A CF DO companion's first retry moves from 1 s to 30 s after a failed stop, and its budget from 1 h to 24 h (one schedule for every child). The first stop is still sent at once. - Removed: the logs
stop.child_unconfirmed_given_up,stop.child_stop_no_namespace,stop.child_stop_run_unsettledandsettle.stop_cascade_children_owed. Watchstop.remote_stop_owed_abandoned(a debt given up),stop.child_stop_refused(a typed refusal, once per debt) andstop.child_stop_owed_unrecorded/settle.stop_cascade_children_unrecorded(a debt write that did not land).
Who retries
- runtime-js: an in-process timer per session, unref'd, re-armed after each stop cascade and entry. Without a live process a debt waits for the session's next entry. A host that paces debts itself passes
onOwedStopsChanged. The runtime-js scheduler hook (executor.wake) replaces only the trigger. - The CF Durable Object: its
stop-cascadealarm, for companions and remote children alike. The stop cascade's mark is written once every child is confirmed or durably owed (it waited for every stop to be paid before). - CF Workflows: entries and stop cascades (its own pacer comes with CP-217 MR 2).
- Temporal and DBOS: one stop, nothing owed yet.
The in-flight dispatch (runtime-js, the CF DO)
On the parent's stop, a remote call's ref now stays running, with its lastSequence, instead of being written failed; a resume re-attaches, and the next entry settles it if the parent never resumes. The call's tool result is the run's stop pairing, as before.