Skip to content

[core] Turbo mode: fast-path the first invocation#2526

Open
VaguelySerious wants to merge 9 commits into
mainfrom
peter/turbo-mode
Open

[core] Turbo mode: fast-path the first invocation#2526
VaguelySerious wants to merge 9 commits into
mainfrom
peter/turbo-mode

Conversation

@VaguelySerious

@VaguelySerious VaguelySerious commented Jun 18, 2026

Copy link
Copy Markdown
Member

Adds turbo mode (on by default) to fast-path the very first delivery of a run's first invocation — where time-to-first-step matters most. Stacked on #2516.

On that first delivery the runtime:

  1. Backgrounds run_started — synthesizes the run entity locally from the queued run input so replay begins immediately; the round-trip overlaps replay (reuses the resilient-start create-on-the-fly contract).
  2. Skips the initial event-log load — nothing has been written yet.
  3. Forces optimistic inline start independent of WORKFLOW_OPTIMISTIC_INLINE_START — the step body runs immediately; only the step_started write waits on the backgrounded run_started.

Net effect: the first step body starts after just the in-process replay, with run_started/step_started happening around it and no events.list before it.

Why it's safe

  • Detection reuses existing signals: the first-invocation message is the only one carrying run input, and attempt === 1 is the first delivery (plus: not a background-step or recovery replay). No new message field, no world/backend change.
  • Forced optimistic start is safe because the first delivery has no concurrent peer handler to race the step create-claim, so the body runs exactly once.
  • Turbo exits the moment a suspension creates a hook or wait (or attributes) — those introduce resume/parallel invocations, ending the single-handler guarantee. The inline steps of that suspension fall back to the normal await-then-run path.
  • Write ordering preserved via a run-ready barrier: the optimistic step_started is chained on it (body still runs immediately), the suspension handler awaits it before any eager write, and terminal run writes await it too. The log stays run_created → run_started → step_created → step_started → step_completed.
  • No-op for everything else (redeliveries, background steps, recovery replays, WORKFLOW_TURBO=0).

Config

On by default; WORKFLOW_TURBO=0/false disables it (kill-switch for non-idempotent/stream-unsafe first-step bodies).

Docs

New changelog page docs/content/docs/v5/changelog/turbo-mode.md (+ meta). Preview links to follow once the docs deployment is up.

@VaguelySerious VaguelySerious requested a review from a team as a code owner June 18, 2026 21:47
@changeset-bot

changeset-bot Bot commented Jun 18, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0cdbdfd

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

This PR includes changesets to release 16 packages
Name Type
workflow Minor
@workflow/core Minor
@workflow/world-testing Patch
@workflow/builders Patch
@workflow/cli Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/vitest Patch
@workflow/web-shared Patch
@workflow/web Patch
@workflow/astro Patch
@workflow/nest Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite Patch
@workflow/nuxt 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 Jun 18, 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 Jun 20, 2026 8:16pm
example-nextjs-workflow-webpack Ready Ready Preview, Comment Jun 20, 2026 8:16pm
example-workflow Ready Ready Preview, Comment Jun 20, 2026 8:16pm
workbench-astro-workflow Ready Ready Preview, Comment Jun 20, 2026 8:16pm
workbench-express-workflow Ready Ready Preview, Comment Jun 20, 2026 8:16pm
workbench-fastify-workflow Ready Ready Preview, Comment Jun 20, 2026 8:16pm
workbench-hono-workflow Ready Ready Preview, Comment Jun 20, 2026 8:16pm
workbench-nitro-workflow Ready Ready Preview, Comment Jun 20, 2026 8:16pm
workbench-nuxt-workflow Ready Ready Preview, Comment Jun 20, 2026 8:16pm
workbench-sveltekit-workflow Ready Ready Preview, Comment Jun 20, 2026 8:16pm
workbench-tanstack-start-workflow Ready Ready Preview, Comment Jun 20, 2026 8:16pm
workbench-vite-workflow Ready Ready Preview, Comment Jun 20, 2026 8:16pm
workflow-docs Ready Ready Preview, Comment, Open in v0 Jun 20, 2026 8:16pm
workflow-swc-playground Ready Ready Preview, Comment Jun 20, 2026 8:16pm
workflow-tarballs Ready Ready Preview, Comment Jun 20, 2026 8:16pm
workflow-web Ready Ready Preview, Comment Jun 20, 2026 8:16pm

@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

Passed Failed Skipped Total
❌ ▲ Vercel Production 1441 1 230 1672
✅ 💻 Local Development 1909 0 219 2128
✅ 📦 Local Production 1909 0 219 2128
✅ 🐘 Local Postgres 1895 0 233 2128
✅ 🪟 Windows 152 0 0 152
✅ 📋 Other 885 0 179 1064
Total 8191 1 1080 9272

❌ Failed Tests

▲ Vercel Production (1 failed)

express (1 failed):

  • hookSupersedeOwnerWorkflow - duplicate cancels the owner and claims the released token | wrun_01KVKB4VPP9YVBNVW9RNJ7XHA7 | 🔍 observability

Details by Category

