Skip to content

Agent Execution Flow ​

This document describes the deterministic execution loop that every runtime implements. The four pre-built runtimes (runtime-js, runtime-temporal, runtime-cloudflare, runtime-dbos) all use the core orchestration helpers documented here.

For the abstract framework concepts that this loop produces, see ./concepts.md. For session lifecycle and state-store contracts (commits, checkpoints, run records), see ./session-model.md.


The agent execution follows a step-based loop (see packages/runtime-js/src/js-agent-executor.ts and packages/runtime-js/src/run-loop.ts):

  1. Create the session (a fresh execute() only) — Executor calls stateStore.createSession() atomically. This persists the session with metadata (userId, tags, organizationId); exactly one concurrent caller wins. On Temporal and Cloudflare Workflows this is a write-once create before the workflow / instance starts; it writes no run and no input row, so a crash right after it strands nothing.

  2. Start the run — An outputSchema agent whose model has no completion strategy is refused first, before anything is written (checkCompletionSupport: framework_completion_strategy_unavailable / framework_completion_schema_unsupported; JS, CF DO, CF Workflows). The run-start commit appends the entry's input rows (possibly none), optionally rewinds (truncateTo), creates the run and writes the input checkpoint, in one atomic store operation. It is the only gate (its rules include the entry gates: the current run and the session must be as the plan read them): there is no separate status CAS claim. The winner then claims the stream, makes it live, writes run_started, writes its entry state (fenced by the new run) and any stream_resync. See Run-start order.

  3. Step Loop — While status is 'running' or 'paused':

    • Read the run's stop record (pollStop: readStop(sessionId, runId)) at each boundary. A stop recorded by any process ends the run by its kind (interrupted, or failed with framework_cancelled); see Stop as one signal.

    • Load the complete history with loadLLMHistory() (see LLM history loading) and build messages with buildMessagesForLLM()

    • Resolve the model's capabilities for this call (resolveCompletionCapabilities, after llmConfigOverride). On a forced-completion call, build the directive for the selected completion strategy (forced_tool or native_output). On a model whose thinking is bound to history, fingerprint the request as sent and refuse a changed system prompt or tool set with framework_prompt_prefix_changed before the call (see Preserved thinking). JS, the CF DO, CF Workflows and Temporal do this (Temporal inside runLLMStep, where the prefix check runs before step_start); DBOS still uses the legacy forced request and no prefix check (MR 3)

    • Write step_start (the attempt's first chunk, stamped with a fresh attemptId), then call the LLM via the adapter with messages + tools

    • planStepProcessing() determines next action from step result

    • Prove the run still owns its session (assertRunOwnsSession), then execute tool calls and sub-agent calls in parallel (see The tool fence)

    • Commit the step: its rows, staged state and a step checkpoint in one commit, then step_committed (see Commit points)

    • resolveStopDecision() makes the stop decision, in the order terminal (structured output, __finish__, a final text or error step) → the agent's stopWhen → maxSteps (see ./step-processing.md §Stop condition checking). A stop_when stop completes the run (completionReason: 'stop_when'); a max_steps stop of an agent without outputSchema fails it with framework_max_steps_exhausted (completionReason: 'max_steps'); an outputSchema agent with no output enters forced completion for either. JS, the Cloudflare DO and Cloudflare Workflows (inside the LLM step's step.do, acted on after the step's tools) use it. Temporal and DBOS still call shouldStopExecution() with maxSteps alone, so they ignore stopWhen and complete a cut-off run (DI-29, DI-25; CF-D1 MR 2 / MR 3). When a structured-output agent completes via __finish__, the step loop also appends a synthetic tool_result so the persisted transcript never holds a dangling tool_use (the history invariant that makes a completed structured-output session continuable). See ./step-processing.md §The __finish__ history invariant.

    • HITL boundary handling (v7 stateless suspension) — If any tool is client-executed, approval-gated, or any sub-agent has not yet completed, the runtime persists durable suspension state in its suspension commit (saveStateAndPromoteStaging, writing pendingClientToolCalls, suspendedAwaitingChildren, or suspendedStepId depending on the kind), writes the run's suspended_* status, pauses the stream and EXITS THE RUN with RunOutcome.kind = 'suspended_*' (see Suspension order). There is no in-memory waiter, no setTimeout, no Durable-Object hibernation guard. Resume is a new entry (executor.resume(sessionId), a client-tool submit, a child completion) that starts a new run:

      • JS — A new run-start commit supersedes the suspended run (its lease was released at the suspension), drains submissions and continues. Process restart is safe; pending entries are recovered from the state store.
      • Temporal — A NEW workflow ${prefix}__${agentType}__${sessionId}__resume-${runId}, keyed on the run id the executor minted for that call (retry() uses __retry__<runId>). The new workflow's entry activity commits the run start, then applyResultsAndReload drains.
      • Cloudflare Workflows — A new write-once instance (buildWorkflowInstanceId, kind resume with a per-call key) with mode: 'resume'; resume drains via applyResultsAndReload.
      • Cloudflare DO — A new run-loop iteration runs in the same DO instance (or a fresh DO if the original was evicted during the wait).
      • DBOS — A submit wakes the same pending workflow (DBOS.recv), which commits a continuation run (same ownerToken, the workflowID) that supersedes the suspended one; an explicit resume() starts a __resume__<runId> workflow.

      Every path drains queued submissions, synthesizes timeout tool_errors for expired deadlines, and resumes the step loop from durable state. See ./concepts.md §Client-Executed Tools for per-runtime mechanics.

  4. Complete — Terminal hooks, the K5 terminal state commit, the terminal run status, then the stream end or failure, on every terminal exit (see Terminal order).

Entry versus recovery ​

Every public entry point starts a NEW run with a run-start commit: execute() (a fresh session or a new turn), every resume() mode, retry(), a client-tool / approval / children continuation, a sub-agent spawn and a persistent-companion continuation (run.new-on-entry.*). Runtimes decide entry versus recovery by code path, never by inspecting state.

Recovery continues an in-flight run instead (run.recovery-keeps-run-id, run.recovery-no-run-start):

  • it keeps the run id, and writes no run-start commit and no run_started;
  • it re-reads its run first and stops (typed RunSupersededError) unless that run is still the session's current run and running (run.recovery-checks-current);
  • when the run's latest checkpoint is its own K5 state commit (the executor died between K5 and the terminal run status), it finishes the run instead of re-entering the loop: core finishCommittedRun writes the run's terminal status from the committed session and the caller ends or fails the stream (run.recovery-finishes-completed). It is recovery-only: an entry hands the same finding (committedTerminalStatus) to its run-start commit as startRun.finishCurrent, and the STORE ends the old run only when its own reads verify that K5 (the session is terminal with that status and the latest checkpoint is the run's own state commit). A verified finish skips the owner guard (CP-84 §3.15): a run whose K5 landed has nothing left to write but its tail, so an unexpired JS lease does not refuse the next turn. The old run's own later status write with the same status is an idempotent no-op, and its stream end is fenced out once the new run claimed the stream (CP-85). An unverified finishCurrent still meets the owner guard.

The recovery paths:

RuntimeRecovery path
JSJSAgentExecutor.recover(): keeps the run id, requires the run current and running, calls finishCommittedRun first, then makes the stream live, truncates uncommitted rows with an in-run fenced truncate commit and restarts its lease, with no run-start or run_started. It still writes run_resumed and fires onAgentResumed.
CF DOthe wake drive: ensureExecutionContinues builds a recover (a running current run, executor.recover()) or continue request and runs it through runDrive; recoverStrandedExecution calls finishCommittedRun for a terminal session whose run is still running, then settles it.
TemporalThe reader path (getHandle(), and resume() on a completed / failed session): recoverCommittedRun finishes a running run whose workflow is provably closed and whose K5 landed. A crashed activity is retried by Temporal inside the same run.
DBOSsettleDeadOwnerRun (the reconnect handle, the active handle once the owner closed, and the interrupt / abort settle): finishes a K5-committed run, else settles it. DBOS recovery of a pending workflow replays it under the same run.
CFWsettleClosedOwner on every executor reader (each handle's result(), getHandle(), canResume(), abort(); DI-34) and consultClosedChildRun in a parent's consult: core closed-owner reconciliation (below) for a live run whose owner instance is provably closed. A K5-committed run is finished (R27); a committed suspension is reported suspended_* (its status written when it still reads running, its own active stream paused); a run with nothing committed is settled failed through failCommittedRun with the detail of how the instance closed (terminated → framework_cancelled; errored → the engine's message, no code; complete without K5 / gone → { message }, except a complete instance whose output is its own failure (validated { status: 'failed', errorDetail }), which keeps that detail; abort() executes only the finish and passes its own cancellation otherwise); an ended run's live own stream is repaired. A live, paused or unreadable owner settles nothing, and a settle whose K5 write fails leaves the run live for the next reader. No lifecycle hook fires. Plus finishCommittedOutcome in the workflow's error catch (finish / repair only).

Run-start order ​

Every entry into running, on every runtime, writes in this order (ordering.run-started-after-commit):

  1. A keyed entry plans from its request key first (D-2), before any concurrency policy and before any plan. On a runtime that records keys (runtime-js, and the CF DO, whose drive does it before checkConcurrency), an entry with a request key reads the run that holds it (getRunByRequestKey) and the current run, and core planKeyedEntry decides: new (continue below; the key and digest ride the run-start commit), attach (a live run holds it) or replay (a finished run that is still current), both answered with that run and writing nothing, or the typed state_request_key_conflict / state_request_already_completed, writing nothing. A duplicate of a live run therefore never meets the busy refusal or interrupt_previous. handleChatStream attaches a keyed send whose key the CURRENT run holds itself, before routing. CF Workflows, Temporal and DBOS declare supportsRequestKey: false and run a keyed entry keyless.
  2. The run-start commit. It is the only gate (idempotency, the request-key race guard, CAS on the current run's id and status, owner guard, session-status gate, supersede-all-live, optional rewind, create; see Entry gates and ./session-model.md §The run-start commit). Two retries of one keyed request that both planned new cannot both commit: the second gets RunStartRejectedError('request_key_taken'), writes nothing, re-reads the key and re-plans (it attaches).
  3. The winner only: claim the stream. claimStream(streamId, runId, { owner }), a compare-and-set driven by core claimStreamForCommittedRun in entry mode. A lagging current-run read throws the retryable StreamClaimStaleReadError (runtime-js retries its stream steps with a bounded back-off); a run that already ended claims nothing. A child that writes into its parent's stream never claims it (R41).
  4. The stream is made live.
  5. CF Workflows only: the killed run's run_terminated, when this entry terminated a stuck instance.
  6. run_started, once per run. On an activity / step retry, Temporal, DBOS and CFW check for the run's own marker before writing it.
  7. The entry state, fenced by the new run (ruling R29: nothing is written before a run-start that can still lose). On JS it is writeEntryState (a commitState when it changes customState, else a fenced saveState); on the DO the claim writes it inside the same transactionSync as the commit (R31); the durable runtimes write it in their run-start activity / step. The resume / retry winner bumps resumeCount here (Temporal, DBOS, CFW).
  8. The entry's stream_resync (retry, from_checkpoint rollback, crash recovery; R34) and run_resumed.
  9. Content.

A loser writes nothing: no input row, no resumeCount, no stream status change, no chunk. It surfaces as RunStartConflictError (named AgentAlreadyRunningError) or RunStartRejectedError: at the call on JS / DO, and from execute() / resume() / retry() on Temporal, DBOS and CFW, which wait only for their ENTRY's outcome (its run record, or its workflow / instance ending without one), never for the run. On CFW, as on Temporal, a winning entry's outcome includes its stream: the call returns once the run wrote a chunk into its own stream at or after its start sequence, its run_started (or the run already left running), so the handle's readers find it (T1-B; a child writing into its parent's stream returns on its run record). An entry carries the run its caller saw (seenRunId); a newer current run at the commit makes it lose typed, and only a stale read of the run it saw retries once (C3 single winner).

A winner whose stream steps fail (claim, make live, run_started) is a started run: core failCommittedRun ends it truthfully in the terminal order (the session failed with the classified errorDetail through a fenced state commit, the run failed, then the stream claimed in terminal mode and failed) and the caller gets the typed RunEntryFailedError. A run superseded meanwhile is left alone (RunSupersededError).

Entry gates (CP-96) ​

An entry commits only if the session and the current run are unchanged since the reads its plan was made from. Two required StartRun fields carry those reads, and the store checks both inside the commit's atomic step:

  • expectCurrentRunStatus: the status of the current run the entry saw (null exactly when expectCurrentRunId is null). Part of the run CAS: the id and the status must match, else RunStartRejectedError('expected_run_status_changed'). Terminal run statuses are sticky, so a mismatch means the run the entry saw was live and has moved on.
  • entryStatuses: the session status(es) the plan read (normally one). The store reads the session's status inside the commit; a status outside the list is RunStartRejectedError('entry_status_changed'). This gate runs AFTER the owner guard, so a still-live winner reports run_active.

The order is: validation, abandoned, idempotency, request key (D-2: request_key_taken), run-id CAS, run-status CAS, owner guard, session gate, supersede / finish. An idempotent replay ends at the idempotency step, before either gate (the session is legitimately active by then), and neither field is part of startDigest. A refusal writes nothing. applyRunStartRules takes the session status the store read as a required sessionStatus, so a custom store cannot compile without the gate. On D1 the batch also pins the current run's status, lease_until and owner_token to the values the owner guard decided on, so a lease renewed between the store's reads no-ops the batch and the decision is retried on the renewed lease.

Why both: a stale concurrent resume() / retry() used to win the commit after another entry's run had started AND finished, because the CAS covered only the run id and the session gate ran at plan time. It then re-ran a second turn over the finished one (a retry rewound the completed turn). A session gate alone still lets a winner that ends in a status the loser accepts (a retry that fails again, a resume interrupted again) be overwritten by a stale plan; the run-status CAS closes that.

The loser's contract is split by cause:

  • A run-status mismatch is the C3 loser: asRunStartLoserError maps expected_run_status_changed (like expected_mismatch_idle) to RunStartConflictError('expected_mismatch'), named AgentAlreadyRunningError. "A newer run started" and "the run I saw moved on" are one race at different timings.
  • A session-status mismatch with the run unchanged (for example an /abort) stays RunStartRejectedError('entry_status_changed'), a typed 409 state_run_start_conflict through handleChatStream; the caller reloads. On CF Workflows the loser now reaches the caller as this rejection (before CP-96 every lost entry collapsed into the C3 conflict).
  • shouldRetryRunStart retries ONCE, only when a fresh read shows exactly what the entry saw (matchesSeenRun: the run id and status, or a session status still in entryStatuses: a lagging read). An expected_run_status_changed rejection with a seen run id but no seen status never retries. runtime-js / the CF DO pass their seen status for every cause (their plan is stale, G2). Temporal, DBOS and CF Workflows pass expected.status only for expected_run_status_changed; for expected_mismatch_idle their check stays id-only (the pre-CP-96 rule), because the retry attempt re-reads expected and re-plans fresh and the store's run-status CAS still guards that commit.

Where the values come from: on runtime-js / the CF DO the status is the one from the entry's first ("seen") run read (resumeSession, retry, execute and the reconnect handle's resumeLoop), because the plan uses a session read made before the builder's expected read; entryStatuses is the status the plan accepted (a branch clone reports the source's copied status). On Temporal, DBOS and CF Workflows the builder reads expected first and then plans from a fresh read, so expectCurrentRunStatus is expected.status and entryStatuses is [current.status]; no workflow input carries them. On those runtimes an owner check (Temporal: the executor's assertNoLiveOwner; DBOS: the entry step's assertDbosOwnerless) or the run-id CAS already refused the CP-96 interleave; the new gates are the in-commit proof for the window between plan and commit.

The loser's class differs per runtime in the CP-96 race (B read the session around A's entry, A won; FU-CP96-DURABLE-LOSER-CLASS):

  • runtime-js / the CF DO: always the C3 AgentAlreadyRunningError (RunStartConflictError('expected_mismatch'), code: 'state_run_start_conflict'). B's plan is stale and its commit carries the status it saw, so the run-status check (the pre-commit check, or the store's run-status CAS inside the commit) refuses it.

  • Temporal, DBOS, CF Workflows: B's entry reads expected and re-plans from a fresh session read (U6), so which of three outcomes B gets depends on when it ran:

    • (a) the C3 conflict (AgentAlreadyRunningError, expected_mismatch), when B's commit lands after A started: B planned before A's commit (the run-id CAS sees A's run; expected_mismatch_idle once A ended, which the id-only retry does not retry because the fresh current run is A, and asRunStartLoserError types as C3), or A ended between B's plan and its commit (expected_run_status_changed → C3). Proven on the real engines by the cp96-entry-interleave probes' "B held at its commit" cases and by the cp96-entry-gates unit tests;
    • (b) RunStartRejectedError('entry_status_changed'), when B plans after A finished: its plan-time status gate refuses a resume() of a session A completed, or a retry() of a session A left no longer failed, before any commit (Temporal's asLostRetryRace keeps the rejection, because the current run is still the one B saw);
    • (c) an owner-check refusal while A's workflow / instance is still open, before B's commit: Temporal's executor assertNoLiveOwner throws a plain AgentAlreadyRunningError (currentStatus: 'running', no code / conflictCause); DBOS's entry step assertDbosOwnerless throws RunStartConflictError('run_active') (the executor's assertNoOpenOwner applies only once the session reads active); CF Workflows' executor awaitPriorInstanceSettled waits for a live previous instance (a timeout is a plain AgentAlreadyRunningError) and its builder's assertCfwOwnerless throws RunStartConflictError('run_active') (code reading; no real-engine probe). The Temporal and DBOS shapes are proven by the probes' "A held after its commit" cases (bReachedCommit === false).

    classifyError maps all of (a) and (c) to state_already_running (every RunStartConflictError is an AgentAlreadyRunningError, whatever code it carries), and (b) to state_run_start_conflict. Treat every one as "another entry won": reload. The durable runtimes thus ADD two loser classes to C3; unifying them needs the seen run status carried into the durable entry, which reverses part of U6.

A concurrent execute() that read a live run on runtime-js / the CF DO loses (AgentAlreadyRunningError, a retryable 409 on a send) even when that run finished before its commit; Temporal, DBOS and CFW re-plan inside the builder, so a later entry there runs as an ordinary next turn.

Run-start fail-safes. Temporal, DBOS and CFW bound how long a call waits for its entry's outcome (an entry no worker takes, or whose start keeps failing):

RuntimeOptionDefault
TemporalentryOutcomeTimeoutMs120 000 ms
DBOSrunStartFailSafeMs120 000 ms
CFWentryOutcomeFailSafeMs. An explicit entryStartTimeoutMs replaces the wait: the call returns at that deadline without abandoning, and 0 disables the wait120 000 ms

Past the bound the call runs stateStore.abandonRunStart(sessionId, runId): 'committed' returns the handle (the input was delivered) on Temporal and DBOS. On CFW a committed run gets its handle only if its entry STARTED (its run_started is in its own stream and its entry state is written). A committed run first gets a start grace until its instance closes or is paused: the same bound again, measured from when the call first saw the commit (entryProgress; a transient owner read or abandon failure does not end it; an abandon whose verdict is known is skipped, and a failed abandon re-reads the run before any terminate), so a real stall can take up to about twice the bound. The final start check (confirmEntryStart) re-reads a failed read of the run, its own stream or the session; a start it still cannot read is never a stall: it gets one more grace pass, then the call throws RunStartStalledError with startUnknown: true and ownerStopped: false, stopping and settling nothing. A run that still has not started is a stall: the call terminates the instance and, when it stopped, settles the run failed (K5 → run → stream) with the error's own detail and throws core RunStartStalledError (transport_error, settle: 'failed'). The stall is NEVER retryable (its input is committed, so repeating the call would append it twice; the recovery is executor.retry(agent, sessionId)). Its settle and message follow the settle's outcome: 'not_committed' (the K5 did not land; a reader settles the run before the retry()), 'not_found', 'superseded' (a newer run took the session over); a run that ended on its own meanwhile is no stall and the call returns its handle. When the terminate failed it settles nothing (ownerStopped: false: the run may still proceed, read the session first). Temporal and DBOS still hand out the committed run without that check (FU-TEMPORAL-ENTRY-FAILSAFE-SILENT-HANDLE). 'abandoned' records a tombstone, so the in-flight entry can never commit later (RunStartRejectedError('entry_abandoned')), stops the owner best-effort and throws core RunStartAbandonedError (transport_error, retryable once the owner was stopped, or where a retry starts a fresh owner). If the abandon itself throws, the call throws RunStartOutcomeUnknownError (transport_error, non-retryable: read the session before retrying). On CFW that is the last resort: a failed abandon leaves no tombstone, so the call re-reads the run (3 attempts; a run it finds gets the start grace, no terminate), otherwise terminates the instance (the only fence against a late commit) and re-reads it again (3 attempts). Only a run still not found is RunStartOutcomeUnknownError; a run that committed before the terminate landed gets one start check (started → the handle; not started → the stall above; start unreadable → the stall with startUnknown: true, whose message says its start could not be confirmed, still settled when the instance stopped since it can never finish the run). Neither is persisted on the session (a CFW stall is persisted only through its settle). The errors live in core (Temporal no longer re-exports them; DBOS's RunStartFailSafeError was replaced). CFW also waits (read-only, priorInstanceSettleTimeoutMs, default 30 000 ms) for a session's still-finishing previous instance before an entry.

Commit points ​

Each boundary is one commit (K4), with the stream's live head as its streamSequence:

BoundaryCommit
Entry (the run's input, optional rewind)run-start appendMessages (input, plus truncate when truncateTo removes rows)
Entry state that changes customStatecommitState (state)
An injection, a client-tool / approval result, a continuation healappendMessages (append), fenced on the run
A tool step / text step / __finish__ stepsaveStateAndPromoteStaging (step): the step's assistant row, its tool results and its staged state, then step_committed
A phase-2 finishWith result (Temporal)a separate append after the step commit (RM-52)
A suspensionsaveStateAndPromoteStaging (step) with the pending calls, ownership and suspendedStepId
A companion call's result (JS / CF DO child phase, CP-106)an append after the step or suspension commit, one per call in call order, fenced on the run (refuseIfStopped)
An in-run rewind / interrupt truncatetruncateMessages (truncate)
The end of the runK5 commitState (state)

Step atomicity (step.atomic): a step's messages commit in one step commit, and an interrupted or failed step persists none. DBOS stages a step's tool results and commits them in the step's own promote (RM-40), with the live sequence (RM-39); Cloudflare Workflows commits every message-log mutation (RM-41), and commits a tool step once, after its last result: its sub-agent, remote, companion and phase-2 finishWith results, and the sub-agent folds, ride the step commit (RM-69; a suspension in the step is that commit, with the rows so far). saveStateAndPromoteStaging({ refuseIfStopped }) refuses a step commit when the run has a stop record, in the same atomic step (CommitStoppedError), so a stop that lands before the commit always wins.

Suspension order ​

A suspending step's pending calls' frames (tool_start / tool_approval_request) are written BEFORE its suspension commit, so the commit's streamSequence covers them (P1, checkpoint.suspension-covers-frames), and the stream is paused only AFTER the commit:

RuntimeOrder
JS / CF DOframes (during the step's tool dispatch) → suspension commit → step_committed → onAgentSuspended → the run's suspended_* status → pauseStream
Temporalframes → commitSuspendedStep (the commit, step_committed, then the suspended_* status) → suspension_marker → pauseStream
DBOSthe run's suspended_* status → each waiting call's prologue (beforeTool, the pending registration, the frame) → suspension commit → pauseStream; onAgentSuspended after the commit
CF Workflowsthe run's suspended_* status → frames → suspension commit → pauseStream. A cascade suspension (a sub-agent wait) is the reverse: the step's suspension commit, THEN the run status

On every runtime the status precedes pauseStream (ordering.run-status-before-paused), so a submit that observed paused finds a suspended run and never conflicts with run_active. "Suspension committed" is signalled by the stream's paused status, the last write of the sequence; the run's suspended_* status alone does not imply it, because DBOS and CFW write it before the commit. Every stream manager's pauseStream writes run_paused and is fenced on the run. A finishWith call deferred by a client call is not covered by the suspension commit: it announces itself in the continuation run.

The child phase (JS / CF DO, CP-106). A step's companion calls are announced with the step's other calls but run after its commit (the step commit, or the suspension commit above), through core runChildPhase: per call, the replay check, the fenced ref write, the keyed child start and the start fence, then its result append and its tool_end. On runtime-js the run lease is released at the run's status write, which follows the phase, so a resume() cannot supersede a run while its phase runs. A step's finishWith runs after the phase. A recovered run (recover) and a resume of a suspended run re-enter the unfinished calls of the last committed step (reenterChildPhase), before any re-park on children. See concepts.

Closed-owner reconciliation (core) ​

When a run's owner is provably closed (a CF Workflows instance that ended, a Durable Object whose driver gave up), a reader settles the run from durable state, never from the run status alone: the suspension order above differs per runtime, so a run killed in the gap reads suspended_* with no suspension commit on CF Workflows and running WITH its suspension committed on the DO. Core planClosedOwnerReconciliation (pure) decides, reconcileClosedOwnerRun executes fenced on the run (module core/src/run/reconcile-closed-owner-run.ts). The ladder, first match wins:

Durable statePlanExecutor
run supersedednonenothing
not the session's current runnonenothing
live (running or suspended_*), latest checkpoint is the run's own K5 state commitfinish_committedfinishCommittedRun, then end / fail the run's own active or paused stream (R27)
live, session paused and awaiting calls or children (isSuspensionCommitted)complete_suspensionsuspended_* status if the run still reads running (stepCount only), pause the own stream if active; report
live, nothing committedfail_uncommittedfailCommittedRun({ requireSessionCommit: true }) with the caller's failure
completed / failed / interrupted, own stream still active or pausedrepair_streamend / fail the stream from the committed outcome
anything elsenonenothing

isSuspensionCommitted({ session }) reads the session only. The latest checkpoint cannot decide it: a re-suspension (runtime-js / DO R47 reSuspendOnRestoredBatch, a CF Workflows resume whose respawned children re-suspend) writes none of its own. A stale paused session behind a resume entry that crashed after its run-start commit counts as suspended: the entry's commit already restamped the pending calls to the new run, so the turn stays resumable, whereas failing it would destroy the user's turn. The suspended status comes from the run if it already reads one, else from the session's maps (suspendedStatusOf; the executor derives it again from its own session read, and writes nothing — none / suspension_not_committed — when that read no longer records a committed suspension): a mixed server+client step killed between its commit and its status write derives suspended_client_tool (resume is unaffected). The executor writes to a stream only when its claim owns it (claimStreamForCommittedRun, terminal mode) and never to a child's stream in its parent (isForeignStream). A caller plans only an owner it proved closed.

Every plan carries the runId it was made for (the executor throws framework_internal_error before any read for another run). The executor's outcome says what it did to the run's own stream: finished, suspended and stream_repaired carry stream (written, already_terminal, not_owned, foreign or absent), and a none outcome carries a reason. repair_stream always answers stream_repaired, so a caller checks stream (only written repaired it). A claim that owns nothing logs the reconcile.stream_not_owned warn. A suspended_* run whose own K5 landed is finished like a running one (a live to terminal run-status transition), and a paused own stream of a run that ended is ended or failed too; a pause never revives a stream.

Who calls it. CF Workflows wires it in three places, all through one owner probe (cfwOwnerClosure: the run's instance is complete, errored, terminated or gone, or the run has no owner token; a failed probe proves nothing):

  • The executor readers (settleClosedOwner: each handle's result(), getHandle(), canResume(), abort()) read and plan first and stop on none without a binding call; otherwise they probe the owner, and only for a closed one read and plan AGAIN and execute that plan (a commit that landed before the close is never overruled by a stale plan). suspended is reported as the run's suspended_* status with suspendedResultOf(session); stream_repaired and none leave the readers to report the run's own record. abort() executes only finish_committed and fails every other run with its own cancellation.
  • The parent's consult (consultClosedChildRun in workflow.ts), after a wait for a child's completion event that received none: the same plan and execute on the child's run (read again once its owner is proven closed), mapped to the event the child would have sent (suspended → a suspended child the parent cascades; a finished or own-K5 ended run → its committed outcome; fail_uncommitted → the settled failure, AgentError with the persisted errorDetail's message, the same answer a later consult gives for that failed run with or without its own K5; not_committed → nothing yet, re-wait). A permanent error (core isPermanentStoreError: a non-retryable HelixError or a corrupt stored row) answers a typed errored; a transient one re-waits.
  • The owner's own R27 path (AgentSteps.finishCommittedOutcome): when an error ends the workflow after the run's K5 landed, the instance executes only finish_committed and repair_stream and returns the committed outcome.
  • The entry fail-safe (failStalledEntry), once it stopped a stalled run's owner: the same plan and execute with the owner known stopped and the stall as the failure (fail_uncommitted → the stall settled failed; a landed K5 is finished, a committed suspension reported — no stall).

Every CFW caller reads the run's own stream status best-effort (an unreadable stream is planned as absent), and core's executor makes the stream half best-effort after the run-level write (stream: 'unavailable', logged reconcile.stream_unavailable): a stream Durable Object that is down never hides a durable outcome.

The CF Durable Object adopts it on its wake path in the WA MR1 work; runtime-js, Temporal and DBOS keep their own finish-or-fail copies (FU-JS-RECONCILE-CLOSED-OWNER-ADOPT, FU-TEMPORAL-RECONCILE-CLOSED-OWNER-ADOPT, FU-DBOS-RECONCILE-CLOSED-OWNER-ADOPT).

Terminal order ​

Every terminal exit (completion, failure, stop / interrupt; user ruling D1) writes, in this order:

  1. The agent's terminal hooks (onAgentComplete on completion, onAgentFail on failure). Their customState writes land in K5: onAgentComplete's on every runtime, onAgentFail's only on Temporal and CF Workflows (runtime-js and the CF DO do not apply them; DBOS hooks cannot write state). A durable runtime runs them once per run across retries (Temporal stages them under terminal-hooks-<runId>).
  2. K5: commitState (kind: 'state') with the terminal status, output / error / errorDetail / interruptContext and the hooks' writes (above), fenced on the run (commit.terminal-state). Bounded retries; a K5 that still fails ends the run failed with the commit's code (commit.terminal-state-failure) through a fenced saveState, and a completing run then also fires onAgentFail (R40).
  3. The terminal run status (updateRunStatus, which releases the JS lease) — ordering.state-before-run-status.
  4. The stream end or failure, only by a run that still owns its stream (a child sharing its parent's stream never ends it, R41), fenced on the run — ordering.run-status-before-end. On an interrupt, run_interrupted is written with the stream end.
  5. The parent notification (sub-agents).
RuntimeTerminal sequence
JS / DOonCompleted / onFailed → commitTerminalState (K5) → writeRunStatus → finalizeStreamFor*; a durable-flag interrupt: finalizeDurableInterrupt
TemporalpersistTerminalState = commitTerminalState (hooks + K5) → finalizeTerminalRun (run status → stream end / fail)
DBOSfinishTurn (via finalizeFailure / failTurn / discardInterrupted): hooks → commitTerminalStateStep (K5) → finalizeRunStep → stream
CFWfinishRun: hooks → commitTerminalState (K5) → finalizeRun → endRunStream / failRunStream / writeRunInterrupted

endAgentStream (which used to set the status, fire onAgentComplete and close the stream in one activity / step) is gone from Temporal and Cloudflare Workflows. A run another entry already ended (its run-start's finishCurrent) makes no status or stream write (N4). The DO host's onRunSettled hook runs from the settle path, after the stream end and the execution release, and writes no session state.

R49: entry failures fire no lifecycle hook. A run that fails at its ENTRY (its run-start commit, stream claim or run_started) fires no onAgentStart, onAgentFail or onAgentComplete. onAgentFail fires only for a run whose start hook (onAgentStart, or onAgentResumed for a resume / retry) fired, keyed on a durable record: Temporal the staged agent-start-hooks-<runId> record (a "start attempted" marker before the hooks, then their outcome; a crash between the two re-fires them, so they run at least once), DBOS agent-start-fired-<runId> (written in the hook's step before the hook runs; abort() reads it), CFW the recorded result of its start-hook step.do (runEntered). A throwing start hook fails the run with exactly one onAgentStart and one onAgentFail. On JS the pairing follows the code path: entry failures throw before the run loop, which is where onAgentStart fires. A pre-commit failure of execute() or retry() on a session that has never had a run fires no hook either: core failSessionWithoutRun records the session alone (R43). On a session that already has a run it writes nothing (M1, below).

Superseded runs. A run whose write hit the fence, or whose lease was lost, stops with RunSupersededError: it writes nothing more, does not touch the stream, fires onAgentSuperseded (never onAgentFail; onAgentComplete only when the supersede lands after it fired and before K5, so complete → superseded, the last hook authoritative), and reports AgentResult.status === 'superseded'. See ./concepts.md §Superseded runs.

The tool fence ​

Core assertRunOwnsSession(store, sessionId, runId) (packages/core/src/run/run-ownership.ts) reads the session's current run and throws RunSupersededError unless it is runId and live. Every runtime calls it IMMEDIATELY before each side effect the store fence cannot cover: a tool execution, a sub-agent spawn, a client-tool or approval registration (on JS through the step iterator's assertRunOwned hook, before approval registration, tool / client-tool execution, sub-agent spawn and finishWith). A superseded run therefore never executes a tool. A session with no run record at all passes (nothing could have superseded the run).

Core orchestration logic lives in packages/core/src/orchestration/:

  • step-iterator.ts — v7 unified step iterator (runStepIteration); wraps phase-1/phase-2 tool execution, hook firing, staging promotion, and HITL suspension classification. Consumed by all four HITL-capable runtimes.
  • step-processor.ts — Determines what to do after each LLM step
  • message-builder.ts — Creates internal Message types for conversation
  • state-operations.ts — State initialization and tool building
  • stop-checker.ts — Stop condition evaluation
  • suspension-classifier.ts — Classifies a step's terminal state into a RunOutcome.kind ('suspended_client_tool' | 'suspended_awaiting_children' | 'suspended_step_partial' | ...).
  • run-outcome.ts — RunOutcome discriminated union and SuspendedChildWait payload type.

Run lifecycle primitives live in packages/core/src/run/ and packages/core/src/stream/:

  • run/run-lease.ts — RunLease (the JS lease heartbeat; it stops the run only on a store-rejected renewal).
  • run/run-ownership.ts — assertRunOwnsSession (the tool fence).
  • run/finish-committed-run.ts — committedTerminalStatus / finishCommittedRun (R27 / R35).
  • run/fail-committed-run.ts — failCommittedRun (a started run whose entry failed after its commit).
  • stream/run-markers.ts — claimStreamForCommittedRun, chunkFenceRunId, emitRunStarted, emitStepCommitted, emitStepDiscarded.
  • stream/with-run.ts / stream/with-attempt.ts — the runId / attemptId stamps.

History loading, commit and checkpoint primitives live in packages/core/src/store/:

  • load-llm-history.ts — loadLLMHistory / historyIncompleteError.
  • message-commit.ts — MessageCommit, StartRun, CommitKind, resolveCommitSequence, NO_STREAM_SEQUENCE, startDigest.
  • run-start.ts — applyRunStartRules, assertValidEntryGate, planRunStartRewind, assertRunIsCurrentLive / assertRunIsCurrentIdle / decideRunStatusTransition, planPendingRestamp.
  • retry-plan.ts — planRetry.
  • snapshot-view.ts — readSnapshotView, deriveSnapshotStatus.
  • checkpoint-ops.ts — resolveCheckpoint, assertRewindable, planRewindToCheckpoint, applyRewindRootOwnership, rewindToCheckpoint (the in-run / custom-loop path), discardedToolEndChunk, loadBranchSource, DiscardedToolCall.

LLM history loading ​

Every runtime's LLM path reads history through one function, loadLLMHistory(store, sessionId):

  1. Page the whole log with getAllMessages (no cap, no window).
  2. Read getMessageCount. On every built-in store this is a live row count (COUNT(*) on Postgres / D1 / DO-SQLite, LLEN on Redis, array length in memory), never a denormalized column.
  3. If the counts differ, re-read, up to 3 reads in all. Fewer rows than counted means an append landed between the two reads; more rows than counted means a truncate did (for example a concurrent from_checkpoint rewind). Both are live writers, not corruption, so neither may raise a false C9 error. A resume that loses that race to a rewind then loses its run-start commit and throws AgentAlreadyRunningError.
  4. If the counts still differ, throw HelixError state_history_incomplete (category: 'state', retryable: false). If either store call threw, throw the same code with retryable: true and the store error as cause: a store throw may be a transient blip (a dropped Redis / Postgres connection), so the runtime's own retry policy applies to it (Temporal activity retry; DBOS getMessagesStep re-attempts a retryable failure up to 3 times; the CF Workflows LLM step.do retry). The loader itself never retries a throw.

A runtime never hands the model a partial transcript. Bookkeeping reads that only decide what to append at the tail of the log — the trailing unpaired-call heal on continuation, the superseded / deferred finishWith result filters, Temporal's appendMessages({ onlyTrailingUnpaired }) and companion re-consult heal, CF Workflows' appendSupersededResults and continuation heal, DBOS's healTrailingUnpairedCalls — use raw getAllMessages on purpose (B18/A6b): they never feed the model, a store throw still propagates to the retry policy, and the next LLM step's loadLLMHistory fails the run typed on any count mismatch before a model call. The error becomes a failed run whose errorDetail.code is state_history_incomplete, persisted on SessionState.errorDetail, on the failed stream chunk and in onAgentFail, with no model call. An ENTRY that fails this way before its run-start commit is different (rule M1, below): the caller gets the typed error, a session that already has a run is not touched, and a session that never had a run gets the detail on its session only (no stream chunk, no onAgentFail; on runtime-js and the CF DO). How each runtime carries the classified detail to its terminal-failure seam:

RuntimeWhere history is loadedHow the classified error reaches the run
JS / CF DOEvery entry point that seeds state.messages: execute() on an existing session, resume() (all modes), retry(), branch/continue loadFromSession, the per-step reload after a companion sendMessage appended to the run (the doorbell: the store's message count is ahead of the loop's), child reloads, companion continuation from interrupted (CP-43)Inside the loop: onFailed. retry() after its own run-start: failRetryRun (core failCommittedRun, each write fenced on the retry run: failed + errorDetail, run status, failed stream; onAgentFail when it ended the run). Before the run-start commit (execute()'s first history read, its run-start plan, retry()'s plan): persistPreCommitFailure (rule M1, below). execute() on a live (active) session still throws AgentAlreadyRunningError instead
CF WorkflowsexecuteAgentStep and loadAgentState (strict). Bookkeeping seams (failAgentStream, endAgentStream, discardStagedChanges, handleInterrupt, the forced-completion recorders) read leniently, so they can still report the failurestep.do keeps only name/message/stack, so guardLostClaims puts every step failure's ErrorDetail JSON in the message behind a marker (DI-46, DI-73); stepErrorDetail decodes it with errorDetailSchema() for the outer catch and every local catch, and strips the marker from any user-visible text
TemporalInside the runLLMStep activity. History never travels through workflow code or activity payloads (payload size limits, and every payload is recorded in workflow history)Re-thrown as an ApplicationFailure with the ErrorDetail as details[0], non-retryable for a count mismatch and retryable for a store throw (the activity retry policy applies); the workflow catch extracts it (extractApplicationFailureDetail) and passes it to resolveErrorDetail
DBOSgetMessagesStep (a checkpointed DBOS step; re-attempts a retryable store throw up to 3 times inside the step)The workflow body captures the step rejection and calls finalizeFailure with the classified detail, also when DBOS replays a failed step and revives the error without its class

Rule M1: a pre-commit entry failure never touches a session that already has a run. An entry (execute(), resume(), retry()) that fails before its run-start commit (a history read short of the live count, a store throw while planning) never started a run and never made the stream live, so it has nothing to end. The caller still sees the typed failure: on runtime-js / the CF DO, execute()'s FIRST history read (C9) hands back a handle whose result() is failed with the code, and every other read rejects the call; on CF Workflows the call returns a handle (the instance closed without a run) whose result() is failed with the typed errorDetail the instance returned, its preCommitFailure (only a completion-support refusal and a lost run-start throw at the call). A CF Workflows instance that closed errored, terminated or complete with no run and no failed output still yields a preCommitFailure, derived from the engine status as a reader's settle derives it (DI-34: errored → the engine's message, no code; terminated → framework_cancelled; complete → a message naming what was observed, no code). The CF DO's request drives (/start, /resume, /retry) await the entry's outcome and answer such a failure typed, never started, and the rule covers /resume too (WA MR1 I-D6). The persistPreCommitFailure seam reads the session's current run first:

  • any current run (live or ended): nothing is written: no session status, error or errorDetail, no stream reactivate / fail / chunk, no lifecycle hook. The session keeps its previous outcome;
  • the current-run read throws: nothing is written either. JS / CF DO stop there; Temporal's activity and CF Workflows' error-pre-commit step rethrow, so their retry policy re-runs the read (on CFW a failed save is retried the same way; see the CFW row below);
  • no run ever: only then is the session recorded failed with the classified errorDetail (a planning validation_error is not recorded on CFW / DBOS). On runtime-js, the CF DO and CF Workflows this is core failSessionWithoutRun with currentRunId: null: an idle-fenced session save only, with no stream write and no lifecycle hook (R43: streams are written only by their runs, so a never-run failure never fails the next turn's live stream or leaves an error chunk after its cursor; see the upgrade guide). Temporal still writes the stream for it (table below).
RuntimePre-commit entry failure
JS / CF DOM1 (JSAgentExecutor.persistPreCommitFailure, for every pre-commit failure of execute() / retry(); a pre-commit resume() failure writes nothing). A never-run session goes through failSessionWithoutRun (session failed + errorDetail only; no stream, no hook). The DO wake fails an active session that has no run record the same way, and a refused DO /retry writes nothing
TemporalM1 (persistPreCommitFailure, reached from commitTerminalState for a run that never committed, returns before any write when the session has a run); a never-run session's stream is also failed (FU-TEMPORAL-NEVER-RUN-PRE-COMMIT-STREAM-WRITE)
DBOSNot M1 yet: persistPreCommitFailure (steps/run-commit.ts) records the session failed whenever no run is LIVE, so a finished session is overwritten (FU-DBOS-PRE-COMMIT-ENTRY-FAILURE-MARKS-SESSION-FAILED)
CFWM1 (AgentSteps.persistPreCommitFailure); a never-run session's failure writes ONLY the session (R43): core failSessionWithoutRun (currentRunId: null, no stream manager) saves it failed under the idle fence (fenceIdle: { runId: null }), and no stream is created, read, failed or written (a run that commits meanwhile owns the session: superseded, nothing written). A failed current-run read or save (other than RunSupersededError) rejects, and the error-pre-commit step retries it (PRE_COMMIT_FAILURE_STEP_CONFIG: 3 retries, about 47 s at most; error-run-exists uses it too, about 94 s for both: inside the 120 s entry fail-safe when the entry's own steps fail fast, while a hang in check-existing / run-start can outlast it and the caller gets the typed abandon / outcome-unknown error). Once those retries are exhausted the workflow logs the failed write at error level (an exhausted error-run-exists skips the write, logged at warn) and still returns the entry's own classified failure (the parent is told errored once); a never-run session then stays active with no run

Temporal's step result is delta-only. runLLMStep returns nextState.messages: [], plus newMessages (the assistant message and any synthetic results this step appended, in order) and baseMessageCount (the length of the loaded history). The workflow commits newMessages and derives absolute hook indices from baseMessageCount. Before this, projectSessionToAgent read a messages field that SessionState does not have, so every Temporal LLM call received only the system prompt (RM-10).

DBOS checkpoint counts. Between-step and terminal checkpoints take messageCount from the checkpointed getMessageCount step, never from the length of a read (RM-12). The loaded history is itself a DBOS step output, so it is recorded once per step; see the O(N²) note in ../dev/follow-ups.md.

Hook messageIndex is the message's absolute position in the persisted log on every runtime.

Checkpoint rewind and branch ​

All runtimes resolve and apply checkpoints with the same core primitives. A rewind is part of the rewinding run's run-start commit (ruling R21), so it is atomic with the new run: there is no separate claim, hold or partial rewind for another caller to observe.

resume({ mode: 'from_checkpoint' }):

  1. Resolve first. resolveCheckpoint(store, sessionId, checkpointId?) runs before any write or workflow start, for retry({ checkpointId }), resume({ mode: 'from_checkpoint' }) and branch.checkpointId (against the source session). An unknown id throws state_checkpoint_not_found; another session's id throws state_checkpoint_foreign. A rejection changes nothing. No id → the latest checkpoint; null means genesis (no checkpoint yet).
  2. Liveness. A rewind of a session whose current run is still live and owned is refused: by the owner guard of the run-start commit on every runtime (RunStartConflictError), and up front with core assertRewindable(session) on Temporal and CF Workflows when the current run is live-owned (an active session that is not suspended on pending client-tool / approval calls throws AgentAlreadyRunningError). A suspended run is supersedable (its JS lease was released at the suspension; the durable runtimes' owners exited), so a rewind of a suspended session supersedes it. Every runtime accepts any non-live status for from_checkpoint, completed / failed included: it time-travels a finished session back to the target and starts a run (C7). DBOS refuses a live run; for a session suspended on a PENDING workflow it cancels that workflow (so its owner is closed) and starts a fresh one, whose run-start commit supersedes the suspended run.
  3. Plan. Core planRewindToCheckpoint({ sessionId, checkpoint, history, current }) is pure: the keep-count (checkpoint.messageCount), the rewound state under the CURRENT session's identity (the checkpoint's customState, stepCount, forcedCompletion and checkpointId; output / error / errorDetail cleared), and the pending calls it keeps, restores and discards. A currently pending call survives only if its tool_use is in the kept prefix; a call that was pending AT the checkpoint and answered after it is restored, so the log never keeps an unpaired tool_use (R8); discarded pre-submit stubs are dropped silently.
  4. Commit. The run-start commit carries truncateTo = plan.keepCount and the pending re-stamp (planPendingRestamp): the store deletes the rewound-away rows, writes the truncate checkpoint attributed to the new run, re-stamps the kept calls to it and drops the discarded ones, and creates the run, in one transaction (commit.start-truncate-atomic; truncateTo is part of startDigest, so an idempotent replay never truncates again). Of concurrent rewinds and resumes, exactly one commit wins (the CAS on the current run); the losers write nothing.
  5. Winner. Claim → live → run_started → the rewound entry state (fenced) → applyRewindRootOwnership (a child session's root ownership for discarded / restored calls) → stream_resync (reason: 'rollback', carrying truncateCheckpointId) → run_resumed → one discardedToolEndChunk (a tool_end with errorCode: 'aborted') per discarded call. A late submitToolResult for one returns { status: 'unknown_tool_call' }. run_resumed.fromCheckpointId and onAgentResumed's resumedFromCheckpointId report the TARGET checkpoint. A target that restores pending calls re-suspends at once while any of them is unsubmitted (R47). That re-suspension is one atomic transition: the entry state itself is paused with the restored calls re-stamped to the new run (the CF DO writes it inside its run-start commit, runtime-js as its one fenced entry write), so the session never reads active over a stored partial batch. The run then gets onAgentSuspended, its suspended_client_tool status (no completedAt) and the stream pause; nothing is drained.

releaseRewindHold remains in core for pending entries an earlier claim-pinned rewind marked rewindHeld: Temporal and CFW applyResultsAndReload write every kept entry through it. rewindToCheckpoint(store, sessionId, checkpoint, ctx, options?) (ctx is the commit context: stream manager, stream id, run id) remains as the in-run path (a fenced saveState plus a truncate commit) and the building block for custom loops; the built-in entries do not call it. The claim CAS, rewindHeld hold and rollbacks that preceded the run-start rewind (rollbackFailedResumeClaim, rollbackLostRewindClaim, failRewoundSession) are no longer used by the built-in runtimes.

A non-rewinding resume() also truncates inside its run-start commit when uncommitted rows sit past the latest checkpoint (a crashed step), and writes a crash_recovery stream_resync when it removed rows or the resumed run's orphan chunks (JS, CF DO, CFW, DBOS).

Branch. execute(agent, input, { branch }) copies exactly the source's history up to the chosen point:

  • checkpointId → the first checkpoint.messageCount messages and the checkpoint's customState;
  • messageIndex → the first messageIndex messages (a count, as in CloneSessionOptions.fromMessageIndex) and the source's current customState;
  • neither → every message and the current customState. checkpointId wins when both are set.

JS and DBOS use store.cloneSession (a clone commit) after resolving the checkpoint on the source session (cloneSession does not check ownership). Temporal (in the initializeAgentState activity) and CF Workflows (in initializeAgentState) use loadBranchSource; both executors first call resolveCheckpoint synchronously in execute(), so a foreign or unknown checkpoint throws before any workflow or instance starts, and the in-workflow check is a backstop. CF DO cannot branch: a Durable Object reads only its own SQLite database.

Retry ​

retry() (a failed session only) is a run-start commit on every runtime, DBOS included (CP-30: it honours RetryOptions.checkpointId and message):

  1. Resolve an explicit checkpointId first (resolveCheckpoint).
  2. Plan with core planRetry from fresh reads (see ./session-model.md §Retry target): the failed run's last step boundary, else its run-start commit (re-running its trigger, CP-58), never its K5 commit. A run whose record says completionReason: 'max_steps' continues with stepCount 0 (the full budget, never its entryStepCount) and needs no input, from its last step boundary or, when it committed no step (a resume at the cap), from its kept entry (C5; JS, the DO and CFW accept an empty plan.input). A retry with nothing to retry with (no options.message and no user row after the target) throws validation_error before any write; DBOS and CFW plan once read-only in the call for that reason, and the entry re-plans inside its run-start.
  3. Run-start commit with truncateTo = plan.truncateTo, the retried input rows (plan.input), the restored entry state (customState, stepCount, forcedCompletion from the target) and the pending re-stamp. The entry's step count is recorded as entryStepCount, and its forced phase rides StartRun.entryState so the retry's own input checkpoint records it (DI-40; a new turn's input checkpoint likewise records no previous-turn phase or output, so a first-step failure's retry never opens forced).
  4. Winner: claim → live → run_started → entry state → stream_resync (reason: 'retry', carrying truncateCheckpointId when rows were removed) → run_resumed → content. The Temporal, DBOS and CFW winners bump resumeCount.

Retry creates a run record and writes a run_started (run.retry-creates-run-record / -run-started); no runtime cleans stream chunks on retry. Retry workflow / instance ids: Temporal …__retry__<runId>, DBOS …__resume__<runId>, CFW buildWorkflowInstanceId kind retry with a per-call key.

Released under the MIT License.