Skip to content

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. stopOwed was 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 (without target) 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 ​

ts
// 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 RemoteAgentTransport sends the body { sessionId, reason, dispatchId } (or parentToolCallId) and throws the child's typed answer: map a non-2xx answer through core remoteProtocolError. A parent reads state_not_stoppable, state_stop_target_mismatch and a no-session answer as a CONFIRMED stop; any other throw leaves it owed. HttpRemoteAgentTransport threw a plain Error for 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 gains target_mismatch and gone, both confirmed.

The stop routes name exactly one delegation (breaking) ​

ts
// 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 / /abort and the CF DO's /subagent/:type/interrupt|abort require exactly one of dispatchId and parentToolCallId in the body. Neither or both is a 400 (agent-server INVALID_REQUEST, the DO validation_error), and nothing is recorded. The CF DO's native /interrupt / /abort accept 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-server POST /chat/{id}/interrupt|abort (no chatHandler needed) or server.interruptAgent(sessionId) / abortAgent, and the CF DO's native /interrupt / /abort (runId optional). 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 /subagent routes, the envelope with that errorCode). A CF DO companion's consult stop of another consult's run answered state_not_stoppable before.
  • A remote / ephemeral child is named by its dispatch (dispatchId, checked against its stored ChildBinding); a persistent companion by its consult (parentToolCallId, checked against its run's RunMetadata.parent).
  • core stopRun takes delegation, and StopAnswer gains { status: 'target_mismatch' } (an exhaustive switch over it needs the new arm). stopSession and runtime-js executor.stop pass delegation through.
  • 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 /start is the record. agent-server's /interrupt / /abort take exactly one of dispatchId and parentToolCallId (400 INVALID_REQUEST for neither or both).
  • core childStopDelegation(ref) builds a ref's target (a companion's consult, any other child's dispatch), and stopDelegationSchema() parses one.

The debt ​

ts
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 with expectStatus in the same atomic step, refusing with SubSessionRefConflictError (op 'stamp'). Use core stopOwedMatches(stored, expected). A stored debt that core's subSessionStopOwedSchema rejects is no debt for the compare-and-set too (expectStopOwed: null lands over it), as readStoredStopOwed reads it; a store that checks in its own language (SQL, Lua) must apply the same rule (contract case stop-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 }, default DEFAULT_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 with JSAgentExecutor's owedStopSchedule option or DurableObjectAgentConfig.owedStopSchedule.
  • Removed: DurableObjectAgentConfig.childStopRetryBudgetMs and 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_unsettled and settle.stop_cascade_children_owed. Watch stop.remote_stop_owed_abandoned (a debt given up), stop.child_stop_refused (a typed refusal, once per debt) and stop.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-cascade alarm, 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.

Released under the MIT License.