Skip to content

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 the consumeInterruptFlag activity 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 list readStopActivity and no longer list consumeInterruptFlag. There is no patch id: the stop change is not gated by wf.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.

typescript
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 (running or suspended_*), 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 throws HelixError state_session_not_found.
  • Precedence (core applyStopPrecedence): an abort upgrades a recorded interrupt (the abort's reason and requestedAt, the earlier deadline); an interrupt never downgrades an abort; 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. deleteSession removes the session's rows.
  • The commit guard. refuseIfStopped on commitState and on appendMessages without startRun (MessageCommit.refuseIfStopped: true | { allowKind }), and on saveStateAndPromoteStaging and promoteStaging (true), refuses the commit in the same atomic step as the write, after the superseded-run fence: it throws CommitStoppedError (state_commit_stopped, non-retryable, carrying the stored stop) and writes nothing. { allowKind } refuses only a stop of another kind: a stop's own terminal commits with it, so an abort that upgraded the interrupt being resolved is never overwritten by interrupted. 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 (core parseStoredStopRequest); one that fails it is a permanent InvalidStoredStateError.
  • Contract suite. stopOperationTests (@helix-agents/core/testing) runs inside stateStoreContractTests on every store; a store whose harness wipes instead of deleting sets skip.deleteSession.

Per-store storage and migrations ​

StoreStop storageMigration
Postgrestable __agents_stop_requests, primary key (session_id, run_id)V16 (also drops __agents_interrupt_flags)
D1table __agents_stop_requestsV20 (also drops __agents_interrupt_flags)
DO-SQLitetable 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)
Redisone hash per session, {prefix}:session:stops:{sessionId} (field = run id)none (written by the commit scripts)
Memoryin-process mapnone

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)StopAnswerMeaning
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_foundno session
typed 503 (core typedErrorResponse)throwsa 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 with errorCode), and EndpointContext.stop(kind, reason?), which now resolves the StopAnswer instead of void.
  • @helix-agents/ai-sdk: CloudflareChatHandler.stop({ sessionId, kind?, reason? }) (new) passes the DO's answer through unchanged.
  • @helix-agents/agent-server: POST /interrupt, POST /abort and the /chat/{id}/interrupt|abort routes; interruptAgent() / abortAgent() now return the StopAnswer.
  • runtime-js: JSAgentExecutor.stop(sessionId, { kind, reason?, agent? }) (new) returns it. agent lets the stop reach remote children through the agent's remote tools; without it they are not reached.

Before:

typescript
await server.interruptAgent(sessionId); // resolved once the run observed the flag, or threw (HTTP 504)
const status = (await stateStore.loadState(sessionId))?.status; // 'interrupted'

After:

typescript
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.

Runtimeinterrupt()abort()
runtime-jsthe stop of the handle's own runthe same, abort
Cloudflare DO clientposts /interrupt; throws the typed error on a 404, a 503 or a transport failure (it used to warn)posts /abort, the same
Cloudflare Workflowsrecords, wakes the open instance (an event)records, terminates the instance, and settles the run before it resolves
Temporalrecords, signals the workflowrecords, signals and cancels the workflow, and resolves once the stop is durable; the cancelled workflow commits its own failed K5 (F4-C)
DBOSrecords, sends the control message, cancels the workflow, then settles the run after 500 msthe 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 (or failed with framework_cancelled), clears pendingClientToolCalls and 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-call tool_end is 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 answer accepted and discard the result silently. Nothing is stored and no continuation starts (on CF Workflows, no __submit__ instance). reason is 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_message over 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 without abandonPending; abandonPending over a session an interrupt already ended is a no-op (it used to be refused state_not_resumable). An ABORT ends the session failed (framework_cancelled), and a failed session is not resumable: that resume() is refused typed (state_not_resumable: ResumeRefusedError on runtime-js and the CF DO, AgentNotResumableError on CF Workflows, with or without abandonPending); retry() is the path. CF Workflows follows the same plan (R2b), and so do Temporal and DBOS (R2c, plain with_message: they do not support abandonPending). On Temporal and DBOS a with_message over a session an abort stop had already ended used to start a new turn; it is now refused AgentNotResumableError (state_not_resumable), like every other runtime. A resume({ 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__sendMessage to 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 /status stopping).
  • A whole-batch submit no longer erases a stop (the DO's honoursStop exception 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. A continue resume (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 an interrupted or failed session (not a from_checkpoint rewind) heals trailing unpaired tool calls before its first step, as DBOS does.
  • Companions. companion__terminateChild is an abort stop of the child's current run. companion__sendMessage to a running child is an append and 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's deadlineAt plus 5 s (else concurrentEntryTimeoutMs), and its stop cascades to the old run's children, background companions included.
  • agent-server: interruptObservationDeadlineMs is removed, and so is the 504 INTERRUPT_DEADLINE_EXCEEDED. A stop on a session with no live run answers 409 state_not_stoppable; an unknown session 404 state_session_not_found.

5. Removed APIs ​

Deleted, not deprecated:

PackageRemovedUse instead
@helix-agents/coreSessionStateStore.setInterruptFlag / checkInterruptFlag / clearInterruptFlagrequestStop / readStop (or core stopRun)
@helix-agents/coreSaveAndPromoteOptions.refuseIfInterruptedrefuseIfStopped
@helix-agents/coreStepCommitInterruptedError, isStepCommitInterruptedError, code state_step_commit_interruptedCommitStoppedError, isCommitStoppedError, state_commit_stopped
@helix-agents/coreTerminatePersistentChildInput, CompanionToolDispatchDeps.terminatePersistentChildStopPersistentChild, CompanionToolDispatchDeps.stopPersistentChild (required)
@helix-agents/corethe parent_message stop reasonnone: sendMessage to a running child is an append
@helix-agents/corethe interrupt-flag contract scenarios and fence-operations.ts's interruptRefusalScenariosstopOperationTests
@helix-agents/agent-serverAgentServerConfig.interruptObservationDeadlineMs, the 504 INTERRUPT_DEADLINE_EXCEEDEDnone: the answer is immediate
@helix-agents/runtime-cloudflareExecutionStateImpl.interrupt / abort, DOAB's interruptRun / abortRun, the interrupt-poll alarm subscriberEndpointContext.stop, the stop routes
@helix-agents/runtime-temporalthe consumeInterruptFlag activityreadStopActivity
@helix-agents/runtime-dboscheckInterruptFlagStep and the set / clear flag stepsreadStopStep

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 codes state_commit_stopped and state_not_stoppable; RunStartRejectedError causes expected_run_stopped and resumed_companion_ref_terminated; the closed-owner planner's settle_stopped plan; 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 Workflows AgentSteps constructor's optional workflowBinding argument and AgentStepResult.stop.
  • ai-sdk: CloudflareChatHandler.stop.

Released under the MIT License.