❌ ▲ Vercel Production
App Passed Failed Skipped
✅ astro 125 0 27
✅ example 125 0 27
❌ express 124 1 27
✅ fastify 125 0 27
✅ hono 125 0 27
✅ nextjs-turbopack 149 0 3
✅ nextjs-webpack 149 0 3
✅ nitro 125 0 27
✅ nuxt 125 0 27
✅ sveltekit 144 0 8
✅ vite 125 0 27
✅ 💻 Local Development
App Passed Failed Skipped
✅ astro-stable 127 0 25
✅ express-stable 127 0 25
✅ fastify-stable 127 0 25
✅ hono-stable 127 0 25
✅ nextjs-turbopack-canary 133 0 19
✅ nextjs-turbopack-stable-lazy-discovery-disabled 152 0 0
✅ nextjs-turbopack-stable-lazy-discovery-enabled 152 0 0
✅ nextjs-webpack-canary 133 0 19
✅ nextjs-webpack-stable-lazy-discovery-disabled 152 0 0
✅ nextjs-webpack-stable-lazy-discovery-enabled 152 0 0
✅ nitro-stable 127 0 25
✅ nuxt-stable 127 0 25
✅ sveltekit-stable 146 0 6
✅ vite-stable 127 0 25
✅ 📦 Local Production
App Passed Failed Skipped
✅ astro-stable 127 0 25
✅ express-stable 127 0 25
✅ fastify-stable 127 0 25
✅ hono-stable 127 0 25
✅ nextjs-turbopack-canary 133 0 19
✅ nextjs-turbopack-stable-lazy-discovery-disabled 152 0 0
✅ nextjs-turbopack-stable-lazy-discovery-enabled 152 0 0
✅ nextjs-webpack-canary 133 0 19
✅ nextjs-webpack-stable-lazy-discovery-disabled 152 0 0
✅ nextjs-webpack-stable-lazy-discovery-enabled 152 0 0
✅ nitro-stable 127 0 25
✅ nuxt-stable 127 0 25
✅ sveltekit-stable 146 0 6
✅ vite-stable 127 0 25
✅ 🐘 Local Postgres
App Passed Failed Skipped
✅ astro-stable 126 0 26
✅ express-stable 126 0 26
✅ fastify-stable 126 0 26
✅ hono-stable 126 0 26
✅ nextjs-turbopack-canary 132 0 20
✅ nextjs-turbopack-stable-lazy-discovery-disabled 151 0 1
✅ nextjs-turbopack-stable-lazy-discovery-enabled 151 0 1
✅ nextjs-webpack-canary 132 0 20
✅ nextjs-webpack-stable-lazy-discovery-disabled 151 0 1
✅ nextjs-webpack-stable-lazy-discovery-enabled 151 0 1
✅ nitro-stable 126 0 26
✅ nuxt-stable 126 0 26
✅ sveltekit-stable 145 0 7
✅ vite-stable 126 0 26
✅ 🪟 Windows
App Passed Failed Skipped
✅ nextjs-turbopack 152 0 0
✅ 📋 Other
App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 127 0 25
✅ e2e-local-dev-tanstack-start- 127 0 25
✅ e2e-local-postgres-nest-stable 126 0 26
✅ e2e-local-postgres-tanstack-start- 126 0 26
✅ e2e-local-prod-nest-stable 127 0 25
✅ e2e-local-prod-tanstack-start- 127 0 25
✅ e2e-vercel-prod-tanstack-start 125 0 27

📋 View full workflow run


Some E2E test jobs failed:

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

Check the workflow run for details.

Comment thread packages/core/src/runtime.ts Outdated
VaguelySerious and others added 2 commits June 18, 2026 16:10
On the first delivery of a run's first invocation, background run_started,
skip the initial event-log load, and force optimistic inline start so the run
reaches its first steps with no preceding network round-trips. Safe because the
first delivery has no concurrent handler to race the step create-claim; turbo
exits the moment a suspension creates a hook or wait, and is a no-op for every
other invocation. On by default; disable with WORKFLOW_TURBO=0.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…rations of the same delivery after a hook/wait was already opened, because it is recomputed solely from the current batch's `suspensionResult` with no latch over the cumulative event log.

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

## Bug

In `packages/core/src/runtime.ts` (~line 1534), `forceOptimisticStart` is computed **per replay-loop iteration** from the *current* batch only:

```ts
const forceOptimisticStart =
  turbo &&
  !suspensionResult.waitTimeout &&
  !suspensionResult.hasHookEvents &&
  !suspensionResult.hasAttributeEvents &&
  !suspensionResult.hasAwaitedHookCreation;
```

All `suspensionResult.*` flags reflect **only what the current suspension batch created**. Confirmed in `suspension-handler.ts`:

*   `hasHookEvents: hookEvents.length > 0` where `hookEvents` is built from `hooksNeedingCreation = allHookItems.filter(item => !item.hasCreatedEvent)` — i.e. only hooks that do **not** yet have a `hook_created` event. A hook created in an *earlier* iteration already has its created event, so it is excluded and `hasHookEvents` is `false` on subsequent iterations.

`turbo` is computed once per delivery (~line 516), and the replay loop runs many iterations within a single delivery.

### Reachability

A fire-and-forget hook (`createHook('h')` not awaited) writes `hook_created` but does **not** block the workflow, so the replay loop continues to later pure-step suspensions in the **same delivery**:

*   Iteration A: `hook_created` written → `hasHookEvents = true` → `forceOptimisticStart = false`. ✅
*   Iteration B (a later pure-step suspension): the hook already has its created event, so it is not in `hooksNeedingCreation` → `hasHookEvents = false` → `forceOptimisticStart = true` again. ❌

This directly contradicts the invariant documented in the surrounding comment ("The moment a hook or wait ... is created ... the single-handler guarantee that makes forced optimistic start safe no longer holds — turbo exits"). Because the hook is open, a concurrent resume handler can be triggered and race the inline create-claim, and `forceOptimisticStart` overrides the user's `WORKFLOW_OPTIMISTIC_INLINE_START=0` kill switch (see `step-executor.ts:332`), risking double-execution of a non-idempotent step body.

## Fix

Latch turbo off permanently once any hook or wait is open anywhere in the cumulative event log, using the existing `hasOpenHookOrWait` helper over `cachedEvents` (the cumulative replay log, set to `events` each iteration at ~line 1148):

