Skip to content

feat(core): opt-in deferred step_completed write (WORKFLOW_ASYNC_STEP_COMPLETED)#2972

Draft
VaguelySerious wants to merge 8 commits into
mainfrom
peter/async-step-completed
Draft

feat(core): opt-in deferred step_completed write (WORKFLOW_ASYNC_STEP_COMPLETED)#2972
VaguelySerious wants to merge 8 commits into
mainfrom
peter/async-step-completed

Conversation

@VaguelySerious

@VaguelySerious VaguelySerious commented Jul 17, 2026

Copy link
Copy Markdown
Member

Note

Draft, stacked on #2970 (base branch peter/inline-delta-turbo-unlocks) — it reuses that PR's isSequentialReplaysEnabled() runtime helper, openHookAndWaitState, and forceOptimisticStart shape. Retarget to main after #2970 merges (GitHub does this automatically).

What

Adds an opt-in WORKFLOW_ASYNC_STEP_COMPLETED=1: for a sequential chain of inline steps, the runtime no longer awaits each step's step_completed POST before continuing. Instead it:

  1. Initiates the terminal write and immediately returns from executeStep with the in-flight promise plus locally-synthesized step_created/step_started/step_completed events (content-equal to the canonical ones: same correlationId, stepName, input, result bytes; locally-minted IDs/timestamps).
  2. Replays the next iteration fetch-free over cachedEvents + synthesized tail (the tail is transient — never persisted into the durable cache), so the workflow discovers the next step and — with optimistic start active — starts its body immediately.
  3. Chains every subsequent durable write behind the deferred write (fail-closed): the next step_started, all suspension writes (hook_created/wait_created/attr_set, via a new fail-closed preWriteBarrier on handleSuspension), and run_completed. The log can never record downstream events without their upstream terminal.
  4. Reconciles when the write lands: merges the canonical events into the cache via the inline delta the World returned (perf(core): skip per-step events.list via inline event-log delta #2475), or an incremental events.list when it didn't, and advances the cursor — before any chained write computes its stateUpdatedAt snapshot (no self-inflicted 412 under WORKFLOW_PRECONDITION_GUARD).
  5. Settles before every ack: reinvoke(), the run-terminal writes, the V2-timeout / replay-budget bail-outs, and the loop top all await the deferred write first. A failure leaves the message unacked so redelivery's owned recovery (A hook_received while a step attempt is in flight re-dispatches the step; both attempts run concurrently in-process #2780) re-runs the step.

Per step this hides one write RTT behind the next step's replay + body start; for chains of short steps on world-vercel that RTT is a large share of step-to-step overhead (STSO).

Gate

deferCompletedWrite =
  WORKFLOW_ASYNC_STEP_COMPLETED === '1'
  && single clean sequential inline step        // same shape as the inline-delta gate
  && no hook created by this suspension
  && no open hook && no open wait                // stricter than the delta gate: no out-of-band writers at all
  && turbo first delivery                        // the one case with no concurrent orchestrator invocation
  && optimistic inline start active              // otherwise the next body waits for its own
                                                 // step_started, which chains behind the deferred
                                                 // write anyway — deferring would buy nothing

Compared to the gate proposed in review discussion (!hasOpenHookOrWait && (first invocation || WORKFLOW_SEQUENTIAL_REPLAYS)), this adds:

  • single-step restriction: with parallel steps in flight, this handler's locally-assumed event order could invert relative to the durable log order (the deferred write commits at an unknown position relative to a concurrently-completing sibling), which is exactly the Promise.race split-brain shape — so parallel batches keep the awaited path;
  • optimistic-start requirement: purely so the flag only engages where it can actually win;
  • an env flag, default off — see the semantics section below for why this shouldn't be on by default yet.

Failure handling

  • Deferred write conflicts (EntityConflictError) or lands on a terminal run (RunExpiredError): another writer owns the outcome and this invocation already speculated on our result, so the awaited path's benign skipped/gone mapping would be wrong. The rejection is converted to PreconditionFailedError, which existing routing turns into a fresh replay (reinvoke(0)) or an unacked redelivery — never run_failed.
  • Transient write failure: propagates out of the handler (unacked), queue redelivers with fast backoff, the redelivered message owns the step (ownerMessageId unchanged) and re-executes it.
  • Crash mid-window: the message was never acked; redelivery's owned recovery re-runs the step. If the deferred write actually landed, the re-execution's step_started hits the terminal-state conflict and is skipped.

Semantics disclosure (why opt-in)

  1. Stale-input propagation on crash. Today, once step N's step_completed is durable, step N+1 always sees the same input on any retry. With deferral, a crash while N's write is in flight re-runs N on redelivery — and a nondeterministic N can produce a different result than the one N+1's body already consumed (that body ran, its side effects happened, and no event records it). This is a strictly stronger idempotency requirement on step side effects than WORKFLOW_OPTIMISTIC_INLINE_START (which only re-runs the same step with the same input).
  2. VM-clock skew across replays. Synthesized events carry local timestamps; the canonical events carry server timestamps. The workflow VM clock advances to each consumed event's createdAt, so Date.now() inside workflow code can differ by roughly one write RTT + clock skew between the synthesized-tail replay and every later replay. A workflow that branches on Date.now() within that window could take different paths across replays → replay-divergence recovery, and in the worst case a detected CORRUPTED_EVENT_LOG. (Time-branching workflows this sensitive are already fragile, but the deferral widens the exposure.)
  3. Telemetry. STSO/TTFS eligibility on a deferred iteration is judged against the pre-reconcile snapshot, so some samples that would have been emitted on the awaited path are skipped. Hydration caching keys on event IDs, so a deferred step's result is hydrated at most twice (synthesized + canonical ID), not O(replays).

Once validated (E2E + benchmarks), folding this into turbo's default is a one-line gate change.

Changes

  • runtime/constants.tsisAsyncStepCompletedEnabled().
  • runtime/step-executor.tsdeferCompletedWrite param; deferred branch initiates the write (threading sinceCursor for the delta as before), registers it with safeWaitUntil, and returns deferredCompleted: { write, synthesizedEvents }.
  • runtime/suspension-handler.ts — new preWriteBarrier param awaited without swallowing rejections before every suspension write (unlike runReadyBarrier, whose swallow is intentional for run_started ordering).
  • runtime.ts — the gate; transient-tail replay path; reconcile chain (delta merge or fetch fallback); barrier composition into executeStep's runReadyBarrier; settle points on every ack path; the deferred step stays in inFlightOwnedSteps until its write settles so the existing ack-invariant assertion covers the window.

Tests

pnpm test in packages/core: 69 files, 1496 passed | 3 expected fail. New:

  • Overlap proof: with the first step's step_completed create held open, the second step's body runs (impossible on the awaited path) while the second step_started is not issued; after release, step_completed(A) strictly precedes step_started(B) and the run completes.
  • Default-off control: same harness without the flag — the loop blocks on the held write; the second body never runs early.
  • Sequential-replays arm: attempt-2 (non-turbo) delivery with WORKFLOW_SEQUENTIAL_REPLAYS=1 + WORKFLOW_OPTIMISTIC_INLINE_START=1 defers the same way.
  • Fail-closed conflict: deferred write rejecting with EntityConflictError propagates as PreconditionFailedError out of the unacked handler — no run_completed, no run_failed.
  • Env-flag parsing tests in constants.test.ts.

The World-returned-delta reconcile arm is exercised by the existing #2475 suite (extraction) plus the fetch-fallback tests here; an E2E pass on world-vercel (which returns deltas) is the remaining validation before undrafting.

Updates

  • Rebased on Enable additional perf optimizations when correctness guarantees are met  #2970's review follow-up (World capabilities): the sequential-replays arm of the deferCompletedWrite gate now also requires world.capabilities.maxConcurrency, matching the base PR's capability gating (env flags alone don't prove backend behavior).
  • Failure-path settle fix (0f02df4, via review agent): the non-suspension failure branch (ReplayDivergence recovery re-queue and the run_failed terminal write) acked the message without settling the deferred write — a superseded write could permanently fail the run on speculative state. It now settles fail-closed first: a superseded write re-invokes for a fresh replay; transient failures rethrow (unacked → redelivery). Covered by a new regression test (workflow consumes the speculative result then throws, deferred write superseded → re-invoke, no run_failed).

Update: sequential-replays arm retracted

Following the second review round on #2970 (a runtime env var cannot prove the built flow trigger carries maxConcurrency: 1), the WORKFLOW_SEQUENTIAL_REPLAYS arm of the deferCompletedWrite gate is retracted too: deferral now engages only on turbo's first delivery. The seq arm can return together with the base PR's follow-up once the build emits a verifiable serialization signal.

Docs Preview

Page Preview (v5)
Runtime Tuning — WORKFLOW_ASYNC_STEP_COMPLETED workflow-docs-git-peter-async-step-completed.vercel.sh/v5/docs/configuration/runtime-tuning#workflow_async_step_completed

(Link requires Vercel team access — the preview deployment is behind deployment protection.)

…imistic start under sequential replays

Two relaxations of conservative gates in the inline replay loop, each tied
to the mechanism that makes it safe:

1. The inline-delta fast path (skip one events.list per sequential step) no
   longer turns off for runs with an open hook when the precondition guard
   (WORKFLOW_PRECONDITION_GUARD=1) is enabled. A hook_received landing in
   the delta window is the same read-to-write race the fetch path already
   has, and with the guard on it is fenced: the marker bump 412s the stale
   replay's guarded creates, which retry over the reloaded log or exhaust
   into a fresh-replay re-invocation. Open waits keep the conservative gate
   (wait_completed does not bump the outside-event marker).

2. Turbo keeps forcing optimistic inline start after the run creates a hook
   or wait when WORKFLOW_SEQUENTIAL_REPLAYS=1: per-run maxConcurrency: 1
   flow topics serialize the resume invocations hooks/waits introduce, so
   no concurrent orchestrator replay can race the optimistic create-claim,
   restoring the single-handler guarantee turbo relies on.

Adds isSequentialReplaysEnabled() to the core runtime (mirroring the
@workflow/builders / @workflow/world-vercel copies) and splits
hasOpenHookOrWait into per-kind state so the two gates can differ.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jul 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f7740b9

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
Name Type
@workflow/core Minor
@workflow/builders Patch
@workflow/cli Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/vitest Patch
@workflow/web-shared Patch
@workflow/web Patch
workflow Minor
@workflow/world-testing Patch
@workflow/astro Patch
@workflow/nest Patch
@workflow/nuxt Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
example-nextjs-workflow-turbopack Ready Ready Preview, Comment Jul 17, 2026 8:25pm
example-nextjs-workflow-webpack Ready Ready Preview, Comment Jul 17, 2026 8:25pm
example-workflow Ready Ready Preview, Comment Jul 17, 2026 8:25pm
workbench-astro-workflow Ready Ready Preview, Comment Jul 17, 2026 8:25pm
workbench-express-workflow Ready Ready Preview, Comment Jul 17, 2026 8:25pm
workbench-fastify-workflow Ready Ready Preview, Comment Jul 17, 2026 8:25pm
workbench-hono-workflow Ready Ready Preview, Comment Jul 17, 2026 8:25pm
workbench-nitro-workflow Ready Ready Preview, Comment Jul 17, 2026 8:25pm
workbench-nuxt-workflow Ready Ready Preview, Comment Jul 17, 2026 8:25pm
workbench-sveltekit-workflow Ready Ready Preview, Comment Jul 17, 2026 8:25pm
workbench-tanstack-start-workflow Ready Ready Preview, Comment Jul 17, 2026 8:25pm
workbench-vite-workflow Ready Ready Preview, Comment Jul 17, 2026 8:25pm
workflow-docs Ready Ready Preview, Comment, Open in v0 Jul 17, 2026 8:25pm
workflow-swc-playground Ready Ready Preview, Comment Jul 17, 2026 8:25pm
workflow-tarballs Ready Ready Preview, Comment Jul 17, 2026 8:25pm
workflow-web Ready Ready Preview, Comment Jul 17, 2026 8:25pm

@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

Passed Failed Skipped Total
✅ ▲ Vercel Production 1453 0 230 1683
✅ 💻 Local Development 1483 0 200 1683
✅ 📦 Local Production 1617 0 219 1836
✅ 🐘 Local Postgres 1617 0 219 1836
✅ 🪟 Windows 153 0 0 153
✅ 📋 Other 894 0 177 1071
✅ vercel-multi-region 27 0 0 27
Total 7244 0 1045 8289

Details by Category

✅ ▲ Vercel Production
App Passed Failed Skipped
✅ astro 126 0 27
✅ example 126 0 27
✅ express 126 0 27
✅ fastify 126 0 27
✅ hono 126 0 27
✅ nextjs-turbopack 150 0 3
✅ nextjs-webpack 150 0 3
✅ nitro 126 0 27
✅ nuxt 126 0 27
✅ sveltekit 145 0 8
✅ vite 126 0 27
✅ 💻 Local Development
App Passed Failed Skipped
✅ astro-stable 128 0 25
✅ express-stable 128 0 25
✅ fastify-stable 128 0 25
✅ hono-stable 128 0 25
✅ nextjs-turbopack-canary 134 0 19
✅ nextjs-turbopack-stable 153 0 0
✅ nextjs-webpack-stable 153 0 0
✅ nitro-stable 128 0 25
✅ nuxt-stable 128 0 25
✅ sveltekit-stable 147 0 6
✅ vite-stable 128 0 25
✅ 📦 Local Production
App Passed Failed Skipped
✅ astro-stable 128 0 25
✅ express-stable 128 0 25
✅ fastify-stable 128 0 25
✅ hono-stable 128 0 25
✅ nextjs-turbopack-canary 134 0 19
✅ nextjs-turbopack-stable 153 0 0
✅ nextjs-webpack-canary 134 0 19
✅ nextjs-webpack-stable 153 0 0
✅ nitro-stable 128 0 25
✅ nuxt-stable 128 0 25
✅ sveltekit-stable 147 0 6
✅ vite-stable 128 0 25
✅ 🐘 Local Postgres
App Passed Failed Skipped
✅ astro-stable 128 0 25
✅ express-stable 128 0 25
✅ fastify-stable 128 0 25
✅ hono-stable 128 0 25
✅ nextjs-turbopack-canary 134 0 19
✅ nextjs-turbopack-stable 153 0 0
✅ nextjs-webpack-canary 134 0 19
✅ nextjs-webpack-stable 153 0 0
✅ nitro-stable 128 0 25
✅ nuxt-stable 128 0 25
✅ sveltekit-stable 147 0 6
✅ vite-stable 128 0 25
✅ 🪟 Windows
App Passed Failed Skipped
✅ nextjs-turbopack 153 0 0
✅ 📋 Other
App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 128 0 25
✅ e2e-local-dev-tanstack-start- 128 0 25
✅ e2e-local-postgres-nest-stable 128 0 25
✅ e2e-local-postgres-tanstack-start- 128 0 25
✅ e2e-local-prod-nest-stable 128 0 25
✅ e2e-local-prod-tanstack-start- 128 0 25
✅ e2e-vercel-prod-tanstack-start 126 0 27
✅ vercel-multi-region
App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: success
  • Local Dev: failure
  • Local Prod: success
  • Local Postgres: success
  • Windows: success

Check the workflow run for details.

@vercel vercel Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional Suggestion:

In the WORKFLOW_ASYNC_STEP_COMPLETED opt-in path, the non-suspension failure branch acks the queue message via the ReplayDivergence-recovery re-queue and the run_failed terminal write without first settling the pending deferred step_completed write, permanently failing runs on speculative, already-superseded state.

Fix on Vercel

@VaguelySerious

Copy link
Copy Markdown
Member Author

Note on the red E2E Local Dev Tests (nextjs-webpack - canary) lane: it fails on the exact same test (dev.test.ts > should follow Next flow-route HMR rebuild rules for body-only changes, ~4min timeout) as the latest main run — the lane is currently flaking on main at ~2 of the last 3 runs. The feature in this PR is opt-in (WORKFLOW_ASYNC_STEP_COMPLETED unset in CI) and the identical base code passed this lane on #2970, so this is the known canary HMR flake, not a regression from this branch.

VaguelySerious and others added 6 commits July 17, 2026 13:10
…; retract the sequential-replays turbo waiver

Second review round on #2970:
- Guard-enforced batches with an open hook (or hooks created by the same
  suspension) now take the await-then-run path even when optimistic inline
  start is enabled: the step_started claim carries the snapshot and is
  awaited before user code runs, so a claim the backend rejects as stale
  (412) never executes the step body — the fence covers side effects, not
  just durable writes.
- Retract the WORKFLOW_SEQUENTIAL_REPLAYS waiver of turbo's hook/wait latch:
  the env var cannot prove the built flow trigger carries maxConcurrency: 1
  (it must also be set at build time, and some integrations write their own
  trigger config), and capabilities.maxConcurrency only declares queue
  support. The conservative latch stays until the build emits a verifiable
  serialization signal; the capability remains declared for that follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…_COMPLETED)

