Skip to content

Submit continuation and one-entry abandonment (W1 MR2b) ​

This guide covers the release in which a client-tool submit says what it caused. submitToolResult answers with a required continuation the runtime decides, CF Workflows continues the turn itself after the batch's last answer, and the chat handler acts on that answer instead of guessing by re-reading state. It also covers three changes that ship with it:

  • a new user message over pending client calls is one resume entry (abandonPending);
  • a with_message resume commits its message after the drained client-tool results, and a retry of it needs no options.message;
  • a companion sendMessage is reported delivered only once the child's run started (CompanionDeliveryOutcome).

It is a major release of @helix-agents/runtime-cloudflare and @helix-agents/ai-sdk, a minor release of @helix-agents/runtime-js, @helix-agents/runtime-temporal, @helix-agents/runtime-dbos and @helix-agents/agent-server, and a minor (0.x, so possibly breaking) release of @helix-agents/core and the four stores. Deploy the client and the server together: an accepted answer without continuation fails the schema, and nothing reads the old shape (no back-compat).

Design: 2026-10-10-cf-w1-mr2b-delivery-design.md (read its "As built (MR2b)" section first). Guides: Client-executed tools, Cloudflare Workflows, AI SDK.

Who is affected ​

You …Read
call executor.submitToolResult() directlyThe continuation field
implement a custom executor, MinimalExecutor or HTTP host for the submitThe continuation field, The core router
run CF WorkflowsCF Workflows continues the turn itself
use handleChatStream / useHelixChatThe chat handler acts on the continuation
send resume({ mode: 'with_message' }), or retry() after onewith_message drains first
implement a SessionStateStoreStore contract
use persistent companions (companion__sendMessage)Companion delivery
match on resume / run-start errorsErrors

The continuation field ​

SubmitToolResultResponse's accepted and already_completed arms gain a required continuation (SubmitContinuation, schema SubmitContinuationSchema). SubmitToolResultResponseSchema requires it too, so a forwarded or HTTP answer can never lose it silently.

typescript
type SubmitContinuation =
  | { kind: 'awaiting_batch' } // other calls of the batch are still unanswered: nothing continues yet
  | { kind: 'caller_resumes' } // stored, nothing started: continue the turn with resume()
  | { kind: 'new_run'; runId: string; startSequence?: number } // a new run continues the turn (created or adopted)
  | { kind: 'deferred'; blockingRunId: string } // a run of this session carries (or carried) the continuation
  | { kind: 'consumed' }; // an earlier continuation already took the answer: nothing is owed

What each runtime answers for a complete batch:

RuntimecontinuationWho continues the turn
runtime-jscaller_resumesa follow-up executor.resume()
Temporalcaller_resumesa follow-up executor.resume()
DBOSnew_run { runId }the parked workflow (DBOS.send wakes its recv), in the new run its continuation commits (<suspended run>:continuation)
CF Workflowsnew_run { runId, startSequence? }the __submit__ instance the submit started
CF DOnew_run, deferred, consumed or caller_resumesthe DO's wake, the drive or run that carries the answer, else the caller

Every runtime answers awaiting_batch while the batch is partial and consumed for a re-send whose answer an earlier continuation already drained. accepted is never consumed: a runtime that continues in place (DBOS) may drain the entry before the decision's re-read.

What to change:

  • A custom executor or MinimalExecutor whose submitToolResult returns { status: 'accepted' } no longer type-checks: return the continuation (build it with core resolveSubmitContinuation, below).
  • A host that relays a submit answer over HTTP passes continuation through; parse a received answer with SubmitToolResultResponseSchema. DOWorkflowExecutor.submitToolResult now parses its 200 body with that schema (a body without continuation rejects with a ZodError).
  • Code that called submitToolResult() and then always resume({ mode: 'continue' }) keeps working on runtime-js and Temporal (the resume is the continuation) and on CF Workflows (the resume adopts the same __submit__ instance). On DBOS and the CF DO the runtime already continues the turn: act on the kind instead of calling resume().
  • No resume() is needed after a complete submit on CF Workflows; if you call it, it adopts. A resume({ mode: 'continue' }) while the submit's continuation runs returns a handle on that same run (two concurrent calls both adopt it: one run, no loser). Once the continuation has completed the turn, the call is refused typed (state_not_resumable, non-retryable): send the next message with execute().