```ts
const forceOptimisticStart =
  turbo &&
  !suspensionResult.waitTimeout &&
  !suspensionResult.hasHookEvents &&
  !suspensionResult.hasAttributeEvents &&
  !suspensionResult.hasAwaitedHookCreation &&
  !hasOpenHookOrWait(cachedEvents ?? []);
```

This mirrors the exact gate already applied to `requestInlineDelta` a few lines above (line 1522), where `!hasOpenHookOrWait(cachedEvents ?? [])` is used for the same "no out-of-band concurrent writer" safety reasoning. By keying off the cumulative log rather than the current batch, turbo now exits the moment a hook/wait exists and stays off for the remainder of the run, matching the documented single-handler guarantee.


Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
Co-authored-by: VaguelySerious <[email protected]>
@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Nitro 0.048s (+17.3% 🔺) 1.006s (~) 0.958s 10 1.00x
💻 Local Express 0.048s (+17.0% 🔺) 1.006s (~) 0.958s 10 1.00x
💻 Local Next.js (Turbopack) 0.049s (+1.9%) 1.006s (~) 0.957s 10 1.02x
🐘 Postgres Next.js (Turbopack) 0.057s (-1.0%) 1.011s (~) 0.954s 10 1.19x
🐘 Postgres Express 0.062s (~) 1.012s (~) 0.950s 10 1.29x
🐘 Postgres Nitro 0.064s (+0.6%) 1.011s (~) 0.947s 10 1.32x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 0.305s (-23.2% 🟢) 1.841s (-24.4% 🟢) 1.536s 10 1.00x
▲ Vercel Next.js (Turbopack) ⚠️ missing - - - -
▲ Vercel Nitro ⚠️ missing - - - -

🔍 Observability: Express

workflow with 1 step

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Next.js (Turbopack) 1.079s (-1.2%) 2.006s (~) 0.927s 10 1.00x
💻 Local Express 1.081s (-1.7%) 2.006s (~) 0.925s 10 1.00x
💻 Local Nitro 1.085s (~) 2.007s (~) 0.922s 10 1.01x
🐘 Postgres Express 1.092s (-1.0%) 2.010s (~) 0.917s 10 1.01x
🐘 Postgres Next.js (Turbopack) 1.093s (-0.9%) 2.010s (~) 0.917s 10 1.01x
🐘 Postgres Nitro 1.101s (~) 2.010s (~) 0.909s 10 1.02x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 1.606s (+5.6% 🔺) 3.440s (-3.6%) 1.834s 10 1.00x
▲ Vercel Next.js (Turbopack) ⚠️ missing - - - -
▲ Vercel Nitro ⚠️ missing - - - -

🔍 Observability: Express

workflow with 10 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Next.js (Turbopack) 10.434s (-1.1%) 11.024s (~) 0.590s 3 1.00x
💻 Local Express 10.450s (-0.6%) 11.023s (~) 0.573s 3 1.00x
🐘 Postgres Express 10.459s (-0.8%) 11.015s (~) 0.556s 3 1.00x
💻 Local Nitro 10.465s (~) 11.022s (~) 0.557s 3 1.00x
🐘 Postgres Next.js (Turbopack) 10.489s (-0.8%) 11.015s (~) 0.526s 3 1.01x
🐘 Postgres Nitro 10.514s (~) 11.011s (~) 0.497s 3 1.01x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 12.501s (-4.5%) 14.203s (-6.6% 🟢) 1.702s 3 1.00x
▲ Vercel Next.js (Turbopack) ⚠️ missing - - - -
▲ Vercel Nitro ⚠️ missing - - - -

🔍 Observability: Express

workflow with 25 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 13.597s (-2.2%) 14.019s (~) 0.422s 5 1.00x
🐘 Postgres Next.js (Turbopack) 13.621s (-2.1%) 14.023s (~) 0.402s 5 1.00x
💻 Local Nitro 13.628s (~) 14.027s (~) 0.399s 5 1.00x
💻 Local Express 13.637s (-2.6%) 14.028s (-1.4%) 0.391s 5 1.00x
💻 Local Next.js (Turbopack) 13.651s (~) 14.028s (~) 0.376s 5 1.00x
🐘 Postgres Express 13.675s (-0.8%) 14.019s (~) 0.343s 5 1.01x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 18.829s (-6.6% 🟢) 20.261s (-9.6% 🟢) 1.432s 3 1.00x
▲ Vercel Next.js (Turbopack) ⚠️ missing - - - -
▲ Vercel Nitro ⚠️ missing - - - -

🔍 Observability: Express

workflow with 50 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Nitro 12.167s (-1.0%) 13.025s (~) 0.858s 7 1.00x
💻 Local Express 12.168s (-3.4%) 13.024s (~) 0.856s 7 1.00x
🐘 Postgres Express 12.176s (-2.3%) 13.017s (~) 0.842s 7 1.00x
🐘 Postgres Nitro 12.193s (-1.7%) 13.019s (~) 0.826s 7 1.00x
🐘 Postgres Next.js (Turbopack) 12.326s (-1.7%) 13.017s (~) 0.691s 7 1.01x
💻 Local Next.js (Turbopack) 12.388s (-0.6%) 13.025s (~) 0.637s 7 1.02x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 30.284s (+31.6% 🔺) 32.614s (+29.6% 🔺) 2.330s 3 1.00x
▲ Vercel Next.js (Turbopack) ⚠️ missing - - - -
▲ Vercel Nitro ⚠️ missing - - - -

🔍 Observability: Express