When enabled, a single sequential inline step's terminal write is
initiated but not awaited: the next replay iteration runs on
locally-synthesized events for that step, and (with optimistic start
active) the next step's body starts while the write settles in the
background. Every subsequent durable write chains behind the deferred
write (fail-closed), and every ack path settles it, so the event log
can never record downstream events without their upstream terminal and
the queue message is never acknowledged with the write in flight.

Gated on: no open hook or wait, single clean sequential inline step,
and (turbo first delivery || WORKFLOW_SEQUENTIAL_REPLAYS=1), plus
optimistic inline start being active. Off by default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-replays arm of the deferred-completed gate

Carries the capability gating from the base branch's review follow-up into
the WORKFLOW_ASYNC_STEP_COMPLETED gate and docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ension failure branch acks the queue message via the ReplayDivergence-recovery re-queue and the `run_failed` terminal write without first settling the pending deferred `step_completed` write, permanently failing runs on speculative, already-superseded state.

This commit fixes the issue reported at packages/core/src/runtime.ts:2822

## The bug

In `packages/core/src/runtime.ts`, the deferred `step_completed` write feature (opt-in via `WORKFLOW_ASYNC_STEP_COMPLETED=1`) establishes an invariant, stated at the `deferredCompleted` declaration (lines ~581-594):