The core router ​

routeSubmitToolResult now decides the continuation. Its params change:

ParamBeforeNow
localResolverSubmitreturns SubmitToolResultResponsereturns SubmitStoreAnswer ({ status } only: what the store did)
forwardToOwnerreturns SubmitToolResultResponsereturns SubmitStoreAnswer (a store-write forward) or a peer's full answer
startContinuation—required SubmitContinuationStart: your runtime's continuation for a complete batch

A peer answer (one that carries a continuation) must parse with SubmitToolResultResponseSchema. One that does not is a non-retryable HelixError transport_error. It used to be resolved against the caller's store as a bare store answer. The same check is exported as parseSubmitToolResultAnswer(body, source) for a host that reads a peer's 200 body itself; both CF DO clients use it.

Removed: hasUnconsumedSubmit and shouldContinueAfterSubmit. They were a second "batch ready" rule: every awaited call has submittedAt, with no lapsed-deadline rule and no run filter. They could disagree with the router. Replace them with:

  • the router's answer: act on its continuation; or
  • isSubmittedBatchReadyAtLastSubmit(entries) over the current run's pending entries, the rule the router decides new_run with.

A paused check plus that rule is what the CF DO's isSubmitContinuationOwed does.

typescript
import {
  routeSubmitToolResult,
  type SubmitContinuationStart,
  type SubmitStoreAnswer,
} from '@helix-agents/core';

const startContinuation: SubmitContinuationStart = async ({
  rootSessionId,
  ownerSessionId,
  owner,
}) => {
  // Idempotent: every answer of the batch, and every re-send, may call it.
  return { kind: 'caller_resumes' }; // or new_run / deferred
};

const answer = await routeSubmitToolResult({
  stateStore,
  submit,
  localResolverSubmit: async (root, s): Promise<SubmitStoreAnswer> => storeTheAnswer(root, s),
  forwardToOwner: async (owner, s) => storeTheAnswerOn(owner, s),
  startContinuation,
});

The router answers consumed for the durable completed marker, passes a peer's schema-valid answer (the CF DO's) through unchanged, and otherwise calls core resolveSubmitContinuation (also exported, for a runtime that stores outside the router). It re-reads the OWNER session after the write and decides:

  • unknown_tool_call passes through (nothing was stored);
  • already_completed whose pending entry is gone → consumed;
  • a batch that is not ready → awaiting_batch. Readiness is core isSubmittedBatchReadyAtLastSubmit: not partial at the batch's last submittedAt (a sibling whose deadline lapsed by then counts as answered), with at least one submitted answer. It never uses the reader's clock, so "owed a continuation" means exactly "the router decided to start one". Only entries of the answered call's run count;
  • otherwise your start().

A failed re-read rejects: the answer is stored, and a re-send (answered already_completed) decides again. New exports: SubmitContinuation, SubmitContinuationSchema, SubmitStoreAnswer, resolveSubmitContinuation, SubmitContinuationStart, SubmitContinuationStartInput, SubmitContinuationStartKind, isSubmittedBatchReady (not partial with now, plus at least one submitted answer) and isSubmittedBatchReadyAtLastSubmit.

runtime-js: JsClientToolResolver.submit returns SubmitStoreAnswer, and the SubmitForwarder type accepts either a SubmitStoreAnswer or a peer's full answer.

The chat handler acts on the continuation ​

handleChatStream's resume path (path 3) submits each intent, then acts on the last answered submit'scontinuation. It no longer re-reads the session to guess what happened, and the auto-resume race catch is gone.