Promise.all with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.174s (-1.8%) 2.009s (~) 0.834s 15 1.00x
🐘 Postgres Nitro 1.175s (-1.1%) 2.007s (~) 0.832s 15 1.00x
🐘 Postgres Next.js (Turbopack) 1.214s (+4.8%) 2.008s (~) 0.794s 15 1.03x
💻 Local Next.js (Turbopack) 1.388s (+1.0%) 2.006s (~) 0.618s 15 1.18x
💻 Local Nitro 1.409s (+17.2% 🔺) 2.006s (~) 0.597s 15 1.20x
💻 Local Express 1.424s (+15.7% 🔺) 2.006s (~) 0.583s 15 1.21x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 2.539s (-5.7% 🟢) 3.794s (-14.9% 🟢) 1.255s 8 1.00x
▲ Vercel Next.js (Turbopack) ⚠️ missing - - - -
▲ Vercel Nitro ⚠️ missing - - - -

🔍 Observability: Express

Promise.all with 25 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Next.js (Turbopack) 1.293s (+3.3%) 3.009s (+50.0% 🔺) 1.716s 10 1.00x
🐘 Postgres Nitro 1.316s (+3.7%) 2.592s (+29.2% 🔺) 1.277s 12 1.02x
🐘 Postgres Express 1.359s (+3.9%) 2.593s (+25.0% 🔺) 1.234s 12 1.05x
💻 Local Nitro 2.415s (+19.8% 🔺) 2.918s (+16.4% 🔺) 0.503s 11 1.87x
💻 Local Next.js (Turbopack) 2.430s (+3.7%) 3.009s (+3.1%) 0.579s 10 1.88x
💻 Local Express 2.465s (+21.2% 🔺) 2.736s (+9.1% 🔺) 0.271s 11 1.91x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 3.728s (+31.5% 🔺) 5.360s (+16.7% 🔺) 1.632s 6 1.00x
▲ Vercel Next.js (Turbopack) ⚠️ missing - - - -
▲ Vercel Nitro ⚠️ missing - - - -

🔍 Observability: Express

Promise.all with 50 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.563s (-5.7% 🟢) 4.010s (+6.6% 🔺) 2.447s 8 1.00x
🐘 Postgres Nitro 1.584s (+9.2% 🔺) 4.261s (+15.8% 🔺) 2.677s 8 1.01x
🐘 Postgres Next.js (Turbopack) 2.738s (+84.8% 🔺) 5.683s (+46.2% 🔺) 2.945s 6 1.75x
💻 Local Next.js (Turbopack) 5.689s (-15.0% 🟢) 6.816s (-8.1% 🟢) 1.127s 5 3.64x
💻 Local Express 6.158s (+6.7% 🔺) 6.614s (+3.1%) 0.455s 5 3.94x
💻 Local Nitro 6.639s (+48.1% 🔺) 7.216s (+44.0% 🔺) 0.577s 5 4.25x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 4.917s (+60.0% 🔺) 6.816s (+38.2% 🔺) 1.899s 5 1.00x
▲ Vercel Next.js (Turbopack) ⚠️ missing - - - -
▲ Vercel Nitro ⚠️ missing - - - -

🔍 Observability: Express

Promise.race with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.171s (-2.0%) 2.008s (~) 0.836s 15 1.00x
🐘 Postgres Next.js (Turbopack) 1.175s (~) 2.008s (~) 0.834s 15 1.00x
🐘 Postgres Nitro 1.204s (~) 2.007s (~) 0.803s 15 1.03x
💻 Local Next.js (Turbopack) 1.393s (+3.1%) 2.007s (~) 0.614s 15 1.19x
💻 Local Express 1.420s (+15.5% 🔺) 2.007s (~) 0.587s 15 1.21x
💻 Local Nitro 1.446s (+17.6% 🔺) 2.007s (~) 0.561s 15 1.23x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 3.166s (+56.4% 🔺) 4.865s (+24.6% 🔺) 1.699s 7 1.00x
▲ Vercel Next.js (Turbopack) ⚠️ missing - - - -
▲ Vercel Nitro ⚠️ missing - - - -

🔍 Observability: Express

Promise.race with 25 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Next.js (Turbopack) 1.301s (+2.9%) 3.009s (+45.0% 🔺) 1.707s 10 1.00x
🐘 Postgres Nitro 1.355s (+6.3% 🔺) 2.315s (+15.3% 🔺) 0.960s 13 1.04x
🐘 Postgres Express 1.381s (+6.7% 🔺) 2.509s (+25.0% 🔺) 1.128s 12 1.06x
💻 Local Next.js (Turbopack) 2.531s (+4.7%) 3.008s (+3.1%) 0.477s 10 1.94x
💻 Local Express 2.713s (+33.1% 🔺) 3.109s (+13.6% 🔺) 0.396s 10 2.08x
💻 Local Nitro 2.726s (+35.6% 🔺) 3.010s (+30.1% 🔺) 0.284s 10 2.09x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 3.166s (+22.6% 🔺) 4.788s (+10.1% 🔺) 1.622s 7 1.00x
▲ Vercel Next.js (Turbopack) ⚠️ missing - - - -
▲ Vercel Nitro ⚠️ missing - - - -

🔍 Observability: Express

Promise.race with 50 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.584s (+10.7% 🔺) 4.137s (+10.0% 🔺) 2.553s 8 1.00x
🐘 Postgres Nitro 1.602s (+10.0% 🔺) 4.138s (+10.0% 🔺) 2.536s 8 1.01x
🐘 Postgres Next.js (Turbopack) 3.452s (+135.0% 🔺) 6.417s (+65.2% 🔺) 2.965s 5 2.18x
💻 Local Next.js (Turbopack) 5.841s (-14.0% 🟢) 6.217s (-17.3% 🟢) 0.376s 5 3.69x
💻 Local Express 6.671s (+11.9% 🔺) 7.619s (+11.8% 🔺) 0.948s 5 4.21x
💻 Local Nitro 6.737s (+36.3% 🔺) 7.417s (+34.5% 🔺) 0.680s 5 4.25x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 4.155s (-2.3%) 5.674s (-6.9% 🟢) 1.519s 6 1.00x
▲ Vercel Next.js (Turbopack) ⚠️ missing - - - -
▲ Vercel Nitro ⚠️ missing - - - -