> every ack path settles it (fail-closed) so the queue message is never acknowledged while the terminal write is in flight or failed.

The verified settle-before-ack sites honor it: `reinvoke` (line 756), replay-budget exhausted (1195), NO_INLINE_REPLAY timeout (1218), `run_completed` terminal write (1574), suspension return (2101).

The **non-suspension `else` branch** of the replay `catch` (starting ~line 2677) has two ack paths that did **not** settle first:

1. **ReplayDivergence recovery** — `await queueMessage(...); return;` re-queues a recovery replay (acks the current message).
2. **`run_failed` terminal write** — `await awaitRunReady(); await world.events.create(runId, { eventType: 'run_failed', ... }); return;` (acks the message).

Only the two branches above them settle/avoid-ack correctly: `PreconditionFailedError.is(err)` → `reinvoke(0)` (settles), and `isRetryableWorldError(err)` → `throw err` (leaves message unacked). Everything else — a user error thrown by the workflow body, or a `CorruptedEventLogError` — falls through to `run_failed` with `deferredCompleted` still pending.

## Concrete trigger

Workflow body: `await a(); throw new Error('boom');`, flag on.

- **Iteration N**: step A executes inline and defers its terminal write. `deferredCompleted` (pending) and `pendingSynthesizedEvents` are set.
- **Iteration N+1 (tail replay)**: the top-of-loop settle at line 1183 is skipped because `pendingSynthesizedEvents` is still set. The body replays step A from the synthesized `step_completed`, consumes its result, then throws `boom`.
- `catch` → not a `WorkflowSuspension` → `else` branch → not `PreconditionFailedError`, not retryable → falls through to the `run_failed` write while `deferredCompleted` is **still pending**.