continuationResponse
awaiting_batch / consumedan empty SSE (or the gathered per-intent rejections); nothing continues on this request
caller_resumesexecutor.resume({ mode: 'continue' }), streamed from the pre-submit head; a refusal is typed with inputCommitted: true (never 503)
new_run / deferredno resume(): attach to the session stream from new_run.startSequence, else the pre-submit head, waiting for it to go live
  • An attach to a run that started a new UI message streams nothing. The attach is pinned to the run carrying the continuation; one that has not committed its run start yet is waited for within the live-wait bound, and the attach is live once that run's own run_started is read (never on an earlier chunk of another run). When that run is a with_message turn (a send from another tab consumed the answer), another run took the session over, or the handler cannot tell, the response is start (the trailing message's id) → any gathered data-resume-rejected events → one data-tool-output-elsewhere per submitted call (runId = the consuming run) → finish. A newer run taking the stream over mid-attach ends the response with its triggers before finish. A client re-anchors on the snapshot (useHelixChat({ resync }) / useAutoResync); one without a resync hook keeps its last-seen view until the next load.
  • A live-wait timeout on an attach is non-retryable (500 transport_stream_drop, cause: 'run_not_live', no Retry-After). The answer is stored, so re-sending would submit it again: re-anchor through the snapshot (useHelixChat().retry()).
  • The pre-submit head is read unmasked. A failed read no longer attaches from sequence 0: the answer is typed transport_error with inputCommitted: true (500). On caller_resumes the turn is still resumed first.
  • A handler test that pinned the old behaviour (a resume() after every submit, the "resume races a runtime auto-resume" reattach, a 503 for a refused caller_resumes resume) must be updated: those paths are deleted.
  • New optional MinimalExecutor.reconcileSession(sessionId): Promise<{ changed: boolean }>. handleChatStream calls it once after its first reads when the current run is live, and re-reads on changed; buildSnapshot calls it before its view read. The CF Workflows executor implements it (settle a run whose instance closed without its terminal commit, then start a continuation a stored answer is still owed). The DO reconciles on its own wake and does not implement it. A throw is permanent and answered typed.
  • New BuildSnapshotDeps.executor?: MinimalExecutor | Pick<MinimalExecutor, 'reconcileSession'>, so one deps object serves both handleChatStream and buildSnapshot. Pass your executor to get the reconciliation in snapshot reads.

CF Workflows continues the turn itself ​

On CF Workflows the submit that completes the batch starts the continuation:

  • It creates (or adopts) a deterministic instance buildWorkflowInstanceId({ agentType, sessionId: root, kind: 'submit', key }), where key is the first 12 hex characters of SHA-256 of <suspended run id>:<generation>, generation 1..3. Every answer of the batch, every re-send and every reader reaches the same instance; the answer is new_run { runId, startSequence? }.
  • The instance first waits in its body until the suspended run's instance has closed (a step.do probe, then step.sleep, 200 ms → 5 s), so the run-start's 30 s in-step owner wait is never reached. The bound is the new executor option continuationPredecessorTimeoutMs (default 600_000, 10 min). At it the predecessor is terminated (its remaining onAgentSuspended hook work is cut off) and the suspended run reconciled; the terminate is tried 3 times. If it never lands, the continuation ends without committing (cfw.continuation.predecessor_unstoppable, error level), the answer stays stored, and the next submit, resume or reader gets the next generation. Past generation 3 the submit answers a typed, non-retryable transport_error, and the session is no longer owed: the next entry drains the stored answers.
  • resume({ mode: 'continue' }) adopts that instance (it no longer races it), both before its run-start commit and once its run is running behind a live instance (then it returns a handle on that run, with no settle wait; a running continuation whose instance already closed is superseded by the resume instead), so the direct submitToolResult() then resume() pattern keeps working with one instance and no loser.
  • resume({ mode: 'with_message' }) after the batch's last answer waits for the continuation to settle (bounded by continuationPredecessorTimeoutMs + entryOutcomeFailSafeMs), then is the next turn: two entries and two model calls. For ONE entry and ONE model call, send with_message + abandonPending instead of submitting first, or do not submit the answers first. At the bound it throws RunStartOutcomeUnknownError with reason: 'continuation_awaiting_predecessor' (nothing written, nothing terminated: read the session) or 'continuation_running' (retryable: the message was not written). The same reason with ownerStopped: true ends the wait early when the continuation's instance closed without its terminal commit and 3 settles of its run failed.
  • A submit that could not start the continuation answers typed and non-retryable (the answer is stored): a failed create is transport_error; an agent type the executor's registry no longer resolves is framework_agent_not_found (logged at error).
  • CloudflareAgentExecutor.reconcileSession (above) lets a chat-only host settle a closed instance and start an owed continuation from a snapshot read.
  • The executor passes new workflow input fields (AgentWorkflowInputFieldsSchema): awaitPredecessor: { instanceId, timeoutMs }, resumeTrigger: { kind: 'owed_continuation' }, abandonPending, inputAfterDrain, and companionRef (renamed from companionResume, below). A code path that builds or picks these params must carry them.
  • An interrupt set while the continuation waits is honoured: the continuation drains the stored answers, then ends interrupted with no model call.