🔍 Observability: Express

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 0.524s (-9.0% 🟢) 1.007s (-1.6%) 0.482s 60 1.00x
🐘 Postgres Next.js (Turbopack) 0.540s (-4.5%) 1.006s (-1.7%) 0.466s 60 1.03x
🐘 Postgres Nitro 0.545s (-3.0%) 1.023s (~) 0.478s 59 1.04x
💻 Local Nitro 0.583s (+2.3%) 1.005s (-1.6%) 0.422s 60 1.11x
💻 Local Next.js (Turbopack) 0.587s (-4.0%) 1.022s (~) 0.435s 59 1.12x
💻 Local Express 0.593s (-2.9%) 1.005s (~) 0.413s 60 1.13x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 5.264s (+53.4% 🔺) 6.824s (+25.7% 🔺) 1.561s 9 1.00x
▲ Vercel Next.js (Turbopack) ⚠️ missing - - - -
▲ Vercel Nitro ⚠️ missing - - - -

🔍 Observability: Express

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.272s (~) 2.007s (~) 0.735s 45 1.00x
🐘 Postgres Express 1.294s (-2.4%) 2.030s (+1.1%) 0.736s 45 1.02x
🐘 Postgres Next.js (Turbopack) 1.295s (+1.6%) 2.008s (~) 0.713s 45 1.02x
💻 Local Next.js (Turbopack) 1.461s (-1.0%) 2.028s (+1.1%) 0.567s 45 1.15x
💻 Local Express 1.473s (-5.5% 🟢) 2.006s (-1.1%) 0.533s 45 1.16x
💻 Local Nitro 1.507s (+9.0% 🔺) 2.028s (+1.1%) 0.521s 45 1.18x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 8.275s (-3.5%) 9.923s (-4.8%) 1.648s 10 1.00x
▲ Vercel Next.js (Turbopack) ⚠️ missing - - - -
▲ Vercel Nitro ⚠️ missing - - - -

🔍 Observability: Express

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 2.532s (-4.0%) 3.059s (~) 0.528s 40 1.00x
🐘 Postgres Nitro 2.584s (-2.6%) 3.058s (-2.5%) 0.474s 40 1.02x
🐘 Postgres Next.js (Turbopack) 2.596s (+1.8%) 3.009s (~) 0.413s 40 1.03x
💻 Local Next.js (Turbopack) 3.097s (-3.4%) 3.736s (-6.1% 🟢) 0.639s 33 1.22x
💻 Local Nitro 3.214s (+7.2% 🔺) 4.009s (+16.7% 🔺) 0.795s 30 1.27x
💻 Local Express 3.272s (+0.6%) 4.010s (~) 0.738s 30 1.29x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 19.451s (+29.3% 🔺) 21.252s (+23.4% 🔺) 1.801s 6 1.00x
▲ Vercel Next.js (Turbopack) ⚠️ missing - - - -
▲ Vercel Nitro ⚠️ missing - - - -

🔍 Observability: Express

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Next.js (Turbopack) 0.171s (-4.5%) 1.006s (~) 0.835s 60 1.00x
🐘 Postgres Express 0.198s (-9.5% 🟢) 1.007s (~) 0.809s 60 1.16x
🐘 Postgres Nitro 0.204s (-3.9%) 1.006s (~) 0.802s 60 1.19x
💻 Local Express 0.438s (+28.4% 🔺) 1.005s (~) 0.567s 60 2.56x
💻 Local Nitro 0.488s (+44.5% 🔺) 1.005s (~) 0.517s 60 2.85x
💻 Local Next.js (Turbopack) 0.565s (-6.7% 🟢) 1.022s (+1.7%) 0.457s 59 3.31x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 1.476s (+16.6% 🔺) 3.063s (+3.6%) 1.588s 20 1.00x
▲ Vercel Next.js (Turbopack) ⚠️ missing - - - -
▲ Vercel Nitro ⚠️ missing - - - -

🔍 Observability: Express

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Next.js (Turbopack) 0.288s (+7.5% 🔺) 1.018s (+1.2%) 0.730s 89 1.00x
🐘 Postgres Express 0.318s (-0.8%) 1.018s (+1.2%) 0.700s 89 1.10x
🐘 Postgres Nitro 0.321s (-0.6%) 1.017s (+1.1%) 0.696s 89 1.11x
💻 Local Express 2.096s (+7.1% 🔺) 2.686s (+8.8% 🔺) 0.590s 34 7.27x
💻 Local Nitro 2.244s (+10.8% 🔺) 2.797s (+13.3% 🔺) 0.553s 33 7.79x
💻 Local Next.js (Turbopack) 2.676s (~) 3.147s (+2.3%) 0.471s 29 9.28x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 2.456s (+44.7% 🔺) 4.253s (+20.3% 🔺) 1.797s 22 1.00x
▲ Vercel Next.js (Turbopack) ⚠️ missing - - - -
▲ Vercel Nitro ⚠️ missing - - - -