If step A's deferred write was superseded (`EntityConflictError`/`RunExpiredError`, which the reconcile chain converts to `PreconditionFailedError` precisely to force a fresh replay), that rejection is **never observed** because settle is never called. The run is permanently and incorrectly failed on speculative state that another writer already superseded — the exact scenario the `PreconditionFailedError` conversion was built to prevent. Even when the deferred write eventually succeeds, the message is acked while the write is in flight, so a process death before the backgrounded write lands loses `step_completed`.

## The fix

Inserted a fail-closed settle at the top of the fall-through region — after the `PreconditionFailedError.is(err)` and `isRetryableWorldError(err)` checks (both of which already handle the deferred write correctly) and **before** both ack paths (`let terminalError = err;`):

```ts
if (deferredCompleted) {
  try {
    await settleDeferredCompleted();
  } catch (settleErr) {
    if (PreconditionFailedError.is(settleErr)) {
      runtimeLogger.warn(
        'Deferred step_completed superseded during failure handling; re-invoking run for a fresh replay instead of failing',
        { workflowRunId: runId, loopIteration }
      );
      return await reinvoke(0);
    }
    throw settleErr;
  }
}
```

This routes settle rejections exactly like `run_completed`'s settle-then-catch:
- **`PreconditionFailedError`** (deferred write superseded) → `reinvoke(0)` forces a fresh replay rather than failing the run on superseded state (mirrors the existing `PreconditionFailedError.is(err)` → `reinvoke(0)` branch just above).
- **Anything else (transient)** → rethrow, leaving the message unacked so redelivery's owned recovery re-runs the step.