What to check: the 10-minute bound is the longest a chat turn can wait behind a suspended instance that never closes; lower continuationPredecessorTimeoutMs if your onAgentSuspended hooks are short. Count the __submit__ instances (up to 3 per suspended run) in any instance-id allow-list.

A new message over pending client calls is one entry ​

New core option ResumeOptionsWithMessage.abandonPending?: true:

typescript
await executor.resume(agent, sessionId, {
  mode: 'with_message',
  message: 'never mind, do Y',
  abandonPending: true,
});

The entry answers every unanswered awaited call client_tool_abandoned (stored answers are kept as given), commits the message after the results, and runs ONE model call. It is supported on CF Workflows, runtime-js and the CF DO, and only for a session suspended on client-tool calls alone (core planWithMessageEntry decides; a suspension that also awaits children is refused state_not_resumable). Temporal and DBOS refuse it with framework_not_supported before any write.

The CF DO's native /resume accepts it in its body: { agentType, options: { mode: 'with_message', message, abandonPending: true } } (DoResumeOptions, strict).

handleChatStream's path 4 uses it on an executor that declares the new MinimalExecutor.supportsAbandonOnResume (CloudflareAgentExecutor, JSAgentExecutor, DOFrontendExecutor): one entry instead of a client_tool_abandoned submit per call followed by execute(), so no submit continuation runs a model step on the abandoned results alone. The response streams from the entry run's own start. Temporal and DBOS keep the per-call submits. A custom executor that declares the flag must implement the one-entry semantics. On CF Workflows a session already owed a submit continuation answers the busy 409 state_already_running (nothing is left to abandon), after making sure the continuation exists.

with_message drains first, and its retry ​