🔍 Observability: Express

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 0.478s (-3.1%) 1.196s (+17.9% 🔺) 0.718s 101 1.00x
🐘 Postgres Next.js (Turbopack) 0.496s (+3.0%) 3.060s (+0.8%) 2.563s 40 1.04x
🐘 Postgres Nitro 0.511s (-2.5%) 1.087s (+3.6%) 0.576s 111 1.07x
💻 Local Next.js (Turbopack) 9.858s (-2.0%) 10.941s (-1.6%) 1.083s 11 20.63x
💻 Local Express 9.875s (+0.9%) 10.780s (+1.6%) 0.906s 12 20.67x
💻 Local Nitro 10.457s (+11.8% 🔺) 11.485s (+13.6% 🔺) 1.028s 11 21.89x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 3.637s (+50.0% 🔺) 5.496s (+25.6% 🔺) 1.859s 22 1.00x
▲ Vercel Next.js (Turbopack) ⚠️ missing - - - -
▲ Vercel Nitro ⚠️ missing - - - -

🔍 Observability: Express

Stream Benchmarks (includes TTFB metrics)
workflow with stream

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Next.js (Turbopack) 1.133s (-1.8%) 1.970s (-1.7%) 0.010s (-16.9% 🟢) 2.018s (~) 0.885s 10 1.00x
🐘 Postgres Express 1.145s (-2.8%) 2.002s (~) 0.001s (-14.3% 🟢) 2.012s (~) 0.867s 10 1.01x
💻 Local Nitro 1.155s (~) 2.004s (~) 0.013s (+35.9% 🔺) 2.020s (~) 0.865s 10 1.02x
💻 Local Express 1.156s (-1.6%) 2.004s (~) 0.012s (-1.6%) 2.019s (~) 0.863s 10 1.02x
🐘 Postgres Next.js (Turbopack) 1.164s (-0.5%) 2.001s (~) 0.001s (+8.3% 🔺) 2.010s (~) 0.846s 10 1.03x
🐘 Postgres Nitro 1.170s (~) 1.998s (~) 0.001s (-36.8% 🟢) 2.011s (~) 0.840s 10 1.03x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 2.747s (+21.7% 🔺) 3.862s (+13.3% 🔺) 4.881s (+80.2% 🔺) 9.161s (+34.4% 🔺) 6.414s 10 1.00x
▲ Vercel Next.js (Turbopack) ⚠️ missing - - - - -
▲ Vercel Nitro ⚠️ missing - - - - -

🔍 Observability: Express

stream pipeline with 5 transform steps (1MB)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.542s (-6.1% 🟢) 2.005s (-1.6%) 0.005s (~) 2.027s (-1.5%) 0.485s 30 1.00x
💻 Local Next.js (Turbopack) 1.545s (-4.5%) 1.972s (-1.8%) 0.013s (+5.2% 🔺) 2.028s (~) 0.482s 30 1.00x
💻 Local Express 1.546s (-2.4%) 2.011s (~) 0.012s (-6.2% 🟢) 2.026s (~) 0.480s 30 1.00x
💻 Local Nitro 1.560s (-1.2%) 2.009s (~) 0.013s (+4.1%) 2.025s (~) 0.465s 30 1.01x
🐘 Postgres Next.js (Turbopack) 1.595s (~) 2.010s (~) 0.005s (+2.7%) 2.025s (~) 0.430s 30 1.03x
🐘 Postgres Express 1.601s (+0.9%) 2.041s (+1.8%) 0.005s (-8.7% 🟢) 2.058s (+1.6%) 0.458s 30 1.04x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 7.042s (+14.8% 🔺) 8.558s (+9.1% 🔺) 0.604s (+113.0% 🔺) 9.643s (+11.2% 🔺) 2.601s 7 1.00x
▲ Vercel Next.js (Turbopack) ⚠️ missing - - - - -
▲ Vercel Nitro ⚠️ missing - - - - -

🔍 Observability: Express

10 parallel streams (1MB each)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 0.758s (-0.7%) 1.068s (~) 0.000s (+Infinity% 🔺) 1.082s (~) 0.323s 56 1.00x
🐘 Postgres Nitro 0.763s (-0.8%) 1.045s (-5.3% 🟢) 0.000s (-100.0% 🟢) 1.060s (-5.2% 🟢) 0.297s 57 1.01x
🐘 Postgres Next.js (Turbopack) 0.976s (+28.2% 🔺) 1.373s (+26.2% 🔺) 0.000s (+32.6% 🔺) 1.403s (+28.0% 🔺) 0.427s 43 1.29x
💻 Local Express 1.250s (-5.5% 🟢) 1.981s (-1.6%) 0.000s (-47.2% 🟢) 1.983s (-1.6%) 0.734s 31 1.65x
💻 Local Next.js (Turbopack) 1.308s (-9.7% 🟢) 1.981s (-1.5%) 0.000s (+44.4% 🔺) 2.016s (~) 0.708s 30 1.72x
💻 Local Nitro 1.349s (-1.2%) 2.013s (~) 0.000s (-33.3% 🟢) 2.016s (~) 0.667s 30 1.78x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 3.766s (+11.5% 🔺) 5.054s (~) 0.000s (-50.0% 🟢) 5.495s (-1.2%) 1.730s 11 1.00x
▲ Vercel Next.js (Turbopack) ⚠️ missing - - - - -
▲ Vercel Nitro ⚠️ missing - - - - -

🔍 Observability: Express