The guard is `if (deferredCompleted)` and `settleDeferredCompleted()` is a no-op when nothing is pending, so **non-opted-in runs (flag off, `deferredCompleted` always `null`) are entirely unaffected**. Placing it after the retryable check preserves the existing behavior for the original `err` when it is itself a `PreconditionFailedError` or a transient world error.

## Verification notes
- Braces balance across the whole file (checked: net 0).
- All referenced identifiers (`settleDeferredCompleted`, `reinvoke`, `PreconditionFailedError`, `runtimeLogger`, `runId`, `loopIteration`) are in scope and used in adjacent lines. `return await reinvoke(0)` already appears in this same `else` branch, so the return type is consistent.
- `tsc` could not be run in the sandbox (`node_modules` not installed).

Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
Co-authored-by: VaguelySerious <mittgfu@gmail.com>
…p_completed

Regression test for the failure-branch settle: a workflow that consumes a
deferred (speculative) step result and then throws, while the deferred write
is superseded, must re-invoke for a fresh replay instead of writing
run_failed on state another writer superseded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Carries the base branch's second review round into the stacked feature:
the sequential-replays arm of the deferCompletedWrite gate is retracted
for the same reason as the base's turbo waiver — the runtime env var
cannot prove the built flow trigger is actually serialized. Deferral now
engages only on turbo's first delivery, where no concurrent orchestrator
invocation can exist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant