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_messageresume commits its message after the drained client-tool results, and a retry of it needs nooptions.message; - a companion
sendMessageis 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() directly | The continuation field |
implement a custom executor, MinimalExecutor or HTTP host for the submit | The continuation field, The core router |
| run CF Workflows | CF Workflows continues the turn itself |
use handleChatStream / useHelixChat | The chat handler acts on the continuation |
send resume({ mode: 'with_message' }), or retry() after one | with_message drains first |
implement a SessionStateStore | Store contract |
use persistent companions (companion__sendMessage) | Companion delivery |
| match on resume / run-start errors | Errors |
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.
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 owedWhat each runtime answers for a complete batch:
| Runtime | continuation | Who continues the turn |
|---|---|---|
| runtime-js | caller_resumes | a follow-up executor.resume() |
| Temporal | caller_resumes | a follow-up executor.resume() |
| DBOS | new_run { runId } | the parked workflow (DBOS.send wakes its recv), in the new run its continuation commits (<suspended run>:continuation) |
| CF Workflows | new_run { runId, startSequence? } | the __submit__ instance the submit started |
| CF DO | new_run, deferred, consumed or caller_resumes | the 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
MinimalExecutorwhosesubmitToolResultreturns{ status: 'accepted' }no longer type-checks: return the continuation (build it with coreresolveSubmitContinuation, below). - A host that relays a submit answer over HTTP passes
continuationthrough; parse a received answer withSubmitToolResultResponseSchema.DOWorkflowExecutor.submitToolResultnow parses its200body with that schema (a body withoutcontinuationrejects with aZodError). - Code that called
submitToolResult()and then alwaysresume({ 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 callingresume(). - No
resume()is needed after a complete submit on CF Workflows; if you call it, it adopts. Aresume({ 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 withexecute().
The core router
routeSubmitToolResult now decides the continuation. Its params change:
| Param | Before | Now |
|---|---|---|
localResolverSubmit | returns SubmitToolResultResponse | returns SubmitStoreAnswer ({ status } only: what the store did) |
forwardToOwner | returns SubmitToolResultResponse | returns 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 decidesnew_runwith.
A paused check plus that rule is what the CF DO's isSubmitContinuationOwed does.
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_callpasses through (nothing was stored);already_completedwhose pending entry is gone →consumed;- a batch that is not ready →
awaiting_batch. Readiness is coreisSubmittedBatchReadyAtLastSubmit: not partial at the batch's lastsubmittedAt(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.
continuation | Response |
|---|---|
awaiting_batch / consumed | an empty SSE (or the gathered per-intent rejections); nothing continues on this request |
caller_resumes | executor.resume({ mode: 'continue' }), streamed from the pre-submit head; a refusal is typed with inputCommitted: true (never 503) |
new_run / deferred | no 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_startedis read (never on an earlier chunk of another run). When that run is awith_messageturn (a send from another tab consumed the answer), another run took the session over, or the handler cannot tell, the response isstart(the trailing message's id) → any gathereddata-resume-rejectedevents → onedata-tool-output-elsewhereper submitted call (runId= the consuming run) →finish. A newer run taking the stream over mid-attach ends the response with its triggers beforefinish. 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 (
500transport_stream_drop,cause: 'run_not_live', noRetry-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_errorwithinputCommitted: true(500). Oncaller_resumesthe 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, a503for a refusedcaller_resumesresume) must be updated: those paths are deleted. - New optional
MinimalExecutor.reconcileSession(sessionId): Promise<{ changed: boolean }>.handleChatStreamcalls it once after its first reads when the current run is live, and re-reads onchanged;buildSnapshotcalls 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 bothhandleChatStreamandbuildSnapshot. 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 }), wherekeyis 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 isnew_run { runId, startSequence? }. - The instance first waits in its body until the suspended run's instance has closed (a
step.doprobe, thenstep.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 optioncontinuationPredecessorTimeoutMs(default600_000, 10 min). At it the predecessor is terminated (its remainingonAgentSuspendedhook 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-retryabletransport_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 directsubmitToolResult()thenresume()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 bycontinuationPredecessorTimeoutMs + entryOutcomeFailSafeMs), then is the next turn: two entries and two model calls. For ONE entry and ONE model call, sendwith_message+abandonPendinginstead of submitting first, or do not submit the answers first. At the bound it throwsRunStartOutcomeUnknownErrorwithreason: 'continuation_awaiting_predecessor'(nothing written, nothing terminated: read the session) or'continuation_running'(retryable: the message was not written). The same reason withownerStopped: trueends 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 isframework_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, andcompanionRef(renamed fromcompanionResume, 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
interruptedwith 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:
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
appendcommit (${agentType}-append-entry-input). - runtime-js and the CF DO: the results, the abandoned calls' failures, any deferred
finishWithcall's not-executed result, and the message ride ONE run-start commit, which consumes those pending entries (theconsumegate, 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
finishWithcall 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.messagereplaces 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
checkpointIdretry whose later rows hold only a notification now answersvalidation_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 atool_useand 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 withRunStartRejectedError('consumed_pending_changed')and writes nothing;shouldRetryRunStartretries it once from a fresh plan.PendingRestamp.stash?: PendingStash({ id, expect, entryInput }): ONE carrier entry whosePendingClientToolCall.entryInputis set, gated the same way. A stash is never replaced unread; a retry replaces it throughstash(rule 5).PendingRestamp.markCompleted?: boolean: also write each consumed call's durablealready_completedmarker (completedClientToolCalls, at the store's commit time) in the same commit (coreapplyConsumedCompletedMarkers,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. Overlappingadopt/drop/consume/stashids are a typedvalidation_error.PendingClientToolCall.entryInput?: Message[](inPendingClientToolCallSchema). Persist the pending map as one JSON value so it round-trips.consumed_pending_changedis a lost claim forisLostClaimError(the DO does not roll back tofailed) and is classifiedtransientbyclassifyEntryFailure(a DO wake re-arms; a request answers retryable).
Companion delivery
companion__sendMessage reports delivery through core CompanionDeliveryOutcome:
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: trueonly once its run-start committed;child_busy(lost to another entry),child_entry_failed(with the child'serrorDetail) or, past the new workflow optioncompanionEntryOutcomeTimeoutMs(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 turnsrunninginside the child's run-start commit. - CF Workflows workflow param
companionResume→companionRef(both the resume and the continue instance set it;AgentWorkflowInputSchemarequiresparentSessionIdwith 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
/resumerefused for any reason but busy answers{ delivered: false, reason: 'child_entry_failed', errorDetail }(it was a tool error); the I1 race answerschild_exited. - DBOS:
child_entry_failedcarrieserrorDetail; a reason-less{ delivered: false }is nowchild_exited. - Core
handleSendMessageand runtime-js: a child whose loop ended before the message answerschild_exited.
Errors
AgentNotResumableErroris aHelixError(code: 'state_not_resumable', never retryable, optionalhintappended to the message). Constructor, fields and name are unchanged. CF Workflows'resume()(continue/with_message) throws it for acompletedorfailedsession (it threw a plainError).RunStartOutcomeUnknownError.reason:'abandon_failed'(the default),'continuation_awaiting_predecessor'or'continuation_running'. It is non-retryable exceptcontinuation_running(the caller's message was not written). agent-server's/resumetreatscontinuation_runningas not committed (503). CF Workflows also throwscontinuation_runningwithownerStopped: truewhen 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-retryableHelixErrortransport_error, never a rawZodErroror a plainError.RunStartRejectedError'srejectCausegains'consumed_pending_changed'(RUN_START_REJECT_CAUSES); a chat host answers it409state_run_start_conflictwith the cause.
Not changed by this release (follow-ups)
- Temporal still answers
caller_resumesand does not self-wake (FU-TEMPORAL-SUBMIT-SELF-WAKE); Temporal and DBOS keep the oldwith_messageorder (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_messageover 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_endin 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.