fan-out fan-in 10 streams (1MB each)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.568s (-5.3% 🟢) 2.217s (~) 0.000s (~) 2.232s (~) 0.664s 27 1.00x
🐘 Postgres Express 1.866s (+22.0% 🔺) 2.440s (+16.1% 🔺) 0.000s (+124.0% 🔺) 2.457s (+14.2% 🔺) 0.591s 25 1.19x
🐘 Postgres Next.js (Turbopack) 2.723s (+42.7% 🔺) 3.265s (+28.3% 🔺) 0.000s (NaN%) 3.274s (+28.4% 🔺) 0.551s 19 1.74x
💻 Local Express 3.402s (-6.6% 🟢) 3.964s (-6.2% 🟢) 0.000s (-90.6% 🟢) 3.969s (-6.2% 🟢) 0.567s 16 2.17x
💻 Local Next.js (Turbopack) 3.479s (-15.5% 🟢) 3.992s (-13.2% 🟢) 0.001s (-22.2% 🟢) 4.034s (-12.4% 🟢) 0.555s 15 2.22x
💻 Local Nitro 4.066s (+16.1% 🔺) 4.456s (+12.5% 🔺) 0.001s (-4.8%) 4.460s (+12.2% 🔺) 0.394s 14 2.59x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 108.475s (+1213.6% 🔺) 110.057s (+1014.7% 🔺) 0.000s (-100.0% 🟢) 110.449s (+957.3% 🔺) 1.974s 3 1.00x
▲ Vercel Next.js (Turbopack) ⚠️ missing - - - - -
▲ Vercel Nitro ⚠️ missing - - - - -

🔍 Observability: Express

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World 🥇 Fastest Framework Wins
💻 Local Next.js (Turbopack) 12/21
🐘 Postgres Express 12/21
▲ Vercel Express 21/21
Fastest World by Framework

Winner determined by most benchmark wins

Framework 🥇 Fastest World Wins
Express 🐘 Postgres 15/21
Next.js (Turbopack) 🐘 Postgres 16/21
Nitro 🐘 Postgres 16/21
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Redis + BullMQ: Community world (local development)
  • 🌐 Cloudflare: Community world (local development)
  • 🌐 MySQL: Community world (local development)
  • 🌐 Azure: Community world (local development)
  • 🌐 NATS JetStream: Community world (local development)
  • 🌐 Upstash: Community world (local development)

📋 View full workflow run


Some benchmark jobs failed:

  • Local: success
  • Postgres: success
  • Vercel: failure

Check the workflow run for details.

Comment thread packages/core/src/runtime.ts
…urbo mode the first delivery synthesizes `startedAt` from the runtime-local clock while later non-turbo deliveries load the server-canonical `startedAt`, so replay regenerates different correlation IDs and throws `ReplayDivergenceError`.

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

## The bug

In `packages/core/src/workflow.ts` (`runWorkflow`), the workflow orchestrator context exposed:

```ts
generateUlid: () => ulid(+startedAt),
```

where `startedAt = workflowRun.startedAt`. Every durable correlation ID is derived from this:

- `step.ts:25` → `step_${ctx.generateUlid()}`
- `workflow/hook.ts:73` → `hook_${ctx.generateUlid()}`
- `workflow/sleep.ts:18` → `wait_${ctx.generateUlid()}`
- `workflow/attribute-dispatcher.ts:20` → `attr_${ctx.generateUlid()}`

The 48-bit time prefix of every correlation ID therefore equals `+startedAt`. For replay to succeed, the value fed to `ulid()` **must be identical on every delivery** — otherwise `EventsConsumer.onUnconsumedEvent` fires and rejects with `ReplayDivergenceError`.

## Why turbo breaks it

`startedAt` is **not** replay-stable under turbo:

- **Turbo first delivery** (`runtime.ts` ~L753): the run is synthesized locally with `startedAt: now`, where `now = new Date()` is the runtime-local clock. The first delivery's `generateUlid` thus encodes the local `now`, and any `step_started`/`wait`/`hook_created` events persisted in this delivery carry correlation IDs encoding that local `now`.
- **Backend persistence**: the backgrounded `run_started` write records the storage layer's own clock as the canonical `startedAt` (`world-local events-storage` uses `currentRun.startedAt ?? now`), which differs from the runtime's local `now`.
- **Next (non-turbo) delivery**: the run is loaded from the backend with the server-canonical `startedAt`. `generateUlid()` now produces ULIDs with a different time prefix, so the regenerated correlation IDs no longer match the persisted ones → `ReplayDivergenceError`.

The divergence only requires a ≥1 ms difference between the two ms-resolution clocks, so it is intermittent but real — and turbo is on by default.

This was already a known hazard: the RNG `seed` and the VM clock `fixedTimestamp` were *deliberately* decoupled from `startedAt`/`createdAt` (see the comment "Dropping the timestamp means the seed no longer depends on startedAt/createdAt, so it ... can be computed before any server round-trip"). `generateUlid` was simply missed in that refactor.

## The fix

Feed `generateUlid` the same replay-stable value already used for the seed and VM clock:

```ts
generateUlid: () => ulid(fixedTimestamp),
```

where `fixedTimestamp = runIdCreatedAt(workflowRun.runId) ?? +workflowRun.createdAt`. Production run IDs are always `wrun_<ulid>` (minted client-side in `start()`), so `runIdCreatedAt` recovers the same epoch-ms value the instant the queue message arrives — identical on turbo and non-turbo deliveries alike. Correlation IDs become replay-stable in all delivery paths.

`workflowStartedAt` (line 296, a user-facing `Date` exposed to workflow code) intentionally keeps using `startedAt` — it is not a correlation ID and is not part of replay matching.

## Test compatibility

The two integration tests that compute expected correlation IDs through the real `runWorkflow` path use non-ULID run IDs (`wrun_stale_wait_replay`, `wrun_test`). For those, `runIdCreatedAt` returns `undefined` and `fixedTimestamp` falls back to `+createdAt`, which equals `+startedAt` in those fixtures — so `ulid(fixedTimestamp)` yields the same IDs as before and the assertions still hold. The unit-test fixtures that hand-build their own `generateUlid: () => ulid(workflowStartedAt)` do not go through `runWorkflow` and are unaffected.

Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
Co-authored-by: VaguelySerious <[email protected]>
VaguelySerious and others added 5 commits June 18, 2026 16:34
…ch step_completed