A resume({ mode: 'with_message' }) on a session paused on client-tool calls alone (core planWithMessageEntry) with results to drain now commits its message after them, so every tool_use keeps its adjacent tool_result (RM-29's pairing invariant). Before, the message rode the run-start commit and the results were appended after it (RM-74). A crashed or interrupted session that still holds answered calls, and a suspension that also awaits children, still put the message first (FU-JS-WITH-MESSAGE-AFTER-CRASHED-DRAIN-ORDER, FU-WITH-MESSAGE-MIXED-CHILDREN-ORDER).

  • CF Workflows: the drain runs first, then the message is the entry's own append commit (${agentType}-append-entry-input).
  • runtime-js and the CF DO: the results, the abandoned calls' failures, any deferred finishWith call's not-executed result, and the message ride ONE run-start commit, which consumes those pending entries (the consume gate, below). When a result needs execution first (an approved approval-gated call, or a pending entry that is not a trailing call), the message is stashed on one pending entry (entryInput) and the drain appends [its result, …message] in one commit, so a crash never loses it.
  • A deferred finishWith call left beside the answered client call is recorded not executed (unpaired_on_continuation) before the message: the message redirects the turn.
  • The message row carries an entry-input marker (COMMON_METADATA_KEYS.ENTRY_INPUT, markEntryInputRow). Never identify input by role.

retry() after such a resume works as any other retry: when the run failed BEFORE committing a step it needs no options.message (rule 4); after committed steps it needs options.message, like any retry. Core planRetry gains three rules:

  • Rule 4: a resumed run that committed no step, whose entry appended its own marked input after its drain, is rewound to just before that row and re-appends it (and the rows after it); the restored state is the commit that contains the row.
  • Rule 5: while a message is still stashed on a pending entry, the retry always drains first: its run-start commit carries no input and re-stashes the carrier for the retried run (a given options.message replaces the failed run's own stashed rows; other runs' rows are kept).
  • Rule 6: with answered pending calls trailing the kept log, the retry's input is stashed and drained after them, never committed while they pend.

planRetry takes two new inputs, pending (the session's pending calls) and retryRunId (the run the retry commits; required where a stash may be pending), and RetryPlan gains stash and dropDelivered. A custom runtime that calls planRetry gets the rules; one that re-implements retry planning must adopt them.

Other retry outcomes that changed:

  • A hidden sub-agent completion notification is never user input: rule 2's trigger search skips it, and an explicit checkpointId retry whose later rows hold only a notification now answers validation_error (it used to re-run the notification). Notifications after the kept prefix are re-appended, never dropped.
  • A retry({ message }) over answered pending calls now drains them first (it used to commit the message between a tool_use and its result).

New core exports: planWithMessageEntry, WithMessageEntryPlan, markEntryInputRow, isEntryInputRowOf, isEntryInputRow, undeliveredStashRows, EntryInputMarkerSchema, EntryInputMarker, isSubAgentCompletionNotification, SubAgentCompletionNotificationMetaSchema, SUB_AGENT_COMPLETION_SOURCE, notExecutedErrorText.

Result frames after the commit (R23 scope). A row that rides a run-start commit gets its tool_end frame after that commit's run_started, not before its checkpoint: the commit is the gate, and a loser writes nothing. The frames are idempotent for a reader that already holds the rows.

Store contract ​

For SessionStateStore authors. The built-in stores (memory, Redis, Postgres, D1, DO-SQLite) implement all of it; run stateStoreContractTests (run-start.pending-consume-gate, run-start.pending-stash and the marker cases).

  • PendingRestamp.consume?: PendingConsume[]: the pending calls the run-start commit consumes (its rows carry their results). Each names the fields the plan read (expect: { runId, submittedAt, stashedRows }, pendingConsumeExpectation). If any consumed entry changed, vanished or is a stub, the commit is refused with RunStartRejectedError('consumed_pending_changed') and writes nothing; shouldRetryRunStart retries it once from a fresh plan.
  • PendingRestamp.stash?: PendingStash ({ id, expect, entryInput }): ONE carrier entry whose PendingClientToolCall.entryInput is set, gated the same way. A stash is never replaced unread; a retry replaces it through stash (rule 5).
  • PendingRestamp.markCompleted?: boolean: also write each consumed call's durable already_completed marker (completedClientToolCalls, at the store's commit time) in the same commit (core applyConsumedCompletedMarkers, consumedMarkerIds, CompletedClientToolCallsSchema). Only for a root session.
  • applyPendingRestamp(pending, restamp, runId, sessionId) takes the session id (for the typed refusal). Call it inside your atomic step, before anything is applied. Overlapping adopt / drop / consume / stash ids are a typed validation_error.
  • PendingClientToolCall.entryInput?: Message[] (in PendingClientToolCallSchema). Persist the pending map as one JSON value so it round-trips.
  • consumed_pending_changed is a lost claim for isLostClaimError (the DO does not roll back to failed) and is classified transient by classifyEntryFailure (a DO wake re-arms; a request answers retryable).

Companion delivery ​

companion__sendMessage reports delivery through core CompanionDeliveryOutcome:

typescript
type CompanionDeliveryOutcome =
  | { delivered: true }
  | { delivered: false; reason: CompanionNotDeliveredReason; errorDetail?: ErrorDetail };
// CompanionNotDeliveredReason: 'child_busy' | 'child_start_unconfirmed' | 'child_entry_failed'
//   | 'child_not_running' | 'child_exited' | 'no_resume_config'

delivered: false always carries a reason and is a successful tool result (the model reads it and may send again). New exports: COMPANION_NOT_DELIVERED_REASONS, CompanionDeliveryOutcomeSchema, parseCompanionSendMessageResult (a value outside the union is a non-retryable framework_internal_error). Changes:

  • CF Workflows: a resume of an interrupted child, and a detached continue of a completed one, poll the child's exact run id in the parent's body and report delivered: true only once its run-start committed; child_busy (lost to another entry), child_entry_failed (with the child's errorDetail) or, past the new workflow option companionEntryOutcomeTimeoutMs (default 60 s, about 34 engine steps at worst per send), child_start_unconfirmed. The continue path no longer resets the parent's ref before the child exists: the ref turns running inside the child's run-start commit.
  • CF Workflows workflow param companionResume → companionRef (both the resume and the continue instance set it; AgentWorkflowInputSchema requires parentSessionId with it). An instance created before the deploy whose run-start had not run yet loses its ref write (zod strips the old key); drain in-flight companion resumes first, or accept that one ref stays as it was.
  • The CF DO: a child /resume refused for any reason but busy answers { delivered: false, reason: 'child_entry_failed', errorDetail } (it was a tool error); the I1 race answers child_exited.
  • DBOS: child_entry_failed carries errorDetail; a reason-less { delivered: false } is now child_exited.
  • Core handleSendMessage and runtime-js: a child whose loop ended before the message answers child_exited.

Errors ​

  • AgentNotResumableError is a HelixError (code: 'state_not_resumable', never retryable, optional hint appended to the message). Constructor, fields and name are unchanged. CF Workflows' resume() (continue / with_message) throws it for a completed or failed session (it threw a plain Error).
  • RunStartOutcomeUnknownError.reason: 'abandon_failed' (the default), 'continuation_awaiting_predecessor' or 'continuation_running'. It is non-retryable except continuation_running (the caller's message was not written). agent-server's /resume treats continuation_running as not committed (503). CF Workflows also throws continuation_running with ownerStopped: true when the continuation's instance closed and 3 settles of its run failed.
  • parseSubmitToolResultAnswer (and so the router and the CF DO clients) answers a malformed peer or DO 200 submit body with a non-retryable HelixError transport_error, never a raw ZodError or a plain Error.
  • RunStartRejectedError's rejectCause gains 'consumed_pending_changed' (RUN_START_REJECT_CAUSES); a chat host answers it 409 state_run_start_conflict with the cause.

Not changed by this release (follow-ups) ​

  • Temporal still answers caller_resumes and does not self-wake (FU-TEMPORAL-SUBMIT-SELF-WAKE); Temporal and DBOS keep the old with_message order (FU-TEMPORAL-WITH-MESSAGE-DRAIN-ORDER, FU-DBOS-WITH-MESSAGE-DRAIN-ORDER) and the per-call abandonment (FU-TEMPORAL-ABANDON-SEND-STUCK, FU-DBOS-ABANDON-SEND-MODEL-STEP).
  • A with_message over a suspension that also awaits children keeps its old order (FU-WITH-MESSAGE-MIXED-CHILDREN-ORDER), and so does one on a session that is not paused on client calls but still holds answered calls — a run that crashed mid-drain, or an interrupted session (FU-JS-WITH-MESSAGE-AFTER-CRASHED-DRAIN-ORDER; runtime-js, the CF DO and CF Workflows).
  • On a chat client, a consumed call's tool_end in the new response message relies on the AI SDK transformer's foreign-tool-output rule (DI-48, WE-2's standalone MR, which this release builds on).

See docs/dev/follow-ups.md for each.

Released under the MIT License.