Upgrade: stop as one signal (the run-scoped StopRequest, WA MR2)
This release replaces the session-level interrupt flag with one durable, run-scoped stop record on every store and runtime. Every stop (interrupt or abort) is recorded on the run it targets, the run's owner is signalled, and the call answers at once: it never waits for the run to wind down. The run's end is observed on its stream or through its status.
It is a major release of @helix-agents/runtime-js, @helix-agents/runtime-cloudflare, @helix-agents/runtime-temporal, @helix-agents/runtime-dbos, @helix-agents/agent-server and every store package (@helix-agents/store-memory, @helix-agents/store-redis, @helix-agents/store-postgres, @helix-agents/store-cloudflare): the store interface gains required methods and loses the flag methods. It is a minor release of @helix-agents/core (a 0.x package) and @helix-agents/ai-sdk (the additive CloudflareChatHandler.stop).
Design: 2026-10-11-do-stop-as-one-signal-design.md (read its "As built" section first) and the drive spec's §4.
Deploying
There is no legacy-data handling: a stop flag written by an older release is not migrated and is not read.
Run the store migrations (below) before the new runtimes. Each one creates the stop table and drops the interrupt-flag table.
Temporal: drain workflows before upgrading. Histories recorded before this release do not replay on it, and in-flight Temporal histories from before the upgrade are unsupported. The workflow's stop boundaries now schedule
readStopActivity, and theconsumeInterruptFlagactivity is gone, so a workflow still open across the upgrade fails replay with a nondeterminism error. Let every open agent workflow finish (or terminate it) before you roll workers onto the new code. Worker activity registration must listreadStopActivityand no longer listconsumeInterruptFlag. There is no patch id: the stop change is not gated bywf.patched.DBOS: drain in-flight workflows before upgrading. Step names and signatures changed:
checkInterruptFlagStep→readStopStep, and the set / clear interrupt-flag steps are gone;- the step-commit result's
{ interrupted: true }is now{ stopped: StopRequest }; - a persistent turn's entry is
persistent: true.
A workflow recovered across the upgrade would replay a step that no longer exists. Let in-flight workflows finish (or cancel them) before you deploy.
1. The store contract
SessionStateStore gains two required methods and one guard, and loses the flag methods.
type StopKind = 'interrupt' | 'abort';
interface StopRequest {
sessionId: string;
runId: string;
kind: StopKind;
reason: string;
requestedAt: number; // the store's clock
deadlineAt: number | null; // requestedAt + deadlineMs; null: no deadline
}
type StopTarget = { kind: 'run'; runId: string } | { kind: 'current' };
interface SessionStateStore {
requestStop(
sessionId: string,
req: { kind: StopKind; reason: string; target: StopTarget; deadlineMs: number | null }
): Promise<{ recorded: true; stop: StopRequest } | { recorded: false; reason: 'no_live_run' }>;
readStop(sessionId: string, runId: string): Promise<StopRequest | null>;
// ...
}- One row per
(sessionId, runId).target: { kind: 'current' }resolves, atomically, to the session's current run only while it is live (runningorsuspended_*), else{ recorded: false, reason: 'no_live_run' }and nothing is written.target: { kind: 'run' }is written as given (a stop for a run that never commits is inert). A missing session throwsHelixErrorstate_session_not_found. - Precedence (core
applyStopPrecedence): anabortupgrades a recordedinterrupt(the abort's reason andrequestedAt, the earlier deadline); aninterruptnever downgrades anabort; a repeat of the same kind keeps the first. - Nothing clears a record. A stop names its run, so a stale stop never stops a newer run.
deleteSessionremoves the session's rows. - The commit guard.
refuseIfStoppedoncommitStateand onappendMessageswithoutstartRun(MessageCommit.refuseIfStopped: true | { allowKind }), and onsaveStateAndPromoteStagingandpromoteStaging(true), refuses the commit in the same atomic step as the write, after the superseded-run fence: it throwsCommitStoppedError(state_commit_stopped, non-retryable, carrying the storedstop) and writes nothing.{ allowKind }refuses only a stop of another kind: a stop's own terminal commits with it, so anabortthat upgraded theinterruptbeing resolved is never overwritten byinterrupted. Every runtime's step, append and terminal commits of a live run carry the guard. - The run-start rule. A run-start commit that would supersede a live run with a stop record (its owner guard passed) is refused with
RunStartRejectedError('expected_run_stopped'): the caller settles the stopped run first. - Corrupt rows. A stored stop row is read with
StopRequestSchema(coreparseStoredStopRequest); one that fails it is a permanentInvalidStoredStateError. - Contract suite.
stopOperationTests(@helix-agents/core/testing) runs insidestateStoreContractTestson every store; a store whose harness wipes instead of deleting setsskip.deleteSession.
Per-store storage and migrations
| Store | Stop storage | Migration |
|---|---|---|
| Postgres | table __agents_stop_requests, primary key (session_id, run_id) | V16 (also drops __agents_interrupt_flags) |
| D1 | table __agents_stop_requests | V20 (also drops __agents_interrupt_flags) |
| DO-SQLite | table stop_requests (one session per Durable Object) | 14 (also drops interrupt_flag; adds sub_session_refs.parent_run_id, the run a child belongs to) |
| Redis | one hash per session, {prefix}:session:stops:{sessionId} (field = run id) | none (written by the commit scripts) |
| Memory | in-process map | none |
Each store applies these migrations the way it applies its earlier ones; the DO migrates its own SQLite.
2. The answers
A stop answers with core's StopAnswer, never with the run's end:
| Answer (HTTP) | StopAnswer | Meaning |
|---|---|---|
202 { status: 'stopping', runId, kind } | { status: 'stopping', runId, kind } | recorded and signalled; the run is executing and ends at its next boundary |
200 { status: 'interrupted' | 'failed', runId } | { status: 'interrupted' | 'failed', runId } | recorded and resolved by this call: nothing executed the run (paused, stranded), so its terminal was committed now |
409 { error, code: 'state_not_stoppable', currentStatus } | { status: 'not_stoppable', currentStatus } | no live run; nothing written |
404 { error, code: 'state_session_not_found' } | throws state_session_not_found | no session |
typed 503 (core typedErrorResponse) | throws | a transient failure; nothing durable was promised |
kind in a 202 is the recorded kind (an abort that upgraded an interrupt answers abort). A 200 can also report a run that ended interrupted or failed on its own just before the stop. Core stopAnswerResponse(answer) maps an answer to its status and body, and StopAnswerBodySchema parses a 200 / 202 body.
Who answers this way:
- Cloudflare DO:
/interrupt,/abort,/subagent/:type/interrupt|abort(the 2xx bodies above; refusals in the remote-protocol envelope witherrorCode), andEndpointContext.stop(kind, reason?), which now resolves theStopAnswerinstead ofvoid. @helix-agents/ai-sdk:CloudflareChatHandler.stop({ sessionId, kind?, reason? })(new) passes the DO's answer through unchanged.@helix-agents/agent-server:POST /interrupt,POST /abortand the/chat/{id}/interrupt|abortroutes;interruptAgent()/abortAgent()now return theStopAnswer.- runtime-js:
JSAgentExecutor.stop(sessionId, { kind, reason?, agent? })(new) returns it.agentlets the stop reach remote children through the agent's remote tools; without it they are not reached.
Before:
await server.interruptAgent(sessionId); // resolved once the run observed the flag, or threw (HTTP 504)
const status = (await stateStore.loadState(sessionId))?.status; // 'interrupted'After:
const answer = await server.interruptAgent(sessionId);
if (answer.status === 'stopping') {
// Recorded and signalled. Observe the end on the stream (`run_interrupted`) or the status.
} else if (answer.status === 'not_stoppable') {
// No live run: answer.currentStatus says why. Nothing was written.
}The stop deadline (Cloudflare DO only)
DurableObjectAgentConfig.stopDeadlineMs (default 30 000, both kinds; a positive integer, else the DO's constructor throws a typed validation_error) is recorded on every DO stop as deadlineAt. A run still live at its deadline (a tool or provider call that ignores its abort signal) is ended with the stop's terminal by a durable alarm, and its stuck drive is detached: the session accepts a new entry at once. A JavaScript promise cannot be killed, so the stuck call keeps its CPU and memory until it returns or the DO is evicted; every write it attempts is refused. Every other runtime records deadlineAt: null (FU-STOP-DEADLINE-NODE).
3. Handles: interrupt() and abort() resolve once the stop is durable
handle.interrupt() and handle.abort() resolve once the stop is recorded (D3); they no longer wait for the run's end, and a handle on a run that already ended resolves and writes nothing. Observe the outcome with result() or the stream. A suspended run is stopped at once.
| Runtime | interrupt() | abort() |
|---|---|---|
| runtime-js | the stop of the handle's own run | the same, abort |
| Cloudflare DO client | posts /interrupt; throws the typed error on a 404, a 503 or a transport failure (it used to warn) | posts /abort, the same |
| Cloudflare Workflows | records, wakes the open instance (an event) | records, terminates the instance, and settles the run before it resolves |
| Temporal | records, signals the workflow | records, signals and cancels the workflow, and resolves once the stop is durable; the cancelled workflow commits its own failed K5 (F4-C) |
| DBOS | records, sends the control message, cancels the workflow, then settles the run after 500 ms | the same, abort |
The reconnect handles (getHandle()) stop their run the same way: the DO reconnect handle's abort() is no longer a no-op and its interrupt() no longer throws; DBOS's getHandle() handle records and settles the stop instead of only setting a flag.
4. Behaviour changes
- A stopped paused run ends at once. A stop on a run suspended on client tools commits
interrupted(orfailedwithframework_cancelled), clearspendingClientToolCallsand ends the stream. A late submit for a discarded call gets{ status: 'unknown_tool_call', reason: 'run_stopped' }, and the next turn's continuation heal pairs the call with a not-executed result. No per-calltool_endis written for a discarded call (FU-STOP-PAUSED-DISCARDED-CALLS-NO-TOOL-END). - A submit reads the stop first (§7.1, Ruling 49b; runtime-js, the CF DO and CF Workflows). A submit for a call whose run has a recorded stop (not yet resolved) settles the run by that stop and answers 404
{ status: 'unknown_tool_call', reason: 'run_stopped' }; it used to answeracceptedand discard the result silently. Nothing is stored and no continuation starts (on CF Workflows, no__submit__instance).reasonis additive: handle it where you show "this call no longer exists" (the client-tools guide). DBOS and Temporal answer it too (R2c; on DBOS the parked workflow is cancelled and nothing is sent to it). with_messageover a stopped paused run is a new turn (§7.3, Ruling 57). A recorded stop on the paused run is settled first (interrupted, its pending calls discarded), before the partial-batch gate, so the entry re-plans as a new turn with or withoutabandonPending;abandonPendingover a session an interrupt already ended is a no-op (it used to be refusedstate_not_resumable). An ABORT ends the sessionfailed(framework_cancelled), and a failed session is not resumable: thatresume()is refused typed (state_not_resumable:ResumeRefusedErroron runtime-js and the CF DO,AgentNotResumableErroron CF Workflows, with or withoutabandonPending);retry()is the path. CF Workflows follows the same plan (R2b), and so do Temporal and DBOS (R2c, plainwith_message: they do not supportabandonPending). On Temporal and DBOS awith_messageover a session an abort stop had already ended used to start a new turn; it is now refusedAgentNotResumableError(state_not_resumable), like every other runtime. Aresume({ mode: 'continue' })over a stored batch whose run has a recorded stop answers the stopped run's handle with no__submit__instance.- A stopping companion is busy (§7.4).
companion__sendMessageto a child whose live run has a recorded stop answers{ delivered: false, reason: 'child_busy' }and appends nothing (runtime-js, CF Workflows, Temporal through core; the CF DO through the child DO's/statusstopping). - A whole-batch submit no longer erases a stop (the DO's
honoursStopexception is gone): the stop ends the paused run before any submit. - No partial-batch stop bypass.
resume()on a paused session whose client-tool batch is partial is refused (state_not_resumable) whatever is recorded; the old "a pending interrupt bypasses the check" rule is deleted. Acontinueresume (or submit continuation) of a run whose stop is recorded but not yet resolved ends that run by the stop with no model call. - A stop recorded before the run's terminal commit wins, including over an adapter that ignores the signal and returns normally (CP-59).
- A new turn over a stopped live run settles the stopped run, then proceeds; a suspension continuation over it ends it by the stop and does not proceed.
- Resume after a stop pairs unanswered calls. runtime-js
resume()from aninterruptedorfailedsession (not afrom_checkpointrewind) heals trailing unpaired tool calls before its first step, as DBOS does. - Companions.
companion__terminateChildis anabortstop of the child's current run.companion__sendMessageto a running child is anappendand nothing else: no stop is recorded, and the child takes the message at its next boundary (DBOS: as its next turn). A stop of a parent cascades to its live children. - Cloudflare DO:
concurrentEntry: 'interrupt_previous'waits up to the stop'sdeadlineAtplus 5 s (elseconcurrentEntryTimeoutMs), and its stop cascades to the old run's children, background companions included. - agent-server:
interruptObservationDeadlineMsis removed, and so is the 504INTERRUPT_DEADLINE_EXCEEDED. A stop on a session with no live run answers 409state_not_stoppable; an unknown session 404state_session_not_found.
5. Removed APIs
Deleted, not deprecated:
| Package | Removed | Use instead |
|---|---|---|
@helix-agents/core | SessionStateStore.setInterruptFlag / checkInterruptFlag / clearInterruptFlag | requestStop / readStop (or core stopRun) |
@helix-agents/core | SaveAndPromoteOptions.refuseIfInterrupted | refuseIfStopped |
@helix-agents/core | StepCommitInterruptedError, isStepCommitInterruptedError, code state_step_commit_interrupted | CommitStoppedError, isCommitStoppedError, state_commit_stopped |
@helix-agents/core | TerminatePersistentChildInput, CompanionToolDispatchDeps.terminatePersistentChild | StopPersistentChild, CompanionToolDispatchDeps.stopPersistentChild (required) |
@helix-agents/core | the parent_message stop reason | none: sendMessage to a running child is an append |
@helix-agents/core | the interrupt-flag contract scenarios and fence-operations.ts's interruptRefusalScenarios | stopOperationTests |
@helix-agents/agent-server | AgentServerConfig.interruptObservationDeadlineMs, the 504 INTERRUPT_DEADLINE_EXCEEDED | none: the answer is immediate |
@helix-agents/runtime-cloudflare | ExecutionStateImpl.interrupt / abort, DOAB's interruptRun / abortRun, the interrupt-poll alarm subscriber | EndpointContext.stop, the stop routes |
@helix-agents/runtime-temporal | the consumeInterruptFlag activity | readStopActivity |
@helix-agents/runtime-dbos | checkInterruptFlagStep and the set / clear flag steps | readStopStep |
Internal to @helix-agents/runtime-js (not exported from the package entry): InterruptCallback, createActiveHandle's interrupt-callback parameter, InterruptState, createChildInterruptStateView, terminatePersistentChild and CompanionExecutionContext.childAbortControllers.
6. Added APIs
- core:
StopRequest,StopKind,StopTarget,RequestStopInput,RequestStopResult,StopRequestSchema,applyStopPrecedence,parseStoredStopRequest,RefuseIfStopped,stopRefusesCommit;StopAnswer,StopAnswerBodySchema,stopAnswerResponse;stopRun(StopOwnerProbe,StopRunDeps,StopRunInput), the one stop decision;commitStopTerminal, the one writer of a stop's terminal when nothing executes the run;resolveTerminalWithStop,commitTerminalHonouringStop,stopTerminalOf,stopAbortDetail,PlannedTerminal,StopTerminal;CommitStoppedError,isCommitStoppedError; the codesstate_commit_stoppedandstate_not_stoppable;RunStartRejectedErrorcausesexpected_run_stoppedandresumed_companion_ref_terminated; the closed-owner planner'ssettle_stoppedplan;FailCommittedRunResult's'finished'outcome (a run whose own K5 had landed is finished with it, never failed);stopOperationTests. - runtime-js:
JSAgentExecutor.stop;remoteTransportsOf/RemoteTransportFor. - runtime-cloudflare:
DurableObjectAgentConfig.stopDeadlineMs;LookupFactoryContext.reason: 'stop_cascade'(a lookup a stop cascade makes to reach a stopped run's ephemeral children); the CF WorkflowsAgentStepsconstructor's optionalworkflowBindingargument andAgentStepResult.stop. - ai-sdk:
CloudflareChatHandler.stop.