Turbo overlaps start round-trips with step bodies but still awaits each
step_completed before advancing. Documents the considered "run-ahead"
extension (defer step writes to a background queue, run sequential steps
ahead) and why it was not pursued: crash re-execution blast radius, and
divergent branches when a step runs against a non-durable result a
redelivery can re-decide.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
… IDs

The generateUlid fix (correlation IDs keyed on the replay-stable
fixedTimestamp = runIdCreatedAt(runId) instead of startedAt) changes the
ULID time prefix for fixtures whose ULID runId encodes a different time
than their startedAt. This race-replay fixture used a 2025 ULID runId with
a 2024 startedAt, so its step_ correlation ID prefixes move from the
startedAt-derived 01HK153X00 to the runId-derived 01K75533W5 (suffixes,
seed-derived, are unchanged). Realigns the fixture; no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…tered-step barrier, docs)

- forceOptimisticStart now defers to an explicit WORKFLOW_OPTIMISTIC_INLINE_START=0:
  turbo still forces optimistic start when the flag is unset, but an operator's
  explicit opt-out (the "body runs before start is confirmed" property) wins.
  Adds isOptimisticInlineStartExplicitlyDisabled().
- Gate the unregistered-step ("step not found") lazy step_started on
  runReadyBarrier so it never precedes the backgrounded run_started under turbo.
- Document that the forced-optimistic first step body's stream/ops writes run
  before run_started (stream-safety caveat + WORKFLOW_TURBO=0), and that a run
  cancelled/expired before its first delivery still runs the first step body
  (reconciled away) under turbo.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
The two turbo tests that wait for the inline step body to run before
releasing the run-ready barrier used vi.waitFor's 1s default, which the
full VM replay can exceed on cold Windows CI (intermittent
"expected [] to include 'body'"). Bump to 15s, matching the existing
queue_dispatch_start waitFor in the same suite.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…dge)

Turbo's immediate re-invoke exits returned `{ timeoutSeconds: 0 }`, which makes
the queue reschedule the CURRENT delivery's message. That message carries
`runInput`, and on async queues (graphile-worker / world-vercel) a reschedule
comes back as delivery attempt 1 — so turbo re-engaged, skipped the event-log
load again, replayed against an empty log, never observed the hook/attr event it
had just written, re-suspended, and rescheduled forever. The run wedged (every
hook + experimental_setAttributes e2e test timed out on world-postgres and
world-vercel; world-local's reschedule increments the attempt, so it was unaffected).

Turbo now re-invokes via an explicit continuation that carries NO `runInput`
(`reinvoke()`), so the next delivery is a normal non-turbo load-and-replay that
observes the committed events and makes progress. Applies to the hasHookConflict,
hasAttributeEvents, hasAwaitedHookCreation, and throttle re-invoke exits.
Verified against world-postgres: hook.getConflict() + experimental_setAttributes
workflows that previously wedged now complete.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
executionContext: runInput.executionContext,
input: runInput.input,
attributes: runInput.attributes ?? {},
startedAt: now,

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.

Follow-up to the (now-resolved) correlation-ID divergence: the replay-matching fix is solid — seed/fixedTimestamp/generateUlid are all runId-derived now, so step/wait/hook IDs are stable across deliveries. ✅

But one residual remains from this synthesized startedAt: now. It still flows into the orchestrator-visible getWorkflowMetadata().workflowStartedAt (workflow.ts:296 new Date(+startedAt)WORKFLOW_CONTEXT_SYMBOL), and getWorkflowMetadata() is replayed user code. On the turbo first delivery that value is the local clock; on any later (non-turbo) delivery it's the server-canonical startedAt — so a workflow that branches on it (e.g. if (Date.now() - +meta.workflowStartedAt > THRESHOLD) …, where Date.now() is now fixedTimestamp-stable but workflowStartedAt is not) can take a different path on resume → ReplayDivergenceError.

Much narrower than the original bug (only workflows reading workflowStartedAt in replayed control flow), but it means the "replay is fully decoupled from startedAt" framing isn't quite complete — the user-facing value is still delivery-dependent. Worth either deriving the synthesized workflowStartedAt from the same replay-stable runIdCreatedAt(runId) value, or adding a one-line caveat that workflowStartedAt may differ by the start→first-delivery latency on the first invocation and shouldn't drive replayed branching.

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.

Pushed 0cdbdfdb8 to address this so it is not left silently open:

  • Doc caveat in the turbo-mode changelog (### workflowStartedAt reflects the first delivery's clock) — explicitly says to treat it as an approximate human-facing timestamp and not branch replayed control flow on it.
  • Regression test in workflow.test.ts proving step correlation IDs are regenerated from the run-ID-derived fixedTimestamp (not startedAt) and stay stable across deliveries. Verified it fails (ReplayDivergenceError) when generateUlid is reverted to ulid(+startedAt).

Left the deeper code fix — deriving the orchestrator-visible workflowStartedAt from fixedTimestamp so the value itself is replay-stable rather than just documented — as your call, since it changes the public getWorkflowMetadata().workflowStartedAt semantics for non-turbo runs too (it would become run-creation time instead of run_started time).

Add a workflow.test.ts regression that replays a recorded step under a
startedAt that diverges from createdAt, proving step correlation IDs are
regenerated from the run-ID-derived fixedTimestamp (not startedAt) and so
stay stable across deliveries. Reverting generateUlid to ulid(+startedAt)
fails this test.

Document in the turbo-mode changelog that getWorkflowMetadata().workflowStartedAt
reflects the first delivery's clock under turbo (local on the first delivery,
server-canonical on later ones) and must not drive replayed control flow.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
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.

2 participants