Skip to content

[core] Add atomic start Hook admission - #3426

Open
NathanColosimo wants to merge 1 commit into
codex/atomic-start-capabilitiesfrom
codex/atomic-start-core
Open

[core] Add atomic start Hook admission#3426
NathanColosimo wants to merge 1 commit into
codex/atomic-start-capabilitiesfrom
codex/atomic-start-core

Conversation

@NathanColosimo

@NathanColosimoNathanColosimo commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

API

construn=awaitstart(processOrder,[orderId],{hook: {token: `order:${orderId}`,experimental_minRetention: "30 days",},});

A successful call returns the existing Run type. A duplicate throws HookConflictError with conflictingRunId. If queueing or admission cannot be confirmed, WorkflowStartError identifies the candidate run and uncertain stage.

Implementation

start() queues the candidate first, then calls events.create() to atomically reserve the token and create the run. The direct event and durable queue input carry the same normalized Hook data, so either path can complete admission. A queued loser exits before creating a run or executing user code.

The caller World and a cross-deployment target must advertise atomicStartHook. Requests with experimental_minRetention must also advertise hookRetention. This PR defines the public API, wire format, runtime behavior, and World contract; no World advertises support yet.

Plan

  1. [world] Expose capabilities in deployment health checks #3425 exposes World capabilities locally and through cross-deployment health checks.
  2. This PR defines atomic start Hook admission.
  3. Follow-up Local, Postgres, and Vercel PRs implement the atomic World operation and progressively enable one shared E2E test.
  4. [docs] Document atomic start Hook admission #3440 documents the feature and stays draft until a World implementation is ready.

@changeset-bot

changeset-botBot commented Aug 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 91d04bb

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

This PR includes changesets to release 21 packages
NameType
workflowMinor
@workflow/coreMinor
@workflow/errorsMinor
@workflow/worldMinor
@workflow/cliPatch
@workflow/web-sharedPatch
@workflow/world-vercelPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/webPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

vercelBot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

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

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewAug 12, 2026 12:03am
example-nextjs-workflow-webpackReadyReadyPreviewAug 12, 2026 12:03am
example-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-astro-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-express-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-fastify-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-hono-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-nestjs-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-nitro-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-nuxt-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-python-workflowErrorErrorAug 12, 2026 12:03am
workbench-sveltekit-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-tanstack-start-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-vite-workflowReadyReadyPreviewAug 12, 2026 12:03am
workflow-docsReadyReadyPreview, v0Aug 12, 2026 12:03am
workflow-swc-playgroundReadyReadyPreviewAug 12, 2026 12:03am
workflow-tarballsReadyReadyPreviewAug 12, 2026 12:03am
workflow-webReadyReadyPreviewAug 12, 2026 12:03am

@github-actions

github-actionsBot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production346605904056
✅ 💻 Local Development381005584368
✅ 📦 Local Production381005584368
✅ 🐘 Local Postgres381005584368
✅ 🪟 Windows31200312
✅ vercel-multi-region270027
Total152350226417499
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node128028
✅ astro-quickjs128028
✅ example-node128028
✅ example-quickjs128028
✅ express-node128028
✅ express-quickjs128028
✅ fastify-node128028
✅ fastify-quickjs128028
✅ hono-node128028
✅ hono-quickjs128028
✅ nest-node128028
✅ nest-quickjs128028
✅ nextjs-turbopack-node15303
✅ nextjs-turbopack-quickjs15303
✅ nextjs-webpack-node15303
✅ nextjs-webpack-quickjs15303
✅ nitro-node128028
✅ nitro-quickjs128028
✅ nuxt-node128028
✅ nuxt-quickjs128028
✅ sveltekit-node14709
✅ sveltekit-quickjs14709
✅ tanstack-start-node128028
✅ tanstack-start-quickjs128028
✅ vite-node128028
✅ vite-quickjs128028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack-node15600
✅ nextjs-turbopack-quickjs15600

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@github-actions

github-actionsBot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 91d04bb · Wed, 12 Aug 2026 00:17:07 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep324 (±0%)1369 🔴 (+30%) 🔻1393 🔴 (+24%) 🔻1446 🔴 (+3.4%)30
TTFSstream1303 (+719%) 🔻1363 🔴 (+27%) 🔻1378 🔴 (+26%) 🔻1450 🔴 (+17%) 🔻30
TTFShook + stream1569 (+17%) 🔻1643 🔴 (+13%)1661 🔴 (+11%)1688 🔴 (+7.4%)30
STSO1020 steps (inline)114 (-10%)159 (-16%) 💚183 (-15%) 💚491 (+16%) 🔻1019
WO1020 steps168782 (-8.4%)168782 (-8.4%)168782 (-8.4%)168782 (-8.4%)1
SLstream latency84 (-3.4%)105 🔴 (-17%) 💚122 🔴 (-11%)179 🔴 (-63%) 💚30
SOstream overhead (text)100 (-7.4%)149 (-26%) 💚160 (-49%) 💚190 (-60%) 💚30
SOstream overhead (structured)110 (+2.8%)146 (-18%) 💚160 (-33%) 💚203 (-54%) 💚30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 183209ms → this run 167452ms (Δ -15757ms, -9%)

 100-150 ms ██████░░░░░░░░░░░░░░░┃ main 168 this 606 +438
150-200 ms ███████████┃████████████ main 675 this 346 -329
200-250 ms ┃████ main 129 this 31 -98
250-300 ms ┃ main 23 this 10 -13
300-350 ms ┃ main 8 this 7 -1
350-400 ms ┃ main 3 this 7 +4
400-450 ms ┃ main 5 this 1 -4
450-500 ms ┃ main 3 this 1 -2
500-550 ms ┃ main 1 this 4 +3
550-600 ms ┃ main 0 this 1 +1
650-700 ms ┃ main 1 this 0 -1
800-850 ms ┃ main 1 this 0 -1
850-900 ms ┃ main 1 this 1 +0
900-950 ms ┃ main 1 this 0 -1
1000-1050 ms ┃ main 0 this 1 +1
1100-1150 ms ┃ main 0 this 1 +1
1650-1700 ms ┃ main 0 this 1 +1
4600-4650 ms ┃ main 0 this 1 +1
📜 Previous results (7)

45239c8

Tue, 11 Aug 2026 21:59:50 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep220 (-32%) 💚1404 🔴 (+33%) 🔻1449 🔴 (+29%) 🔻1799 🔴 (+29%) 🔻30
TTFSstream224 (+41%) 🔻1378 🔴 (+28%) 🔻1394 🔴 (+28%) 🔻1451 🔴 (+17%) 🔻30
TTFShook + stream411 (-69%) 💚1665 🔴 (+14%)1754 🔴 (+17%) 🔻1846 🔴 (+18%) 🔻30
STSO1020 steps (inline)136 (+7.1%)213 (+13%)249 (+15%) 🔻442 (+4.5%)1019
WO1020 steps213097 (+16%) 🔻213097 (+16%) 🔻213097 (+16%) 🔻213097 (+16%) 🔻1
SLstream latency100 (+15%)171 🔴 (+35%) 🔻192 🔴 (+40%) 🔻980 🔴 (+102%) 🔻30
SOstream overhead (text)131 (+21%) 🔻273 🔴 (+36%) 🔻420 (+34%) 🔻913 (+92%) 🔻30
SOstream overhead (structured)142 (+33%) 🔻264 🔴 (+49%) 🔻448 (+87%) 🔻977 (+122%) 🔻30

adf58eb

Tue, 11 Aug 2026 05:11:23 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1333 (+26%) 🔻1498 🔴 (+27%) 🔻1566 🔴 (+30%) 🔻1860 🔴 (+8.8%)30
TTFSstream1420 (+36%) 🔻1496 🔴 (+31%) 🔻1545 🔴 (+33%) 🔻1598 🔴 (+32%) 🔻30
TTFShook + stream1638 (+31%) 🔻1797 🔴 (+31%) 🔻1832 🔴 (+31%) 🔻2111 🔴 (+40%) 🔻30
STSO1020 steps (inline)115 (+20%) 🔻153 (-6.7%)169 (-17%) 💚253 (-67%) 💚1019
WO1020 steps154672 (-9.8%)154672 (-9.8%)154672 (-9.8%)154672 (-9.8%)1
SLstream latency119 (+31%) 🔻140 🔴 (-9.1%)170 🔴 (-1.2%)362 🔴 (-35%) 💚30
SOstream overhead (text)134 (+1.5%)214 (-22%) 💚224 (-32%) 💚290 (-63%) 💚30
SOstream overhead (structured)133 (+9.0%)187 (-24%) 💚209 (-33%) 💚319 (-29%) 💚30

7825269

Tue, 11 Aug 2026 00:39:16 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep224 (-73%) 💚1512 🔴 (+36%) 🔻1522 🔴 (+33%) 🔻1758 🔴 (+49%) 🔻30
TTFSstream246 (+19%) 🔻1487 🔴 (+35%) 🔻1505 🔴 (+36%) 🔻1534 🔴 (+33%) 🔻30
TTFShook + stream401 (-68%) 💚1674 🔴 (+21%) 🔻1742 🔴 (+22%) 🔻1808 🔴 (+17%) 🔻30
STSO1020 steps (inline)103 (+2.0%)153 (+3.4%)178 (+4.7%)298 (-0.7%)1019
WO1020 steps149862 (+5.7%)149862 (+5.7%)149862 (+5.7%)149862 (+5.7%)1
SLstream latency98 (+13%)193 🔴 (+71%) 🔻446 🔴 (+257%) 🔻1484 🔴 (+945%) 🔻30
SOstream overhead (text)122 (+11%)250 (+33%) 🔻376 (+77%) 🔻1304 🔴 (+446%) 🔻30
SOstream overhead (structured)124 (+12%)231 (+43%) 🔻343 (+85%) 🔻864 (+251%) 🔻30

f9614bf

Mon, 10 Aug 2026 23:58:39 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1220 (+45%) 🔻1308 🔴 (+18%) 🔻1332 🔴 (+17%) 🔻1394 🔴 (+18%) 🔻30
TTFSstream1213 (+486%) 🔻1384 🔴 (+26%) 🔻1404 🔴 (+27%) 🔻1434 🔴 (+24%) 🔻30
TTFShook + stream1448 (+16%) 🔻1646 🔴 (+19%) 🔻1750 🔴 (+23%) 🔻1822 🔴 (+18%) 🔻30
STSO1020 steps (inline)96 (-5.0%)143 (-3.4%)162 (-4.7%)398 (+33%) 🔻1019
WO1020 steps144978 (+2.2%)144978 (+2.2%)144978 (+2.2%)144978 (+2.2%)1
SLstream latency104 (+20%) 🔻141 🔴 (+25%) 🔻248 🔴 (+98%) 🔻4139 🔴 (+2815%) 🔻30
SOstream overhead (text)133 (+21%) 🔻254 🔴 (+35%) 🔻330 (+56%) 🔻587 (+146%) 🔻30
SOstream overhead (structured)114 (+2.7%)211 (+30%) 🔻302 (+63%) 🔻813 (+230%) 🔻30

77ce876

Mon, 10 Aug 2026 23:17:12 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1322 (+291%) 🔻1401 🔴 (+25%) 🔻1419 🔴 (+24%) 🔻1531 🔴 (±0%)30
TTFSstream997 (±0%)1398 🔴 (+34%) 🔻1415 🔴 (+34%) 🔻1475 🔴 (+36%) 🔻30
TTFShook + stream453 (-64%) 💚1656 🔴 (+19%) 🔻1691 🔴 (+15%)1823 🔴 (+8.3%)30
STSO1020 steps (inline)103 (+8.4%)134 (-11%)151 (-16%) 💚267 (-25%) 💚1019
WO1020 steps135938 (-8.2%)135938 (-8.2%)135938 (-8.2%)135938 (-8.2%)1
SLstream latency94 (+9.3%)129 🔴 (-0.8%)191 🔴 (+9.1%)422 🔴 (+94%) 🔻30
SOstream overhead (text)118 (+28%) 🔻209 (+12%)223 (+4.2%)330 (+31%) 🔻30
SOstream overhead (structured)120 (+17%) 🔻177 (+13%)284 (+47%) 🔻423 (+75%) 🔻30

06bd237

Mon, 10 Aug 2026 21:45:13 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1294 (+525%) 🔻1356 🔴 (+25%) 🔻1372 🔴 (+22%) 🔻1514 🔴 (-6.5%)30
TTFSstream1292 (+576%) 🔻1344 🔴 (+25%) 🔻1352 🔴 (+24%) 🔻1368 🔴 (+23%) 🔻30
TTFShook + stream1555 (+69%) 🔻1654 🔴 (+21%) 🔻1705 🔴 (+16%) 🔻1800 🔴 (+18%) 🔻30
STSO1020 steps (inline)101 (+31%) 🔻126 (-14%)143 (-19%) 💚245 (-51%) 💚1019
WO1020 steps127611 (-17%) 💚127611 (-17%) 💚127611 (-17%) 💚127611 (-17%) 💚1
SLstream latency88 (+2.3%)121 🔴 (-18%) 💚137 🔴 (-15%)152 🔴 (-50%) 💚30
SOstream overhead (text)105 (-15%) 💚163 (-39%) 💚229 (-30%) 💚251 (-85%) 💚30
SOstream overhead (structured)112 (+1.8%)163 (-42%) 💚185 (-80%) 💚652 (-68%) 💚30

3da6597

Mon, 10 Aug 2026 21:05:53 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep232 (-76%) 💚1445 🔴 (+20%) 🔻1560 🔴 (+25%) 🔻1871 🔴 (+40%) 🔻30
TTFSstream246 (-5.0%)1423 🔴 (+26%) 🔻1464 🔴 (+23%) 🔻1551 🔴 (+11%)30
TTFShook + stream406 (-5.4%)1665 🔴 (+10%)1684 🔴 (+3.0%)2292 🔴 (+8.4%)30
STSO1020 steps (inline)97 (+5.4%)153 (-6.7%)178 (-7.3%)293 (-32%) 💚1019
WO1020 steps151031 (-5.5%)151031 (-5.5%)151031 (-5.5%)151031 (-5.5%)1
SLstream latency103 (-8.0%)185 🔴 (-2.1%)218 🔴 (-50%) 💚327 🔴 (-44%) 💚30
SOstream overhead (text)150 (+9.5%)326 🔴 (+37%) 🔻504 🔴 (+36%) 🔻3837 🔴 (+205%) 🔻30
SOstream overhead (structured)137 (-9.9%)238 (-8.8%)307 (-19%) 💚343 (-50%) 💚30
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: is main, marks where this run lands, bridges the gap when this run has more samples in a bucket.

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@NathanColosimoNathanColosimo changed the title feat(core): add atomic start hook admission[core] Add atomic start Hook admissionAug 10, 2026
Comment threadpackages/core/src/runtime/start.ts Outdated
@github-actions

github-actionsBot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 Mint-ordered log — 6 fail of 41 total

log=mint-ordered · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted171.0mMISMATCH1
stale-read-equal-step-countscompleted141.0mMISMATCH1
step-vs-step-forkcompleted120msMISMATCH1
step-vs-step-fork-fencedcompleted120msMISMATCH1
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mMISMATCH1
in-flight-before-decision-countedcompleted201.0mok0
in-flight-after-decisionfailed142.0mMISMATCH1
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-mint.txt

🟢 Append-only log — 0 fail of 41 total

log=append-only · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted201.0mok0
stale-read-equal-step-countscompleted141.0mok0
step-vs-step-forkcompleted120msok0
step-vs-step-fork-fencedcompleted120msok0
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mok0
in-flight-before-decision-countedcompleted171.0mok0
in-flight-after-decisioncompleted192.0mok0
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-append-only.txt

Comment threadpackages/core/src/runtime.ts Outdated
if (
(err instanceof WorkflowWorldError ||
WorkflowWorldError.is(err)) &&
!isRetryableWorldError(err)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should this not check for the specific error? This seems like any non-retriable error will surface as a hook admission rejected

@VaguelySeriousVaguelySeriousAug 11, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI: Blocking The new runInput?.startHook !== undefined block sits before the EntityConflictError/RunExpiredError check and before the getWorkflowSetupErrorCode → recordFatalRunError path. EntityConflictError, RunExpiredError, and PreconditionFailedError all extend WorkflowWorldError and all classify as non-retryable, so the prelude shadows every one of them.

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Human pass LGTM. Agent pass coming in a few minutes probably

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI review: blocking issues found

return;
}
} catch (err) {
if (runInput?.startHook !== undefined) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

This prelude sits before the EntityConflictError/RunExpiredError check and before the getWorkflowSetupErrorCode -> recordFatalRunError path. EntityConflictError, RunExpiredError, and PreconditionFailedError all extend WorkflowWorldError and all classify as non-retryable, so err instanceof WorkflowWorldError && !isRetryableWorldError(err) shadows every one of them.

For a world-contract error the pre-existing path writes run_failed; the atomic path logs at ERROR and acks the message. The run stays in pending forever with no terminal event and no retry.

I confirmed this against the driveTurbo harness in this PR (scratch test, not committed):

non-atomic run + WorkflowWorldError{code: WORLD_CONTRACT_ERROR} -> 1 run_failed
atomic-start run + the identical error -> 0 run_failed

A second scratch test showed RunExpiredError in the atomic path now logs error: "Atomic start Hook admission rejected queued candidate" and never logs the pre-existing info: "Run already finished during setup, skipping" — same outcome, wrong severity, misleading message.

The second branch is strictly worse than falling through for contract errors, since the fall-through already stops the retry loop and records the failure. Narrowing the swallow to HookConflictError.is(err) alone fixes the case that matters. If you also want to stop queue retries for non-contract, non-retryable world errors, route those through recordFatalRunError rather than a bare return.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Resolved by 91d04bb. Narrowing to WorkflowWorldError.is(err) && err.code === START_HOOK_ADMISSION_REJECTED fixes it, and using the name-exact .is() rather than instanceof is the right call: subclasses no longer match, so the terminal-run families reach their own branch.

Verified with the same paired scratch tests, run against both heads (45239c8 pre-fix, 91d04bb post-fix):

 45239c8 (pre) 91d04bb (post)
EntityConflictError rejectedLog=true skipLog=false rejectedLog=false skipLog=true
RunExpiredError rejectedLog=true skipLog=false rejectedLog=false skipLog=true
PreconditionFailedError acked 204, no rethrow, no event rethrown (queue retries)

All three assertions fail on the pre-fix head and pass here, so the fix is load-bearing rather than incidental. PreconditionFailedError was the clearest strand: previously acked with neither a terminal event nor a retry.

The two tests added alongside the fix cover the tagged-rejection and unrelated-contract-error cases. Neither covers the terminal-run families, which are what the original prelude actually shadowed. A parametrized case over EntityConflictError/RunExpiredError/PreconditionFailedError asserting the INFO path would keep a future re-widening of this condition from going unnoticed. Note that runtimeLogger.info only reaches a sink when DEBUG matches workflow:runtime:info, so such a test has to set it.

// No probe channel to the target — cannot attest the consumer honors
// `hookInput`, so leave the marker off (fail closed to sequential).
targetHookResumeInputVersion = undefined;
} else {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

The else if (typeof world.streams?.get !== 'function') branch and its comment were deleted here. This fires for every cross-deployment start, with or without hook.

A world with no stream channel now enters healthCheck(), which enqueues a health-check message nobody can answer, then loops world.streams.get(...) -> TypeError -> sleep HEALTH_CHECK_POLL_INTERVAL, 20 times, for the full CROSS_DEPLOYMENT_CAPABILITY_PROBE_TIMEOUT_MS of 2000ms, before falling back to exactly the values the deleted branch set synchronously.

Measured on start.test.ts, same file, base vs head: 34ms -> 8.15s total, with four pre-existing tests each at ~2020ms:

should pass explicit deploymentId from opts to getEncryptionKeyForRun 2022ms
should resolve "latest" to the actual deployment ID via resolveLatestDeploymentId 2015ms
should pass the resolved deployment ID to getEncryptionKeyForRun when using "latest" 2017ms
should not call resolveLatestDeploymentId when a normal deploymentId is provided 2019ms

The guard was added deliberately in 303b6da (#1853). Nothing in the PR body explains removing it, so this reads as collateral from the refactor.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

Still open on 91d04bb. packages/core/src/runtime/start.ts is unchanged between 45239c8 and this head, and the else if (typeof world.streams?.get !== 'function') branch is still absent while it is present on the PR base (codex/atomic-start-capabilities, 459e34b, line 357).

Re-measured start.test.ts on both, same install and same build:

459e34b (PR base) 58 tests 36ms of test time
91d04bb (this head) 72 tests 8.16s of test time

with the same four pre-existing tests pinned at the probe timeout:

✓ should pass explicit deploymentId from opts to getEncryptionKeyForRun 2021ms
✓ should resolve "latest" to the actual deployment ID via resolveLatestDeploymentId 2023ms
✓ should pass the resolved deployment ID to getEncryptionKeyForRun when using "latest" 2029ms
✓ should not call resolveLatestDeploymentId when a normal deploymentId is provided 2029ms

None of those tests involve hook. Every cross-deployment start on a World without a stream channel now pays 2s and one undeliverable queue write to arrive at the values the deleted branch set synchronously. The suite passes, so CI will not catch it.

specVersion: _specVersion,
...data
} = runInput;
runCreationData = data;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Behavior change that applies to every run, not just atomic-start ones.

Replacing the explicit six-field pick with {...runInput} minus environment/specVersion means run_started now also carries encryptionPublicKey on the resilient-start path. Verified empirically by porting the new assertion on line 2348 back to the base branch:

base: expected undefined to be 'test-public-key'
head: passes

This looks like a fix, and it matches what the (now-deleted) RunStartedEventSchema comment described: on the resilient path the run is created from this event, and without the key it silently loses the ability to receive sealed writes. But it ships unannounced — the changeset doesn't mention it, and the only coverage is an added assertion inside a test named for turbo optimistic start. Worth its own changeset line and a test that names the behavior, so a future refactor doesn't drop it again.

The spread also means any field added to RunInput later is auto-forwarded into run_started rather than opted in. That's the mechanism that just quietly changed the payload here.

deploymentId: z.string(),
workflowName: z.string(),
export const RunInputSchema = RunCreationDataSchema.extend({
specVersion: z.number(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

RunInputSchema moving from a standalone object to RunCreationDataSchema.extend(...) changes input from optional to required at the type level. z.unknown() infers input?: unknown; SerializedDataSchema infers input: unknown.

Verified both halves:

  • Type: constructing a RunInput without input now fails with TS2741: Property 'input' is missing.
  • Runtime: RunInputSchema.safeParse({deploymentId, workflowName, specVersion}) still succeeds, so no parse behavior changed.

RunInputSchema and RunInput are public exports of @workflow/world, so this is a source-breaking type change for external consumers (community worlds, test fixtures) riding a minor bump. Either restore optionality with .partial({ input: true })-style handling, or call it out in the changeset.

}).catch(() => undefined);
if (
startHook &&
probe?.capabilities?.atomicStartHook?.active !== true

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

healthCheck(...).catch(() => undefined) collapses a network blip, a slow target, and a genuinely old deployment into the same probe === undefined, which then throws WORLD_CONTRACT_ERROR: "The target deployment does not support atomic start Hooks."

A transient probe failure is exactly the uncertainty WorkflowStartError was added for. Classifying it as a deterministic contract error tells the caller not to retry something that is retryable, and the message points at a capability gap that may not exist. Worth distinguishing "probe returned and said no" from "probe did not return".

meta.executionContext = input.executionContext;
}
if (input.attributes !== undefined) meta.attributes = input.attributes;
if (input.startHook !== undefined) meta.startHook = input.startHook;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

The v4 meta contract is two-sided: a field only survives if the receiving parser also knows it, and unknown meta keys are dropped silently rather than rejected. I checked the receiving side and it has no startHook handling today.

Inert on merge because no world advertises atomicStartHook, but the world-enabling PR has to land both halves together or admission data vanishes on the wire with no error. Worth a note in the follow-up PR description so it isn't discovered at E2E time.

(error as Error & { cause?: unknown }).cause = value.cause;
}
return error;
return makeWebError(value.name, value);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Unrelated to startHook, and it changes every hydrated error in the o11y UI, not just the new one.

The old base Error reviver assigned error.cause = value.cause after construction; makeWebError passes {cause} to the constructor instead. InstallErrorCause defines cause as non-enumerable, a plain assignment defines it as enumerable:

old: {"writable":true,"enumerable":true,"configurable":true} Object.keys -> ['name','cause']
new: {"writable":true,"enumerable":false,"configurable":true} Object.keys -> ['name']

Anything that enumerates own properties (spread, Object.keys, JSON.stringify) stops seeing cause. This is arguably a consistency fix, since FatalError and the other revivers already used the constructor form. Flagging it because it's a silent semantic change in a shared hydration path and nothing in the PR calls it out.

);
}
// Pin the run to the VM engine selected when it starts.
const workflowVm = getWorkflowVmFromEnv();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Two ordering changes here apply to all starts, not just atomic ones. Both look like improvements; noting them because neither is mentioned and both change observable behavior:

  1. Attribute/lineage/replayedFromRunId/WORKFLOW_VM validation now runs before world.getDeploymentId(). When both would fail, the validation error now wins where the deployment lookup used to.
  2. safeWaitUntil(Promise.all(ops), ...) moved ahead of admission, so the stream flush is registered even if the code between the old and new positions throws.

attributes: z.record(z.string(), z.string()).optional(),
allowReservedAttributes: z.literal(true).optional(),
startHook: StartHookSchema.optional(),
/** Public key used by cross-run writers to seal payloads to this run. */

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

The extraction into RunCreationDataSchema dropped several accurate explanatory comments: the X25519/sealed-envelope rationale on run_created.encryptionPublicKey, the resilient-start rationale on run_started.encryptionPublicKey, and in queue.ts the allowReservedAttributes mirroring note and "Initial plaintext run attributes, for resilient run creation".

start.ts lost a similar batch in the same refactor: the "<=1% of cases" note on the 409 branch, the 429/5xx/transport rationale on the retryable branch, "Queue failure is always fatal", the per-region queue routing note on opts.region, and the WORKFLOW_VM pinning note.

None of it was stale — it documents current behavior, which is what the repo guidance asks comments to do. Worth carrying forward into the new shapes.

}

function normalizeStartHook(options: StartHookOptions): StartHook {
if (options.token.length === 0) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Nit

options.token.length === 0 doesn't reject a non-string. A JS caller passing a number gets undefined === 0 -> false and the value flows through to the world. typeof options.token !== 'string' || options.token.length === 0 matches what the doc comment on line 111 already promises.

@VaguelySerious

Copy link
Copy Markdown
Member

AI Review: Note

Not anchorable inline since these files aren't in the diff. Four docs pages still describe the workaround this PR is replacing, each with the phrase "until native atomic start-and-hook registration exists":

  • docs/content/docs/v4/api-reference/workflow-api/start.mdx
  • docs/content/docs/v4/api-reference/workflow-api/get-hook-by-token.mdx
  • docs/content/docs/v5/api-reference/workflow-api/start.mdx
  • docs/content/docs/v5/api-reference/workflow-api/get-hook-by-token.mdx

Fine to leave while the capability is inert everywhere, but hook is a user-facing start() option landing in this PR and the repo guidance is that user-facing changes are documented in docs. Worth confirming #3440 covers all four, or adding a short "experimental, requires a world that advertises the capability" line here.

@vercelvercelBot 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:

Removal of the world.streams?.get guard in the cross-deployment start path forces every streams-less world (minimal worlds / test mocks) through healthCheck(), which busy-loops until the 2s probe timeout and leaves an orphaned health-check queue message.

Fix on Vercel

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI review: blocking issues found

}
if (
WorkflowWorldError.is(err) &&
err.code === START_HOOK_ADMISSION_REJECTED

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Follow-up on the narrowed condition (which is the right fix). The escape hatch only fires when the World tags the rejection, and no shipped World sets START_HOOK_ADMISSION_REJECTED today. The constant appears in exactly three places: its declaration in @workflow/errors, this check, and the prose contract on Storage.events.create. No implementation produces it.

So for any World that rejects admission with a plain 400 — the shape a validation failure naturally takes — the error falls past this check, past the EntityConflictError/RunExpiredError branch, and into getWorkflowSetupErrorCode, which returns null because isWorldContractError requires the absence of a status. That rethrows, and the queue retries a permanently-invalid request forever.

Scratch test on this head:

atomic start + WorkflowWorldError{status: 400} -> threw WorkflowWorldError, runFailed=0
atomic start + WorkflowWorldError{status: 400, code: START_HOOK_ADMISSION_REJECTED} -> acked 204, runFailed=0

Pre-existing behavior for 400s in general, so not a regression. Flagging it because this PR introduces a new category of permanent 400 and the handling for it is inert until a World opts in. Worth stating in the PR body that the code is a contract for World implementors to adopt, so the follow-up doesn't ship admission rejection without it.

@VaguelySerious

Copy link
Copy Markdown
Member

AI Review: Note

Status of the earlier findings on 91d04bb. The head was force-pushed to a single squashed commit on the same base; the only files that changed since the last review are runtime.ts, errors/src/index.ts, world/src/interfaces.ts, and two test files.

Addressed:

  • The atomic prelude shadowing terminal-run and contract errors. Details and before/after evidence in the thread.

Still open, all re-verified on this head:

  • The deleted world.streams?.get short-circuit in start.ts. Re-measured, thread updated.
  • run_started now carrying encryptionPublicKey on every resilient start, unannounced.
  • RunInput['input'] still required rather than optional: TS2741: Property 'input' is missing ... but required.
  • cause still installed non-enumerably by makeWebError.
  • The probe-timeout / capability-gap conflation in the cross-deployment branch.
  • Validation hoisted above getDeploymentId(), and safeWaitUntil moved ahead of admission.
  • The deleted explanatory comments in events.ts, queue.ts, and start.ts.
  • The token.length === 0 check still admits a non-string.
  • All four docs pages still carry "until native atomic start-and-hook registration exists".

No reply on any of those threads yet, so I have left them as-is rather than reposting.

CI on this head is green apart from Vercel – workbench-python-workflow, which fails on main and is unrelated.

Sign up for freeto 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

@NathanColosimo@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
[core] Add atomic start Hook admission by NathanColosimo · Pull Request #3426 · vercel/workflow · GitHub
Skip to content

[core] Add atomic start Hook admission - #3426

Open
NathanColosimo wants to merge 1 commit into
codex/atomic-start-capabilitiesfrom
codex/atomic-start-core
Open

[core] Add atomic start Hook admission#3426
NathanColosimo wants to merge 1 commit into
codex/atomic-start-capabilitiesfrom
codex/atomic-start-core

Conversation

@NathanColosimo

@NathanColosimoNathanColosimo commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

API

construn=awaitstart(processOrder,[orderId],{hook: {token: `order:${orderId}`,experimental_minRetention: "30 days",},});

A successful call returns the existing Run type. A duplicate throws HookConflictError with conflictingRunId. If queueing or admission cannot be confirmed, WorkflowStartError identifies the candidate run and uncertain stage.

Implementation

start() queues the candidate first, then calls events.create() to atomically reserve the token and create the run. The direct event and durable queue input carry the same normalized Hook data, so either path can complete admission. A queued loser exits before creating a run or executing user code.

The caller World and a cross-deployment target must advertise atomicStartHook. Requests with experimental_minRetention must also advertise hookRetention. This PR defines the public API, wire format, runtime behavior, and World contract; no World advertises support yet.

Plan

  1. [world] Expose capabilities in deployment health checks #3425 exposes World capabilities locally and through cross-deployment health checks.
  2. This PR defines atomic start Hook admission.
  3. Follow-up Local, Postgres, and Vercel PRs implement the atomic World operation and progressively enable one shared E2E test.
  4. [docs] Document atomic start Hook admission #3440 documents the feature and stays draft until a World implementation is ready.

@changeset-bot

changeset-botBot commented Aug 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 91d04bb

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

This PR includes changesets to release 21 packages
NameType
workflowMinor
@workflow/coreMinor
@workflow/errorsMinor
@workflow/worldMinor
@workflow/cliPatch
@workflow/web-sharedPatch
@workflow/world-vercelPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/webPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

vercelBot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

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

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewAug 12, 2026 12:03am
example-nextjs-workflow-webpackReadyReadyPreviewAug 12, 2026 12:03am
example-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-astro-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-express-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-fastify-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-hono-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-nestjs-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-nitro-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-nuxt-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-python-workflowErrorErrorAug 12, 2026 12:03am
workbench-sveltekit-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-tanstack-start-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-vite-workflowReadyReadyPreviewAug 12, 2026 12:03am
workflow-docsReadyReadyPreview, v0Aug 12, 2026 12:03am
workflow-swc-playgroundReadyReadyPreviewAug 12, 2026 12:03am
workflow-tarballsReadyReadyPreviewAug 12, 2026 12:03am
workflow-webReadyReadyPreviewAug 12, 2026 12:03am

@github-actions

github-actionsBot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production346605904056
✅ 💻 Local Development381005584368
✅ 📦 Local Production381005584368
✅ 🐘 Local Postgres381005584368
✅ 🪟 Windows31200312
✅ vercel-multi-region270027
Total152350226417499
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node128028
✅ astro-quickjs128028
✅ example-node128028
✅ example-quickjs128028
✅ express-node128028
✅ express-quickjs128028
✅ fastify-node128028
✅ fastify-quickjs128028
✅ hono-node128028
✅ hono-quickjs128028
✅ nest-node128028
✅ nest-quickjs128028
✅ nextjs-turbopack-node15303
✅ nextjs-turbopack-quickjs15303
✅ nextjs-webpack-node15303
✅ nextjs-webpack-quickjs15303
✅ nitro-node128028
✅ nitro-quickjs128028
✅ nuxt-node128028
✅ nuxt-quickjs128028
✅ sveltekit-node14709
✅ sveltekit-quickjs14709
✅ tanstack-start-node128028
✅ tanstack-start-quickjs128028
✅ vite-node128028
✅ vite-quickjs128028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack-node15600
✅ nextjs-turbopack-quickjs15600

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@github-actions

github-actionsBot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 91d04bb · Wed, 12 Aug 2026 00:17:07 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep324 (±0%)1369 🔴 (+30%) 🔻1393 🔴 (+24%) 🔻1446 🔴 (+3.4%)30
TTFSstream1303 (+719%) 🔻1363 🔴 (+27%) 🔻1378 🔴 (+26%) 🔻1450 🔴 (+17%) 🔻30
TTFShook + stream1569 (+17%) 🔻1643 🔴 (+13%)1661 🔴 (+11%)1688 🔴 (+7.4%)30
STSO1020 steps (inline)114 (-10%)159 (-16%) 💚183 (-15%) 💚491 (+16%) 🔻1019
WO1020 steps168782 (-8.4%)168782 (-8.4%)168782 (-8.4%)168782 (-8.4%)1
SLstream latency84 (-3.4%)105 🔴 (-17%) 💚122 🔴 (-11%)179 🔴 (-63%) 💚30
SOstream overhead (text)100 (-7.4%)149 (-26%) 💚160 (-49%) 💚190 (-60%) 💚30
SOstream overhead (structured)110 (+2.8%)146 (-18%) 💚160 (-33%) 💚203 (-54%) 💚30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 183209ms → this run 167452ms (Δ -15757ms, -9%)

 100-150 ms ██████░░░░░░░░░░░░░░░┃ main 168 this 606 +438
150-200 ms ███████████┃████████████ main 675 this 346 -329
200-250 ms ┃████ main 129 this 31 -98
250-300 ms ┃ main 23 this 10 -13
300-350 ms ┃ main 8 this 7 -1
350-400 ms ┃ main 3 this 7 +4
400-450 ms ┃ main 5 this 1 -4
450-500 ms ┃ main 3 this 1 -2
500-550 ms ┃ main 1 this 4 +3
550-600 ms ┃ main 0 this 1 +1
650-700 ms ┃ main 1 this 0 -1
800-850 ms ┃ main 1 this 0 -1
850-900 ms ┃ main 1 this 1 +0
900-950 ms ┃ main 1 this 0 -1
1000-1050 ms ┃ main 0 this 1 +1
1100-1150 ms ┃ main 0 this 1 +1
1650-1700 ms ┃ main 0 this 1 +1
4600-4650 ms ┃ main 0 this 1 +1
📜 Previous results (7)

45239c8

Tue, 11 Aug 2026 21:59:50 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep220 (-32%) 💚1404 🔴 (+33%) 🔻1449 🔴 (+29%) 🔻1799 🔴 (+29%) 🔻30
TTFSstream224 (+41%) 🔻1378 🔴 (+28%) 🔻1394 🔴 (+28%) 🔻1451 🔴 (+17%) 🔻30
TTFShook + stream411 (-69%) 💚1665 🔴 (+14%)1754 🔴 (+17%) 🔻1846 🔴 (+18%) 🔻30
STSO1020 steps (inline)136 (+7.1%)213 (+13%)249 (+15%) 🔻442 (+4.5%)1019
WO1020 steps213097 (+16%) 🔻213097 (+16%) 🔻213097 (+16%) 🔻213097 (+16%) 🔻1
SLstream latency100 (+15%)171 🔴 (+35%) 🔻192 🔴 (+40%) 🔻980 🔴 (+102%) 🔻30
SOstream overhead (text)131 (+21%) 🔻273 🔴 (+36%) 🔻420 (+34%) 🔻913 (+92%) 🔻30
SOstream overhead (structured)142 (+33%) 🔻264 🔴 (+49%) 🔻448 (+87%) 🔻977 (+122%) 🔻30

adf58eb

Tue, 11 Aug 2026 05:11:23 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1333 (+26%) 🔻1498 🔴 (+27%) 🔻1566 🔴 (+30%) 🔻1860 🔴 (+8.8%)30
TTFSstream1420 (+36%) 🔻1496 🔴 (+31%) 🔻1545 🔴 (+33%) 🔻1598 🔴 (+32%) 🔻30
TTFShook + stream1638 (+31%) 🔻1797 🔴 (+31%) 🔻1832 🔴 (+31%) 🔻2111 🔴 (+40%) 🔻30
STSO1020 steps (inline)115 (+20%) 🔻153 (-6.7%)169 (-17%) 💚253 (-67%) 💚1019
WO1020 steps154672 (-9.8%)154672 (-9.8%)154672 (-9.8%)154672 (-9.8%)1
SLstream latency119 (+31%) 🔻140 🔴 (-9.1%)170 🔴 (-1.2%)362 🔴 (-35%) 💚30
SOstream overhead (text)134 (+1.5%)214 (-22%) 💚224 (-32%) 💚290 (-63%) 💚30
SOstream overhead (structured)133 (+9.0%)187 (-24%) 💚209 (-33%) 💚319 (-29%) 💚30

7825269

Tue, 11 Aug 2026 00:39:16 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep224 (-73%) 💚1512 🔴 (+36%) 🔻1522 🔴 (+33%) 🔻1758 🔴 (+49%) 🔻30
TTFSstream246 (+19%) 🔻1487 🔴 (+35%) 🔻1505 🔴 (+36%) 🔻1534 🔴 (+33%) 🔻30
TTFShook + stream401 (-68%) 💚1674 🔴 (+21%) 🔻1742 🔴 (+22%) 🔻1808 🔴 (+17%) 🔻30
STSO1020 steps (inline)103 (+2.0%)153 (+3.4%)178 (+4.7%)298 (-0.7%)1019
WO1020 steps149862 (+5.7%)149862 (+5.7%)149862 (+5.7%)149862 (+5.7%)1
SLstream latency98 (+13%)193 🔴 (+71%) 🔻446 🔴 (+257%) 🔻1484 🔴 (+945%) 🔻30
SOstream overhead (text)122 (+11%)250 (+33%) 🔻376 (+77%) 🔻1304 🔴 (+446%) 🔻30
SOstream overhead (structured)124 (+12%)231 (+43%) 🔻343 (+85%) 🔻864 (+251%) 🔻30

f9614bf

Mon, 10 Aug 2026 23:58:39 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1220 (+45%) 🔻1308 🔴 (+18%) 🔻1332 🔴 (+17%) 🔻1394 🔴 (+18%) 🔻30
TTFSstream1213 (+486%) 🔻1384 🔴 (+26%) 🔻1404 🔴 (+27%) 🔻1434 🔴 (+24%) 🔻30
TTFShook + stream1448 (+16%) 🔻1646 🔴 (+19%) 🔻1750 🔴 (+23%) 🔻1822 🔴 (+18%) 🔻30
STSO1020 steps (inline)96 (-5.0%)143 (-3.4%)162 (-4.7%)398 (+33%) 🔻1019
WO1020 steps144978 (+2.2%)144978 (+2.2%)144978 (+2.2%)144978 (+2.2%)1
SLstream latency104 (+20%) 🔻141 🔴 (+25%) 🔻248 🔴 (+98%) 🔻4139 🔴 (+2815%) 🔻30
SOstream overhead (text)133 (+21%) 🔻254 🔴 (+35%) 🔻330 (+56%) 🔻587 (+146%) 🔻30
SOstream overhead (structured)114 (+2.7%)211 (+30%) 🔻302 (+63%) 🔻813 (+230%) 🔻30

77ce876

Mon, 10 Aug 2026 23:17:12 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1322 (+291%) 🔻1401 🔴 (+25%) 🔻1419 🔴 (+24%) 🔻1531 🔴 (±0%)30
TTFSstream997 (±0%)1398 🔴 (+34%) 🔻1415 🔴 (+34%) 🔻1475 🔴 (+36%) 🔻30
TTFShook + stream453 (-64%) 💚1656 🔴 (+19%) 🔻1691 🔴 (+15%)1823 🔴 (+8.3%)30
STSO1020 steps (inline)103 (+8.4%)134 (-11%)151 (-16%) 💚267 (-25%) 💚1019
WO1020 steps135938 (-8.2%)135938 (-8.2%)135938 (-8.2%)135938 (-8.2%)1
SLstream latency94 (+9.3%)129 🔴 (-0.8%)191 🔴 (+9.1%)422 🔴 (+94%) 🔻30
SOstream overhead (text)118 (+28%) 🔻209 (+12%)223 (+4.2%)330 (+31%) 🔻30
SOstream overhead (structured)120 (+17%) 🔻177 (+13%)284 (+47%) 🔻423 (+75%) 🔻30

06bd237

Mon, 10 Aug 2026 21:45:13 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1294 (+525%) 🔻1356 🔴 (+25%) 🔻1372 🔴 (+22%) 🔻1514 🔴 (-6.5%)30
TTFSstream1292 (+576%) 🔻1344 🔴 (+25%) 🔻1352 🔴 (+24%) 🔻1368 🔴 (+23%) 🔻30
TTFShook + stream1555 (+69%) 🔻1654 🔴 (+21%) 🔻1705 🔴 (+16%) 🔻1800 🔴 (+18%) 🔻30
STSO1020 steps (inline)101 (+31%) 🔻126 (-14%)143 (-19%) 💚245 (-51%) 💚1019
WO1020 steps127611 (-17%) 💚127611 (-17%) 💚127611 (-17%) 💚127611 (-17%) 💚1
SLstream latency88 (+2.3%)121 🔴 (-18%) 💚137 🔴 (-15%)152 🔴 (-50%) 💚30
SOstream overhead (text)105 (-15%) 💚163 (-39%) 💚229 (-30%) 💚251 (-85%) 💚30
SOstream overhead (structured)112 (+1.8%)163 (-42%) 💚185 (-80%) 💚652 (-68%) 💚30

3da6597

Mon, 10 Aug 2026 21:05:53 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep232 (-76%) 💚1445 🔴 (+20%) 🔻1560 🔴 (+25%) 🔻1871 🔴 (+40%) 🔻30
TTFSstream246 (-5.0%)1423 🔴 (+26%) 🔻1464 🔴 (+23%) 🔻1551 🔴 (+11%)30
TTFShook + stream406 (-5.4%)1665 🔴 (+10%)1684 🔴 (+3.0%)2292 🔴 (+8.4%)30
STSO1020 steps (inline)97 (+5.4%)153 (-6.7%)178 (-7.3%)293 (-32%) 💚1019
WO1020 steps151031 (-5.5%)151031 (-5.5%)151031 (-5.5%)151031 (-5.5%)1
SLstream latency103 (-8.0%)185 🔴 (-2.1%)218 🔴 (-50%) 💚327 🔴 (-44%) 💚30
SOstream overhead (text)150 (+9.5%)326 🔴 (+37%) 🔻504 🔴 (+36%) 🔻3837 🔴 (+205%) 🔻30
SOstream overhead (structured)137 (-9.9%)238 (-8.8%)307 (-19%) 💚343 (-50%) 💚30
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: is main, marks where this run lands, bridges the gap when this run has more samples in a bucket.

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@NathanColosimoNathanColosimo changed the title feat(core): add atomic start hook admission[core] Add atomic start Hook admissionAug 10, 2026
Comment threadpackages/core/src/runtime/start.ts Outdated
@github-actions

github-actionsBot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 Mint-ordered log — 6 fail of 41 total

log=mint-ordered · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted171.0mMISMATCH1
stale-read-equal-step-countscompleted141.0mMISMATCH1
step-vs-step-forkcompleted120msMISMATCH1
step-vs-step-fork-fencedcompleted120msMISMATCH1
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mMISMATCH1
in-flight-before-decision-countedcompleted201.0mok0
in-flight-after-decisionfailed142.0mMISMATCH1
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-mint.txt

🟢 Append-only log — 0 fail of 41 total

log=append-only · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted201.0mok0
stale-read-equal-step-countscompleted141.0mok0
step-vs-step-forkcompleted120msok0
step-vs-step-fork-fencedcompleted120msok0
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mok0
in-flight-before-decision-countedcompleted171.0mok0
in-flight-after-decisioncompleted192.0mok0
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-append-only.txt

Comment threadpackages/core/src/runtime.ts Outdated
if (
(err instanceof WorkflowWorldError ||
WorkflowWorldError.is(err)) &&
!isRetryableWorldError(err)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should this not check for the specific error? This seems like any non-retriable error will surface as a hook admission rejected

@VaguelySeriousVaguelySeriousAug 11, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI: Blocking The new runInput?.startHook !== undefined block sits before the EntityConflictError/RunExpiredError check and before the getWorkflowSetupErrorCode → recordFatalRunError path. EntityConflictError, RunExpiredError, and PreconditionFailedError all extend WorkflowWorldError and all classify as non-retryable, so the prelude shadows every one of them.

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Human pass LGTM. Agent pass coming in a few minutes probably

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI review: blocking issues found

return;
}
} catch (err) {
if (runInput?.startHook !== undefined) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

This prelude sits before the EntityConflictError/RunExpiredError check and before the getWorkflowSetupErrorCode -> recordFatalRunError path. EntityConflictError, RunExpiredError, and PreconditionFailedError all extend WorkflowWorldError and all classify as non-retryable, so err instanceof WorkflowWorldError && !isRetryableWorldError(err) shadows every one of them.

For a world-contract error the pre-existing path writes run_failed; the atomic path logs at ERROR and acks the message. The run stays in pending forever with no terminal event and no retry.

I confirmed this against the driveTurbo harness in this PR (scratch test, not committed):

non-atomic run + WorkflowWorldError{code: WORLD_CONTRACT_ERROR} -> 1 run_failed
atomic-start run + the identical error -> 0 run_failed

A second scratch test showed RunExpiredError in the atomic path now logs error: "Atomic start Hook admission rejected queued candidate" and never logs the pre-existing info: "Run already finished during setup, skipping" — same outcome, wrong severity, misleading message.

The second branch is strictly worse than falling through for contract errors, since the fall-through already stops the retry loop and records the failure. Narrowing the swallow to HookConflictError.is(err) alone fixes the case that matters. If you also want to stop queue retries for non-contract, non-retryable world errors, route those through recordFatalRunError rather than a bare return.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Resolved by 91d04bb. Narrowing to WorkflowWorldError.is(err) && err.code === START_HOOK_ADMISSION_REJECTED fixes it, and using the name-exact .is() rather than instanceof is the right call: subclasses no longer match, so the terminal-run families reach their own branch.

Verified with the same paired scratch tests, run against both heads (45239c8 pre-fix, 91d04bb post-fix):

 45239c8 (pre) 91d04bb (post)
EntityConflictError rejectedLog=true skipLog=false rejectedLog=false skipLog=true
RunExpiredError rejectedLog=true skipLog=false rejectedLog=false skipLog=true
PreconditionFailedError acked 204, no rethrow, no event rethrown (queue retries)

All three assertions fail on the pre-fix head and pass here, so the fix is load-bearing rather than incidental. PreconditionFailedError was the clearest strand: previously acked with neither a terminal event nor a retry.

The two tests added alongside the fix cover the tagged-rejection and unrelated-contract-error cases. Neither covers the terminal-run families, which are what the original prelude actually shadowed. A parametrized case over EntityConflictError/RunExpiredError/PreconditionFailedError asserting the INFO path would keep a future re-widening of this condition from going unnoticed. Note that runtimeLogger.info only reaches a sink when DEBUG matches workflow:runtime:info, so such a test has to set it.

// No probe channel to the target — cannot attest the consumer honors
// `hookInput`, so leave the marker off (fail closed to sequential).
targetHookResumeInputVersion = undefined;
} else {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

The else if (typeof world.streams?.get !== 'function') branch and its comment were deleted here. This fires for every cross-deployment start, with or without hook.

A world with no stream channel now enters healthCheck(), which enqueues a health-check message nobody can answer, then loops world.streams.get(...) -> TypeError -> sleep HEALTH_CHECK_POLL_INTERVAL, 20 times, for the full CROSS_DEPLOYMENT_CAPABILITY_PROBE_TIMEOUT_MS of 2000ms, before falling back to exactly the values the deleted branch set synchronously.

Measured on start.test.ts, same file, base vs head: 34ms -> 8.15s total, with four pre-existing tests each at ~2020ms:

should pass explicit deploymentId from opts to getEncryptionKeyForRun 2022ms
should resolve "latest" to the actual deployment ID via resolveLatestDeploymentId 2015ms
should pass the resolved deployment ID to getEncryptionKeyForRun when using "latest" 2017ms
should not call resolveLatestDeploymentId when a normal deploymentId is provided 2019ms

The guard was added deliberately in 303b6da (#1853). Nothing in the PR body explains removing it, so this reads as collateral from the refactor.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

Still open on 91d04bb. packages/core/src/runtime/start.ts is unchanged between 45239c8 and this head, and the else if (typeof world.streams?.get !== 'function') branch is still absent while it is present on the PR base (codex/atomic-start-capabilities, 459e34b, line 357).

Re-measured start.test.ts on both, same install and same build:

459e34b (PR base) 58 tests 36ms of test time
91d04bb (this head) 72 tests 8.16s of test time

with the same four pre-existing tests pinned at the probe timeout:

✓ should pass explicit deploymentId from opts to getEncryptionKeyForRun 2021ms
✓ should resolve "latest" to the actual deployment ID via resolveLatestDeploymentId 2023ms
✓ should pass the resolved deployment ID to getEncryptionKeyForRun when using "latest" 2029ms
✓ should not call resolveLatestDeploymentId when a normal deploymentId is provided 2029ms

None of those tests involve hook. Every cross-deployment start on a World without a stream channel now pays 2s and one undeliverable queue write to arrive at the values the deleted branch set synchronously. The suite passes, so CI will not catch it.

specVersion: _specVersion,
...data
} = runInput;
runCreationData = data;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Behavior change that applies to every run, not just atomic-start ones.

Replacing the explicit six-field pick with {...runInput} minus environment/specVersion means run_started now also carries encryptionPublicKey on the resilient-start path. Verified empirically by porting the new assertion on line 2348 back to the base branch:

base: expected undefined to be 'test-public-key'
head: passes

This looks like a fix, and it matches what the (now-deleted) RunStartedEventSchema comment described: on the resilient path the run is created from this event, and without the key it silently loses the ability to receive sealed writes. But it ships unannounced — the changeset doesn't mention it, and the only coverage is an added assertion inside a test named for turbo optimistic start. Worth its own changeset line and a test that names the behavior, so a future refactor doesn't drop it again.

The spread also means any field added to RunInput later is auto-forwarded into run_started rather than opted in. That's the mechanism that just quietly changed the payload here.

deploymentId: z.string(),
workflowName: z.string(),
export const RunInputSchema = RunCreationDataSchema.extend({
specVersion: z.number(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

RunInputSchema moving from a standalone object to RunCreationDataSchema.extend(...) changes input from optional to required at the type level. z.unknown() infers input?: unknown; SerializedDataSchema infers input: unknown.

Verified both halves:

  • Type: constructing a RunInput without input now fails with TS2741: Property 'input' is missing.
  • Runtime: RunInputSchema.safeParse({deploymentId, workflowName, specVersion}) still succeeds, so no parse behavior changed.

RunInputSchema and RunInput are public exports of @workflow/world, so this is a source-breaking type change for external consumers (community worlds, test fixtures) riding a minor bump. Either restore optionality with .partial({ input: true })-style handling, or call it out in the changeset.

}).catch(() => undefined);
if (
startHook &&
probe?.capabilities?.atomicStartHook?.active !== true

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

healthCheck(...).catch(() => undefined) collapses a network blip, a slow target, and a genuinely old deployment into the same probe === undefined, which then throws WORLD_CONTRACT_ERROR: "The target deployment does not support atomic start Hooks."

A transient probe failure is exactly the uncertainty WorkflowStartError was added for. Classifying it as a deterministic contract error tells the caller not to retry something that is retryable, and the message points at a capability gap that may not exist. Worth distinguishing "probe returned and said no" from "probe did not return".

meta.executionContext = input.executionContext;
}
if (input.attributes !== undefined) meta.attributes = input.attributes;
if (input.startHook !== undefined) meta.startHook = input.startHook;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

The v4 meta contract is two-sided: a field only survives if the receiving parser also knows it, and unknown meta keys are dropped silently rather than rejected. I checked the receiving side and it has no startHook handling today.

Inert on merge because no world advertises atomicStartHook, but the world-enabling PR has to land both halves together or admission data vanishes on the wire with no error. Worth a note in the follow-up PR description so it isn't discovered at E2E time.

(error as Error & { cause?: unknown }).cause = value.cause;
}
return error;
return makeWebError(value.name, value);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Unrelated to startHook, and it changes every hydrated error in the o11y UI, not just the new one.

The old base Error reviver assigned error.cause = value.cause after construction; makeWebError passes {cause} to the constructor instead. InstallErrorCause defines cause as non-enumerable, a plain assignment defines it as enumerable:

old: {"writable":true,"enumerable":true,"configurable":true} Object.keys -> ['name','cause']
new: {"writable":true,"enumerable":false,"configurable":true} Object.keys -> ['name']

Anything that enumerates own properties (spread, Object.keys, JSON.stringify) stops seeing cause. This is arguably a consistency fix, since FatalError and the other revivers already used the constructor form. Flagging it because it's a silent semantic change in a shared hydration path and nothing in the PR calls it out.

);
}
// Pin the run to the VM engine selected when it starts.
const workflowVm = getWorkflowVmFromEnv();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Two ordering changes here apply to all starts, not just atomic ones. Both look like improvements; noting them because neither is mentioned and both change observable behavior:

  1. Attribute/lineage/replayedFromRunId/WORKFLOW_VM validation now runs before world.getDeploymentId(). When both would fail, the validation error now wins where the deployment lookup used to.
  2. safeWaitUntil(Promise.all(ops), ...) moved ahead of admission, so the stream flush is registered even if the code between the old and new positions throws.

attributes: z.record(z.string(), z.string()).optional(),
allowReservedAttributes: z.literal(true).optional(),
startHook: StartHookSchema.optional(),
/** Public key used by cross-run writers to seal payloads to this run. */

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

The extraction into RunCreationDataSchema dropped several accurate explanatory comments: the X25519/sealed-envelope rationale on run_created.encryptionPublicKey, the resilient-start rationale on run_started.encryptionPublicKey, and in queue.ts the allowReservedAttributes mirroring note and "Initial plaintext run attributes, for resilient run creation".

start.ts lost a similar batch in the same refactor: the "<=1% of cases" note on the 409 branch, the 429/5xx/transport rationale on the retryable branch, "Queue failure is always fatal", the per-region queue routing note on opts.region, and the WORKFLOW_VM pinning note.

None of it was stale — it documents current behavior, which is what the repo guidance asks comments to do. Worth carrying forward into the new shapes.

}

function normalizeStartHook(options: StartHookOptions): StartHook {
if (options.token.length === 0) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Nit

options.token.length === 0 doesn't reject a non-string. A JS caller passing a number gets undefined === 0 -> false and the value flows through to the world. typeof options.token !== 'string' || options.token.length === 0 matches what the doc comment on line 111 already promises.

@VaguelySerious

Copy link
Copy Markdown
Member

AI Review: Note

Not anchorable inline since these files aren't in the diff. Four docs pages still describe the workaround this PR is replacing, each with the phrase "until native atomic start-and-hook registration exists":

  • docs/content/docs/v4/api-reference/workflow-api/start.mdx
  • docs/content/docs/v4/api-reference/workflow-api/get-hook-by-token.mdx
  • docs/content/docs/v5/api-reference/workflow-api/start.mdx
  • docs/content/docs/v5/api-reference/workflow-api/get-hook-by-token.mdx

Fine to leave while the capability is inert everywhere, but hook is a user-facing start() option landing in this PR and the repo guidance is that user-facing changes are documented in docs. Worth confirming #3440 covers all four, or adding a short "experimental, requires a world that advertises the capability" line here.

@vercelvercelBot 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:

Removal of the world.streams?.get guard in the cross-deployment start path forces every streams-less world (minimal worlds / test mocks) through healthCheck(), which busy-loops until the 2s probe timeout and leaves an orphaned health-check queue message.

Fix on Vercel

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI review: blocking issues found

}
if (
WorkflowWorldError.is(err) &&
err.code === START_HOOK_ADMISSION_REJECTED

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Follow-up on the narrowed condition (which is the right fix). The escape hatch only fires when the World tags the rejection, and no shipped World sets START_HOOK_ADMISSION_REJECTED today. The constant appears in exactly three places: its declaration in @workflow/errors, this check, and the prose contract on Storage.events.create. No implementation produces it.

So for any World that rejects admission with a plain 400 — the shape a validation failure naturally takes — the error falls past this check, past the EntityConflictError/RunExpiredError branch, and into getWorkflowSetupErrorCode, which returns null because isWorldContractError requires the absence of a status. That rethrows, and the queue retries a permanently-invalid request forever.

Scratch test on this head:

atomic start + WorkflowWorldError{status: 400} -> threw WorkflowWorldError, runFailed=0
atomic start + WorkflowWorldError{status: 400, code: START_HOOK_ADMISSION_REJECTED} -> acked 204, runFailed=0

Pre-existing behavior for 400s in general, so not a regression. Flagging it because this PR introduces a new category of permanent 400 and the handling for it is inert until a World opts in. Worth stating in the PR body that the code is a contract for World implementors to adopt, so the follow-up doesn't ship admission rejection without it.

@VaguelySerious

Copy link
Copy Markdown
Member

AI Review: Note

Status of the earlier findings on 91d04bb. The head was force-pushed to a single squashed commit on the same base; the only files that changed since the last review are runtime.ts, errors/src/index.ts, world/src/interfaces.ts, and two test files.

Addressed:

  • The atomic prelude shadowing terminal-run and contract errors. Details and before/after evidence in the thread.

Still open, all re-verified on this head:

  • The deleted world.streams?.get short-circuit in start.ts. Re-measured, thread updated.
  • run_started now carrying encryptionPublicKey on every resilient start, unannounced.
  • RunInput['input'] still required rather than optional: TS2741: Property 'input' is missing ... but required.
  • cause still installed non-enumerably by makeWebError.
  • The probe-timeout / capability-gap conflation in the cross-deployment branch.
  • Validation hoisted above getDeploymentId(), and safeWaitUntil moved ahead of admission.
  • The deleted explanatory comments in events.ts, queue.ts, and start.ts.
  • The token.length === 0 check still admits a non-string.
  • All four docs pages still carry "until native atomic start-and-hook registration exists".

No reply on any of those threads yet, so I have left them as-is rather than reposting.

CI on this head is green apart from Vercel – workbench-python-workflow, which fails on main and is unrelated.

Sign up for freeto 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

@NathanColosimo@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [core] Add atomic start Hook admission by NathanColosimo · Pull Request #3426 · vercel/workflow · GitHub
Skip to content

[core] Add atomic start Hook admission - #3426

Open
NathanColosimo wants to merge 1 commit into
codex/atomic-start-capabilitiesfrom
codex/atomic-start-core
Open

[core] Add atomic start Hook admission#3426
NathanColosimo wants to merge 1 commit into
codex/atomic-start-capabilitiesfrom
codex/atomic-start-core

Conversation

@NathanColosimo

@NathanColosimoNathanColosimo commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

API

construn=awaitstart(processOrder,[orderId],{hook: {token: `order:${orderId}`,experimental_minRetention: "30 days",},});

A successful call returns the existing Run type. A duplicate throws HookConflictError with conflictingRunId. If queueing or admission cannot be confirmed, WorkflowStartError identifies the candidate run and uncertain stage.

Implementation

start() queues the candidate first, then calls events.create() to atomically reserve the token and create the run. The direct event and durable queue input carry the same normalized Hook data, so either path can complete admission. A queued loser exits before creating a run or executing user code.

The caller World and a cross-deployment target must advertise atomicStartHook. Requests with experimental_minRetention must also advertise hookRetention. This PR defines the public API, wire format, runtime behavior, and World contract; no World advertises support yet.

Plan

  1. [world] Expose capabilities in deployment health checks #3425 exposes World capabilities locally and through cross-deployment health checks.
  2. This PR defines atomic start Hook admission.
  3. Follow-up Local, Postgres, and Vercel PRs implement the atomic World operation and progressively enable one shared E2E test.
  4. [docs] Document atomic start Hook admission #3440 documents the feature and stays draft until a World implementation is ready.

@changeset-bot

changeset-botBot commented Aug 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 91d04bb

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

This PR includes changesets to release 21 packages
NameType
workflowMinor
@workflow/coreMinor
@workflow/errorsMinor
@workflow/worldMinor
@workflow/cliPatch
@workflow/web-sharedPatch
@workflow/world-vercelPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/webPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

vercelBot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

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

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewAug 12, 2026 12:03am
example-nextjs-workflow-webpackReadyReadyPreviewAug 12, 2026 12:03am
example-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-astro-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-express-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-fastify-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-hono-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-nestjs-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-nitro-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-nuxt-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-python-workflowErrorErrorAug 12, 2026 12:03am
workbench-sveltekit-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-tanstack-start-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-vite-workflowReadyReadyPreviewAug 12, 2026 12:03am
workflow-docsReadyReadyPreview, v0Aug 12, 2026 12:03am
workflow-swc-playgroundReadyReadyPreviewAug 12, 2026 12:03am
workflow-tarballsReadyReadyPreviewAug 12, 2026 12:03am
workflow-webReadyReadyPreviewAug 12, 2026 12:03am

@github-actions

github-actionsBot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production346605904056
✅ 💻 Local Development381005584368
✅ 📦 Local Production381005584368
✅ 🐘 Local Postgres381005584368
✅ 🪟 Windows31200312
✅ vercel-multi-region270027
Total152350226417499
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node128028
✅ astro-quickjs128028
✅ example-node128028
✅ example-quickjs128028
✅ express-node128028
✅ express-quickjs128028
✅ fastify-node128028
✅ fastify-quickjs128028
✅ hono-node128028
✅ hono-quickjs128028
✅ nest-node128028
✅ nest-quickjs128028
✅ nextjs-turbopack-node15303
✅ nextjs-turbopack-quickjs15303
✅ nextjs-webpack-node15303
✅ nextjs-webpack-quickjs15303
✅ nitro-node128028
✅ nitro-quickjs128028
✅ nuxt-node128028
✅ nuxt-quickjs128028
✅ sveltekit-node14709
✅ sveltekit-quickjs14709
✅ tanstack-start-node128028
✅ tanstack-start-quickjs128028
✅ vite-node128028
✅ vite-quickjs128028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack-node15600
✅ nextjs-turbopack-quickjs15600

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@github-actions

github-actionsBot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 91d04bb · Wed, 12 Aug 2026 00:17:07 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep324 (±0%)1369 🔴 (+30%) 🔻1393 🔴 (+24%) 🔻1446 🔴 (+3.4%)30
TTFSstream1303 (+719%) 🔻1363 🔴 (+27%) 🔻1378 🔴 (+26%) 🔻1450 🔴 (+17%) 🔻30
TTFShook + stream1569 (+17%) 🔻1643 🔴 (+13%)1661 🔴 (+11%)1688 🔴 (+7.4%)30
STSO1020 steps (inline)114 (-10%)159 (-16%) 💚183 (-15%) 💚491 (+16%) 🔻1019
WO1020 steps168782 (-8.4%)168782 (-8.4%)168782 (-8.4%)168782 (-8.4%)1
SLstream latency84 (-3.4%)105 🔴 (-17%) 💚122 🔴 (-11%)179 🔴 (-63%) 💚30
SOstream overhead (text)100 (-7.4%)149 (-26%) 💚160 (-49%) 💚190 (-60%) 💚30
SOstream overhead (structured)110 (+2.8%)146 (-18%) 💚160 (-33%) 💚203 (-54%) 💚30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 183209ms → this run 167452ms (Δ -15757ms, -9%)

 100-150 ms ██████░░░░░░░░░░░░░░░┃ main 168 this 606 +438
150-200 ms ███████████┃████████████ main 675 this 346 -329
200-250 ms ┃████ main 129 this 31 -98
250-300 ms ┃ main 23 this 10 -13
300-350 ms ┃ main 8 this 7 -1
350-400 ms ┃ main 3 this 7 +4
400-450 ms ┃ main 5 this 1 -4
450-500 ms ┃ main 3 this 1 -2
500-550 ms ┃ main 1 this 4 +3
550-600 ms ┃ main 0 this 1 +1
650-700 ms ┃ main 1 this 0 -1
800-850 ms ┃ main 1 this 0 -1
850-900 ms ┃ main 1 this 1 +0
900-950 ms ┃ main 1 this 0 -1
1000-1050 ms ┃ main 0 this 1 +1
1100-1150 ms ┃ main 0 this 1 +1
1650-1700 ms ┃ main 0 this 1 +1
4600-4650 ms ┃ main 0 this 1 +1
📜 Previous results (7)

45239c8

Tue, 11 Aug 2026 21:59:50 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep220 (-32%) 💚1404 🔴 (+33%) 🔻1449 🔴 (+29%) 🔻1799 🔴 (+29%) 🔻30
TTFSstream224 (+41%) 🔻1378 🔴 (+28%) 🔻1394 🔴 (+28%) 🔻1451 🔴 (+17%) 🔻30
TTFShook + stream411 (-69%) 💚1665 🔴 (+14%)1754 🔴 (+17%) 🔻1846 🔴 (+18%) 🔻30
STSO1020 steps (inline)136 (+7.1%)213 (+13%)249 (+15%) 🔻442 (+4.5%)1019
WO1020 steps213097 (+16%) 🔻213097 (+16%) 🔻213097 (+16%) 🔻213097 (+16%) 🔻1
SLstream latency100 (+15%)171 🔴 (+35%) 🔻192 🔴 (+40%) 🔻980 🔴 (+102%) 🔻30
SOstream overhead (text)131 (+21%) 🔻273 🔴 (+36%) 🔻420 (+34%) 🔻913 (+92%) 🔻30
SOstream overhead (structured)142 (+33%) 🔻264 🔴 (+49%) 🔻448 (+87%) 🔻977 (+122%) 🔻30

adf58eb

Tue, 11 Aug 2026 05:11:23 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1333 (+26%) 🔻1498 🔴 (+27%) 🔻1566 🔴 (+30%) 🔻1860 🔴 (+8.8%)30
TTFSstream1420 (+36%) 🔻1496 🔴 (+31%) 🔻1545 🔴 (+33%) 🔻1598 🔴 (+32%) 🔻30
TTFShook + stream1638 (+31%) 🔻1797 🔴 (+31%) 🔻1832 🔴 (+31%) 🔻2111 🔴 (+40%) 🔻30
STSO1020 steps (inline)115 (+20%) 🔻153 (-6.7%)169 (-17%) 💚253 (-67%) 💚1019
WO1020 steps154672 (-9.8%)154672 (-9.8%)154672 (-9.8%)154672 (-9.8%)1
SLstream latency119 (+31%) 🔻140 🔴 (-9.1%)170 🔴 (-1.2%)362 🔴 (-35%) 💚30
SOstream overhead (text)134 (+1.5%)214 (-22%) 💚224 (-32%) 💚290 (-63%) 💚30
SOstream overhead (structured)133 (+9.0%)187 (-24%) 💚209 (-33%) 💚319 (-29%) 💚30

7825269

Tue, 11 Aug 2026 00:39:16 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep224 (-73%) 💚1512 🔴 (+36%) 🔻1522 🔴 (+33%) 🔻1758 🔴 (+49%) 🔻30
TTFSstream246 (+19%) 🔻1487 🔴 (+35%) 🔻1505 🔴 (+36%) 🔻1534 🔴 (+33%) 🔻30
TTFShook + stream401 (-68%) 💚1674 🔴 (+21%) 🔻1742 🔴 (+22%) 🔻1808 🔴 (+17%) 🔻30
STSO1020 steps (inline)103 (+2.0%)153 (+3.4%)178 (+4.7%)298 (-0.7%)1019
WO1020 steps149862 (+5.7%)149862 (+5.7%)149862 (+5.7%)149862 (+5.7%)1
SLstream latency98 (+13%)193 🔴 (+71%) 🔻446 🔴 (+257%) 🔻1484 🔴 (+945%) 🔻30
SOstream overhead (text)122 (+11%)250 (+33%) 🔻376 (+77%) 🔻1304 🔴 (+446%) 🔻30
SOstream overhead (structured)124 (+12%)231 (+43%) 🔻343 (+85%) 🔻864 (+251%) 🔻30

f9614bf

Mon, 10 Aug 2026 23:58:39 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1220 (+45%) 🔻1308 🔴 (+18%) 🔻1332 🔴 (+17%) 🔻1394 🔴 (+18%) 🔻30
TTFSstream1213 (+486%) 🔻1384 🔴 (+26%) 🔻1404 🔴 (+27%) 🔻1434 🔴 (+24%) 🔻30
TTFShook + stream1448 (+16%) 🔻1646 🔴 (+19%) 🔻1750 🔴 (+23%) 🔻1822 🔴 (+18%) 🔻30
STSO1020 steps (inline)96 (-5.0%)143 (-3.4%)162 (-4.7%)398 (+33%) 🔻1019
WO1020 steps144978 (+2.2%)144978 (+2.2%)144978 (+2.2%)144978 (+2.2%)1
SLstream latency104 (+20%) 🔻141 🔴 (+25%) 🔻248 🔴 (+98%) 🔻4139 🔴 (+2815%) 🔻30
SOstream overhead (text)133 (+21%) 🔻254 🔴 (+35%) 🔻330 (+56%) 🔻587 (+146%) 🔻30
SOstream overhead (structured)114 (+2.7%)211 (+30%) 🔻302 (+63%) 🔻813 (+230%) 🔻30

77ce876

Mon, 10 Aug 2026 23:17:12 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1322 (+291%) 🔻1401 🔴 (+25%) 🔻1419 🔴 (+24%) 🔻1531 🔴 (±0%)30
TTFSstream997 (±0%)1398 🔴 (+34%) 🔻1415 🔴 (+34%) 🔻1475 🔴 (+36%) 🔻30
TTFShook + stream453 (-64%) 💚1656 🔴 (+19%) 🔻1691 🔴 (+15%)1823 🔴 (+8.3%)30
STSO1020 steps (inline)103 (+8.4%)134 (-11%)151 (-16%) 💚267 (-25%) 💚1019
WO1020 steps135938 (-8.2%)135938 (-8.2%)135938 (-8.2%)135938 (-8.2%)1
SLstream latency94 (+9.3%)129 🔴 (-0.8%)191 🔴 (+9.1%)422 🔴 (+94%) 🔻30
SOstream overhead (text)118 (+28%) 🔻209 (+12%)223 (+4.2%)330 (+31%) 🔻30
SOstream overhead (structured)120 (+17%) 🔻177 (+13%)284 (+47%) 🔻423 (+75%) 🔻30

06bd237

Mon, 10 Aug 2026 21:45:13 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1294 (+525%) 🔻1356 🔴 (+25%) 🔻1372 🔴 (+22%) 🔻1514 🔴 (-6.5%)30
TTFSstream1292 (+576%) 🔻1344 🔴 (+25%) 🔻1352 🔴 (+24%) 🔻1368 🔴 (+23%) 🔻30
TTFShook + stream1555 (+69%) 🔻1654 🔴 (+21%) 🔻1705 🔴 (+16%) 🔻1800 🔴 (+18%) 🔻30
STSO1020 steps (inline)101 (+31%) 🔻126 (-14%)143 (-19%) 💚245 (-51%) 💚1019
WO1020 steps127611 (-17%) 💚127611 (-17%) 💚127611 (-17%) 💚127611 (-17%) 💚1
SLstream latency88 (+2.3%)121 🔴 (-18%) 💚137 🔴 (-15%)152 🔴 (-50%) 💚30
SOstream overhead (text)105 (-15%) 💚163 (-39%) 💚229 (-30%) 💚251 (-85%) 💚30
SOstream overhead (structured)112 (+1.8%)163 (-42%) 💚185 (-80%) 💚652 (-68%) 💚30

3da6597

Mon, 10 Aug 2026 21:05:53 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep232 (-76%) 💚1445 🔴 (+20%) 🔻1560 🔴 (+25%) 🔻1871 🔴 (+40%) 🔻30
TTFSstream246 (-5.0%)1423 🔴 (+26%) 🔻1464 🔴 (+23%) 🔻1551 🔴 (+11%)30
TTFShook + stream406 (-5.4%)1665 🔴 (+10%)1684 🔴 (+3.0%)2292 🔴 (+8.4%)30
STSO1020 steps (inline)97 (+5.4%)153 (-6.7%)178 (-7.3%)293 (-32%) 💚1019
WO1020 steps151031 (-5.5%)151031 (-5.5%)151031 (-5.5%)151031 (-5.5%)1
SLstream latency103 (-8.0%)185 🔴 (-2.1%)218 🔴 (-50%) 💚327 🔴 (-44%) 💚30
SOstream overhead (text)150 (+9.5%)326 🔴 (+37%) 🔻504 🔴 (+36%) 🔻3837 🔴 (+205%) 🔻30
SOstream overhead (structured)137 (-9.9%)238 (-8.8%)307 (-19%) 💚343 (-50%) 💚30
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: is main, marks where this run lands, bridges the gap when this run has more samples in a bucket.

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@NathanColosimoNathanColosimo changed the title feat(core): add atomic start hook admission[core] Add atomic start Hook admissionAug 10, 2026
Comment threadpackages/core/src/runtime/start.ts Outdated
@github-actions

github-actionsBot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 Mint-ordered log — 6 fail of 41 total

log=mint-ordered · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted171.0mMISMATCH1
stale-read-equal-step-countscompleted141.0mMISMATCH1
step-vs-step-forkcompleted120msMISMATCH1
step-vs-step-fork-fencedcompleted120msMISMATCH1
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mMISMATCH1
in-flight-before-decision-countedcompleted201.0mok0
in-flight-after-decisionfailed142.0mMISMATCH1
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-mint.txt

🟢 Append-only log — 0 fail of 41 total

log=append-only · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted201.0mok0
stale-read-equal-step-countscompleted141.0mok0
step-vs-step-forkcompleted120msok0
step-vs-step-fork-fencedcompleted120msok0
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mok0
in-flight-before-decision-countedcompleted171.0mok0
in-flight-after-decisioncompleted192.0mok0
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-append-only.txt

Comment threadpackages/core/src/runtime.ts Outdated
if (
(err instanceof WorkflowWorldError ||
WorkflowWorldError.is(err)) &&
!isRetryableWorldError(err)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should this not check for the specific error? This seems like any non-retriable error will surface as a hook admission rejected

@VaguelySeriousVaguelySeriousAug 11, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI: Blocking The new runInput?.startHook !== undefined block sits before the EntityConflictError/RunExpiredError check and before the getWorkflowSetupErrorCode → recordFatalRunError path. EntityConflictError, RunExpiredError, and PreconditionFailedError all extend WorkflowWorldError and all classify as non-retryable, so the prelude shadows every one of them.

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Human pass LGTM. Agent pass coming in a few minutes probably

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI review: blocking issues found

return;
}
} catch (err) {
if (runInput?.startHook !== undefined) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

This prelude sits before the EntityConflictError/RunExpiredError check and before the getWorkflowSetupErrorCode -> recordFatalRunError path. EntityConflictError, RunExpiredError, and PreconditionFailedError all extend WorkflowWorldError and all classify as non-retryable, so err instanceof WorkflowWorldError && !isRetryableWorldError(err) shadows every one of them.

For a world-contract error the pre-existing path writes run_failed; the atomic path logs at ERROR and acks the message. The run stays in pending forever with no terminal event and no retry.

I confirmed this against the driveTurbo harness in this PR (scratch test, not committed):

non-atomic run + WorkflowWorldError{code: WORLD_CONTRACT_ERROR} -> 1 run_failed
atomic-start run + the identical error -> 0 run_failed

A second scratch test showed RunExpiredError in the atomic path now logs error: "Atomic start Hook admission rejected queued candidate" and never logs the pre-existing info: "Run already finished during setup, skipping" — same outcome, wrong severity, misleading message.

The second branch is strictly worse than falling through for contract errors, since the fall-through already stops the retry loop and records the failure. Narrowing the swallow to HookConflictError.is(err) alone fixes the case that matters. If you also want to stop queue retries for non-contract, non-retryable world errors, route those through recordFatalRunError rather than a bare return.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Resolved by 91d04bb. Narrowing to WorkflowWorldError.is(err) && err.code === START_HOOK_ADMISSION_REJECTED fixes it, and using the name-exact .is() rather than instanceof is the right call: subclasses no longer match, so the terminal-run families reach their own branch.

Verified with the same paired scratch tests, run against both heads (45239c8 pre-fix, 91d04bb post-fix):

 45239c8 (pre) 91d04bb (post)
EntityConflictError rejectedLog=true skipLog=false rejectedLog=false skipLog=true
RunExpiredError rejectedLog=true skipLog=false rejectedLog=false skipLog=true
PreconditionFailedError acked 204, no rethrow, no event rethrown (queue retries)

All three assertions fail on the pre-fix head and pass here, so the fix is load-bearing rather than incidental. PreconditionFailedError was the clearest strand: previously acked with neither a terminal event nor a retry.

The two tests added alongside the fix cover the tagged-rejection and unrelated-contract-error cases. Neither covers the terminal-run families, which are what the original prelude actually shadowed. A parametrized case over EntityConflictError/RunExpiredError/PreconditionFailedError asserting the INFO path would keep a future re-widening of this condition from going unnoticed. Note that runtimeLogger.info only reaches a sink when DEBUG matches workflow:runtime:info, so such a test has to set it.

// No probe channel to the target — cannot attest the consumer honors
// `hookInput`, so leave the marker off (fail closed to sequential).
targetHookResumeInputVersion = undefined;
} else {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

The else if (typeof world.streams?.get !== 'function') branch and its comment were deleted here. This fires for every cross-deployment start, with or without hook.

A world with no stream channel now enters healthCheck(), which enqueues a health-check message nobody can answer, then loops world.streams.get(...) -> TypeError -> sleep HEALTH_CHECK_POLL_INTERVAL, 20 times, for the full CROSS_DEPLOYMENT_CAPABILITY_PROBE_TIMEOUT_MS of 2000ms, before falling back to exactly the values the deleted branch set synchronously.

Measured on start.test.ts, same file, base vs head: 34ms -> 8.15s total, with four pre-existing tests each at ~2020ms:

should pass explicit deploymentId from opts to getEncryptionKeyForRun 2022ms
should resolve "latest" to the actual deployment ID via resolveLatestDeploymentId 2015ms
should pass the resolved deployment ID to getEncryptionKeyForRun when using "latest" 2017ms
should not call resolveLatestDeploymentId when a normal deploymentId is provided 2019ms

The guard was added deliberately in 303b6da (#1853). Nothing in the PR body explains removing it, so this reads as collateral from the refactor.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

Still open on 91d04bb. packages/core/src/runtime/start.ts is unchanged between 45239c8 and this head, and the else if (typeof world.streams?.get !== 'function') branch is still absent while it is present on the PR base (codex/atomic-start-capabilities, 459e34b, line 357).

Re-measured start.test.ts on both, same install and same build:

459e34b (PR base) 58 tests 36ms of test time
91d04bb (this head) 72 tests 8.16s of test time

with the same four pre-existing tests pinned at the probe timeout:

✓ should pass explicit deploymentId from opts to getEncryptionKeyForRun 2021ms
✓ should resolve "latest" to the actual deployment ID via resolveLatestDeploymentId 2023ms
✓ should pass the resolved deployment ID to getEncryptionKeyForRun when using "latest" 2029ms
✓ should not call resolveLatestDeploymentId when a normal deploymentId is provided 2029ms

None of those tests involve hook. Every cross-deployment start on a World without a stream channel now pays 2s and one undeliverable queue write to arrive at the values the deleted branch set synchronously. The suite passes, so CI will not catch it.

specVersion: _specVersion,
...data
} = runInput;
runCreationData = data;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Behavior change that applies to every run, not just atomic-start ones.

Replacing the explicit six-field pick with {...runInput} minus environment/specVersion means run_started now also carries encryptionPublicKey on the resilient-start path. Verified empirically by porting the new assertion on line 2348 back to the base branch:

base: expected undefined to be 'test-public-key'
head: passes

This looks like a fix, and it matches what the (now-deleted) RunStartedEventSchema comment described: on the resilient path the run is created from this event, and without the key it silently loses the ability to receive sealed writes. But it ships unannounced — the changeset doesn't mention it, and the only coverage is an added assertion inside a test named for turbo optimistic start. Worth its own changeset line and a test that names the behavior, so a future refactor doesn't drop it again.

The spread also means any field added to RunInput later is auto-forwarded into run_started rather than opted in. That's the mechanism that just quietly changed the payload here.

deploymentId: z.string(),
workflowName: z.string(),
export const RunInputSchema = RunCreationDataSchema.extend({
specVersion: z.number(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

RunInputSchema moving from a standalone object to RunCreationDataSchema.extend(...) changes input from optional to required at the type level. z.unknown() infers input?: unknown; SerializedDataSchema infers input: unknown.

Verified both halves:

  • Type: constructing a RunInput without input now fails with TS2741: Property 'input' is missing.
  • Runtime: RunInputSchema.safeParse({deploymentId, workflowName, specVersion}) still succeeds, so no parse behavior changed.

RunInputSchema and RunInput are public exports of @workflow/world, so this is a source-breaking type change for external consumers (community worlds, test fixtures) riding a minor bump. Either restore optionality with .partial({ input: true })-style handling, or call it out in the changeset.

}).catch(() => undefined);
if (
startHook &&
probe?.capabilities?.atomicStartHook?.active !== true

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

healthCheck(...).catch(() => undefined) collapses a network blip, a slow target, and a genuinely old deployment into the same probe === undefined, which then throws WORLD_CONTRACT_ERROR: "The target deployment does not support atomic start Hooks."

A transient probe failure is exactly the uncertainty WorkflowStartError was added for. Classifying it as a deterministic contract error tells the caller not to retry something that is retryable, and the message points at a capability gap that may not exist. Worth distinguishing "probe returned and said no" from "probe did not return".

meta.executionContext = input.executionContext;
}
if (input.attributes !== undefined) meta.attributes = input.attributes;
if (input.startHook !== undefined) meta.startHook = input.startHook;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

The v4 meta contract is two-sided: a field only survives if the receiving parser also knows it, and unknown meta keys are dropped silently rather than rejected. I checked the receiving side and it has no startHook handling today.

Inert on merge because no world advertises atomicStartHook, but the world-enabling PR has to land both halves together or admission data vanishes on the wire with no error. Worth a note in the follow-up PR description so it isn't discovered at E2E time.

(error as Error & { cause?: unknown }).cause = value.cause;
}
return error;
return makeWebError(value.name, value);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Unrelated to startHook, and it changes every hydrated error in the o11y UI, not just the new one.

The old base Error reviver assigned error.cause = value.cause after construction; makeWebError passes {cause} to the constructor instead. InstallErrorCause defines cause as non-enumerable, a plain assignment defines it as enumerable:

old: {"writable":true,"enumerable":true,"configurable":true} Object.keys -> ['name','cause']
new: {"writable":true,"enumerable":false,"configurable":true} Object.keys -> ['name']

Anything that enumerates own properties (spread, Object.keys, JSON.stringify) stops seeing cause. This is arguably a consistency fix, since FatalError and the other revivers already used the constructor form. Flagging it because it's a silent semantic change in a shared hydration path and nothing in the PR calls it out.

);
}
// Pin the run to the VM engine selected when it starts.
const workflowVm = getWorkflowVmFromEnv();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Two ordering changes here apply to all starts, not just atomic ones. Both look like improvements; noting them because neither is mentioned and both change observable behavior:

  1. Attribute/lineage/replayedFromRunId/WORKFLOW_VM validation now runs before world.getDeploymentId(). When both would fail, the validation error now wins where the deployment lookup used to.
  2. safeWaitUntil(Promise.all(ops), ...) moved ahead of admission, so the stream flush is registered even if the code between the old and new positions throws.

attributes: z.record(z.string(), z.string()).optional(),
allowReservedAttributes: z.literal(true).optional(),
startHook: StartHookSchema.optional(),
/** Public key used by cross-run writers to seal payloads to this run. */

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

The extraction into RunCreationDataSchema dropped several accurate explanatory comments: the X25519/sealed-envelope rationale on run_created.encryptionPublicKey, the resilient-start rationale on run_started.encryptionPublicKey, and in queue.ts the allowReservedAttributes mirroring note and "Initial plaintext run attributes, for resilient run creation".

start.ts lost a similar batch in the same refactor: the "<=1% of cases" note on the 409 branch, the 429/5xx/transport rationale on the retryable branch, "Queue failure is always fatal", the per-region queue routing note on opts.region, and the WORKFLOW_VM pinning note.

None of it was stale — it documents current behavior, which is what the repo guidance asks comments to do. Worth carrying forward into the new shapes.

}

function normalizeStartHook(options: StartHookOptions): StartHook {
if (options.token.length === 0) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Nit

options.token.length === 0 doesn't reject a non-string. A JS caller passing a number gets undefined === 0 -> false and the value flows through to the world. typeof options.token !== 'string' || options.token.length === 0 matches what the doc comment on line 111 already promises.

@VaguelySerious

Copy link
Copy Markdown
Member

AI Review: Note

Not anchorable inline since these files aren't in the diff. Four docs pages still describe the workaround this PR is replacing, each with the phrase "until native atomic start-and-hook registration exists":

  • docs/content/docs/v4/api-reference/workflow-api/start.mdx
  • docs/content/docs/v4/api-reference/workflow-api/get-hook-by-token.mdx
  • docs/content/docs/v5/api-reference/workflow-api/start.mdx
  • docs/content/docs/v5/api-reference/workflow-api/get-hook-by-token.mdx

Fine to leave while the capability is inert everywhere, but hook is a user-facing start() option landing in this PR and the repo guidance is that user-facing changes are documented in docs. Worth confirming #3440 covers all four, or adding a short "experimental, requires a world that advertises the capability" line here.

@vercelvercelBot 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:

Removal of the world.streams?.get guard in the cross-deployment start path forces every streams-less world (minimal worlds / test mocks) through healthCheck(), which busy-loops until the 2s probe timeout and leaves an orphaned health-check queue message.

Fix on Vercel

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI review: blocking issues found

}
if (
WorkflowWorldError.is(err) &&
err.code === START_HOOK_ADMISSION_REJECTED

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Follow-up on the narrowed condition (which is the right fix). The escape hatch only fires when the World tags the rejection, and no shipped World sets START_HOOK_ADMISSION_REJECTED today. The constant appears in exactly three places: its declaration in @workflow/errors, this check, and the prose contract on Storage.events.create. No implementation produces it.

So for any World that rejects admission with a plain 400 — the shape a validation failure naturally takes — the error falls past this check, past the EntityConflictError/RunExpiredError branch, and into getWorkflowSetupErrorCode, which returns null because isWorldContractError requires the absence of a status. That rethrows, and the queue retries a permanently-invalid request forever.

Scratch test on this head:

atomic start + WorkflowWorldError{status: 400} -> threw WorkflowWorldError, runFailed=0
atomic start + WorkflowWorldError{status: 400, code: START_HOOK_ADMISSION_REJECTED} -> acked 204, runFailed=0

Pre-existing behavior for 400s in general, so not a regression. Flagging it because this PR introduces a new category of permanent 400 and the handling for it is inert until a World opts in. Worth stating in the PR body that the code is a contract for World implementors to adopt, so the follow-up doesn't ship admission rejection without it.

@VaguelySerious

Copy link
Copy Markdown
Member

AI Review: Note

Status of the earlier findings on 91d04bb. The head was force-pushed to a single squashed commit on the same base; the only files that changed since the last review are runtime.ts, errors/src/index.ts, world/src/interfaces.ts, and two test files.

Addressed:

  • The atomic prelude shadowing terminal-run and contract errors. Details and before/after evidence in the thread.

Still open, all re-verified on this head:

  • The deleted world.streams?.get short-circuit in start.ts. Re-measured, thread updated.
  • run_started now carrying encryptionPublicKey on every resilient start, unannounced.
  • RunInput['input'] still required rather than optional: TS2741: Property 'input' is missing ... but required.
  • cause still installed non-enumerably by makeWebError.
  • The probe-timeout / capability-gap conflation in the cross-deployment branch.
  • Validation hoisted above getDeploymentId(), and safeWaitUntil moved ahead of admission.
  • The deleted explanatory comments in events.ts, queue.ts, and start.ts.
  • The token.length === 0 check still admits a non-string.
  • All four docs pages still carry "until native atomic start-and-hook registration exists".

No reply on any of those threads yet, so I have left them as-is rather than reposting.

CI on this head is green apart from Vercel – workbench-python-workflow, which fails on main and is unrelated.

Sign up for freeto 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

@NathanColosimo@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [core] Add atomic start Hook admission by NathanColosimo · Pull Request #3426 · vercel/workflow · GitHub
Skip to content

[core] Add atomic start Hook admission - #3426

Open
NathanColosimo wants to merge 1 commit into
codex/atomic-start-capabilitiesfrom
codex/atomic-start-core
Open

[core] Add atomic start Hook admission#3426
NathanColosimo wants to merge 1 commit into
codex/atomic-start-capabilitiesfrom
codex/atomic-start-core

Conversation

@NathanColosimo

@NathanColosimoNathanColosimo commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

API

construn=awaitstart(processOrder,[orderId],{hook: {token: `order:${orderId}`,experimental_minRetention: "30 days",},});

A successful call returns the existing Run type. A duplicate throws HookConflictError with conflictingRunId. If queueing or admission cannot be confirmed, WorkflowStartError identifies the candidate run and uncertain stage.

Implementation

start() queues the candidate first, then calls events.create() to atomically reserve the token and create the run. The direct event and durable queue input carry the same normalized Hook data, so either path can complete admission. A queued loser exits before creating a run or executing user code.

The caller World and a cross-deployment target must advertise atomicStartHook. Requests with experimental_minRetention must also advertise hookRetention. This PR defines the public API, wire format, runtime behavior, and World contract; no World advertises support yet.

Plan

  1. [world] Expose capabilities in deployment health checks #3425 exposes World capabilities locally and through cross-deployment health checks.
  2. This PR defines atomic start Hook admission.
  3. Follow-up Local, Postgres, and Vercel PRs implement the atomic World operation and progressively enable one shared E2E test.
  4. [docs] Document atomic start Hook admission #3440 documents the feature and stays draft until a World implementation is ready.

@changeset-bot

changeset-botBot commented Aug 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 91d04bb

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

This PR includes changesets to release 21 packages
NameType
workflowMinor
@workflow/coreMinor
@workflow/errorsMinor
@workflow/worldMinor
@workflow/cliPatch
@workflow/web-sharedPatch
@workflow/world-vercelPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/webPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

vercelBot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

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

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewAug 12, 2026 12:03am
example-nextjs-workflow-webpackReadyReadyPreviewAug 12, 2026 12:03am
example-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-astro-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-express-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-fastify-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-hono-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-nestjs-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-nitro-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-nuxt-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-python-workflowErrorErrorAug 12, 2026 12:03am
workbench-sveltekit-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-tanstack-start-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-vite-workflowReadyReadyPreviewAug 12, 2026 12:03am
workflow-docsReadyReadyPreview, v0Aug 12, 2026 12:03am
workflow-swc-playgroundReadyReadyPreviewAug 12, 2026 12:03am
workflow-tarballsReadyReadyPreviewAug 12, 2026 12:03am
workflow-webReadyReadyPreviewAug 12, 2026 12:03am

@github-actions

github-actionsBot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production346605904056
✅ 💻 Local Development381005584368
✅ 📦 Local Production381005584368
✅ 🐘 Local Postgres381005584368
✅ 🪟 Windows31200312
✅ vercel-multi-region270027
Total152350226417499
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node128028
✅ astro-quickjs128028
✅ example-node128028
✅ example-quickjs128028
✅ express-node128028
✅ express-quickjs128028
✅ fastify-node128028
✅ fastify-quickjs128028
✅ hono-node128028
✅ hono-quickjs128028
✅ nest-node128028
✅ nest-quickjs128028
✅ nextjs-turbopack-node15303
✅ nextjs-turbopack-quickjs15303
✅ nextjs-webpack-node15303
✅ nextjs-webpack-quickjs15303
✅ nitro-node128028
✅ nitro-quickjs128028
✅ nuxt-node128028
✅ nuxt-quickjs128028
✅ sveltekit-node14709
✅ sveltekit-quickjs14709
✅ tanstack-start-node128028
✅ tanstack-start-quickjs128028
✅ vite-node128028
✅ vite-quickjs128028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack-node15600
✅ nextjs-turbopack-quickjs15600

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@github-actions

github-actionsBot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 91d04bb · Wed, 12 Aug 2026 00:17:07 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep324 (±0%)1369 🔴 (+30%) 🔻1393 🔴 (+24%) 🔻1446 🔴 (+3.4%)30
TTFSstream1303 (+719%) 🔻1363 🔴 (+27%) 🔻1378 🔴 (+26%) 🔻1450 🔴 (+17%) 🔻30
TTFShook + stream1569 (+17%) 🔻1643 🔴 (+13%)1661 🔴 (+11%)1688 🔴 (+7.4%)30
STSO1020 steps (inline)114 (-10%)159 (-16%) 💚183 (-15%) 💚491 (+16%) 🔻1019
WO1020 steps168782 (-8.4%)168782 (-8.4%)168782 (-8.4%)168782 (-8.4%)1
SLstream latency84 (-3.4%)105 🔴 (-17%) 💚122 🔴 (-11%)179 🔴 (-63%) 💚30
SOstream overhead (text)100 (-7.4%)149 (-26%) 💚160 (-49%) 💚190 (-60%) 💚30
SOstream overhead (structured)110 (+2.8%)146 (-18%) 💚160 (-33%) 💚203 (-54%) 💚30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 183209ms → this run 167452ms (Δ -15757ms, -9%)

 100-150 ms ██████░░░░░░░░░░░░░░░┃ main 168 this 606 +438
150-200 ms ███████████┃████████████ main 675 this 346 -329
200-250 ms ┃████ main 129 this 31 -98
250-300 ms ┃ main 23 this 10 -13
300-350 ms ┃ main 8 this 7 -1
350-400 ms ┃ main 3 this 7 +4
400-450 ms ┃ main 5 this 1 -4
450-500 ms ┃ main 3 this 1 -2
500-550 ms ┃ main 1 this 4 +3
550-600 ms ┃ main 0 this 1 +1
650-700 ms ┃ main 1 this 0 -1
800-850 ms ┃ main 1 this 0 -1
850-900 ms ┃ main 1 this 1 +0
900-950 ms ┃ main 1 this 0 -1
1000-1050 ms ┃ main 0 this 1 +1
1100-1150 ms ┃ main 0 this 1 +1
1650-1700 ms ┃ main 0 this 1 +1
4600-4650 ms ┃ main 0 this 1 +1
📜 Previous results (7)

45239c8

Tue, 11 Aug 2026 21:59:50 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep220 (-32%) 💚1404 🔴 (+33%) 🔻1449 🔴 (+29%) 🔻1799 🔴 (+29%) 🔻30
TTFSstream224 (+41%) 🔻1378 🔴 (+28%) 🔻1394 🔴 (+28%) 🔻1451 🔴 (+17%) 🔻30
TTFShook + stream411 (-69%) 💚1665 🔴 (+14%)1754 🔴 (+17%) 🔻1846 🔴 (+18%) 🔻30
STSO1020 steps (inline)136 (+7.1%)213 (+13%)249 (+15%) 🔻442 (+4.5%)1019
WO1020 steps213097 (+16%) 🔻213097 (+16%) 🔻213097 (+16%) 🔻213097 (+16%) 🔻1
SLstream latency100 (+15%)171 🔴 (+35%) 🔻192 🔴 (+40%) 🔻980 🔴 (+102%) 🔻30
SOstream overhead (text)131 (+21%) 🔻273 🔴 (+36%) 🔻420 (+34%) 🔻913 (+92%) 🔻30
SOstream overhead (structured)142 (+33%) 🔻264 🔴 (+49%) 🔻448 (+87%) 🔻977 (+122%) 🔻30

adf58eb

Tue, 11 Aug 2026 05:11:23 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1333 (+26%) 🔻1498 🔴 (+27%) 🔻1566 🔴 (+30%) 🔻1860 🔴 (+8.8%)30
TTFSstream1420 (+36%) 🔻1496 🔴 (+31%) 🔻1545 🔴 (+33%) 🔻1598 🔴 (+32%) 🔻30
TTFShook + stream1638 (+31%) 🔻1797 🔴 (+31%) 🔻1832 🔴 (+31%) 🔻2111 🔴 (+40%) 🔻30
STSO1020 steps (inline)115 (+20%) 🔻153 (-6.7%)169 (-17%) 💚253 (-67%) 💚1019
WO1020 steps154672 (-9.8%)154672 (-9.8%)154672 (-9.8%)154672 (-9.8%)1
SLstream latency119 (+31%) 🔻140 🔴 (-9.1%)170 🔴 (-1.2%)362 🔴 (-35%) 💚30
SOstream overhead (text)134 (+1.5%)214 (-22%) 💚224 (-32%) 💚290 (-63%) 💚30
SOstream overhead (structured)133 (+9.0%)187 (-24%) 💚209 (-33%) 💚319 (-29%) 💚30

7825269

Tue, 11 Aug 2026 00:39:16 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep224 (-73%) 💚1512 🔴 (+36%) 🔻1522 🔴 (+33%) 🔻1758 🔴 (+49%) 🔻30
TTFSstream246 (+19%) 🔻1487 🔴 (+35%) 🔻1505 🔴 (+36%) 🔻1534 🔴 (+33%) 🔻30
TTFShook + stream401 (-68%) 💚1674 🔴 (+21%) 🔻1742 🔴 (+22%) 🔻1808 🔴 (+17%) 🔻30
STSO1020 steps (inline)103 (+2.0%)153 (+3.4%)178 (+4.7%)298 (-0.7%)1019
WO1020 steps149862 (+5.7%)149862 (+5.7%)149862 (+5.7%)149862 (+5.7%)1
SLstream latency98 (+13%)193 🔴 (+71%) 🔻446 🔴 (+257%) 🔻1484 🔴 (+945%) 🔻30
SOstream overhead (text)122 (+11%)250 (+33%) 🔻376 (+77%) 🔻1304 🔴 (+446%) 🔻30
SOstream overhead (structured)124 (+12%)231 (+43%) 🔻343 (+85%) 🔻864 (+251%) 🔻30

f9614bf

Mon, 10 Aug 2026 23:58:39 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1220 (+45%) 🔻1308 🔴 (+18%) 🔻1332 🔴 (+17%) 🔻1394 🔴 (+18%) 🔻30
TTFSstream1213 (+486%) 🔻1384 🔴 (+26%) 🔻1404 🔴 (+27%) 🔻1434 🔴 (+24%) 🔻30
TTFShook + stream1448 (+16%) 🔻1646 🔴 (+19%) 🔻1750 🔴 (+23%) 🔻1822 🔴 (+18%) 🔻30
STSO1020 steps (inline)96 (-5.0%)143 (-3.4%)162 (-4.7%)398 (+33%) 🔻1019
WO1020 steps144978 (+2.2%)144978 (+2.2%)144978 (+2.2%)144978 (+2.2%)1
SLstream latency104 (+20%) 🔻141 🔴 (+25%) 🔻248 🔴 (+98%) 🔻4139 🔴 (+2815%) 🔻30
SOstream overhead (text)133 (+21%) 🔻254 🔴 (+35%) 🔻330 (+56%) 🔻587 (+146%) 🔻30
SOstream overhead (structured)114 (+2.7%)211 (+30%) 🔻302 (+63%) 🔻813 (+230%) 🔻30

77ce876

Mon, 10 Aug 2026 23:17:12 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1322 (+291%) 🔻1401 🔴 (+25%) 🔻1419 🔴 (+24%) 🔻1531 🔴 (±0%)30
TTFSstream997 (±0%)1398 🔴 (+34%) 🔻1415 🔴 (+34%) 🔻1475 🔴 (+36%) 🔻30
TTFShook + stream453 (-64%) 💚1656 🔴 (+19%) 🔻1691 🔴 (+15%)1823 🔴 (+8.3%)30
STSO1020 steps (inline)103 (+8.4%)134 (-11%)151 (-16%) 💚267 (-25%) 💚1019
WO1020 steps135938 (-8.2%)135938 (-8.2%)135938 (-8.2%)135938 (-8.2%)1
SLstream latency94 (+9.3%)129 🔴 (-0.8%)191 🔴 (+9.1%)422 🔴 (+94%) 🔻30
SOstream overhead (text)118 (+28%) 🔻209 (+12%)223 (+4.2%)330 (+31%) 🔻30
SOstream overhead (structured)120 (+17%) 🔻177 (+13%)284 (+47%) 🔻423 (+75%) 🔻30

06bd237

Mon, 10 Aug 2026 21:45:13 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1294 (+525%) 🔻1356 🔴 (+25%) 🔻1372 🔴 (+22%) 🔻1514 🔴 (-6.5%)30
TTFSstream1292 (+576%) 🔻1344 🔴 (+25%) 🔻1352 🔴 (+24%) 🔻1368 🔴 (+23%) 🔻30
TTFShook + stream1555 (+69%) 🔻1654 🔴 (+21%) 🔻1705 🔴 (+16%) 🔻1800 🔴 (+18%) 🔻30
STSO1020 steps (inline)101 (+31%) 🔻126 (-14%)143 (-19%) 💚245 (-51%) 💚1019
WO1020 steps127611 (-17%) 💚127611 (-17%) 💚127611 (-17%) 💚127611 (-17%) 💚1
SLstream latency88 (+2.3%)121 🔴 (-18%) 💚137 🔴 (-15%)152 🔴 (-50%) 💚30
SOstream overhead (text)105 (-15%) 💚163 (-39%) 💚229 (-30%) 💚251 (-85%) 💚30
SOstream overhead (structured)112 (+1.8%)163 (-42%) 💚185 (-80%) 💚652 (-68%) 💚30

3da6597

Mon, 10 Aug 2026 21:05:53 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep232 (-76%) 💚1445 🔴 (+20%) 🔻1560 🔴 (+25%) 🔻1871 🔴 (+40%) 🔻30
TTFSstream246 (-5.0%)1423 🔴 (+26%) 🔻1464 🔴 (+23%) 🔻1551 🔴 (+11%)30
TTFShook + stream406 (-5.4%)1665 🔴 (+10%)1684 🔴 (+3.0%)2292 🔴 (+8.4%)30
STSO1020 steps (inline)97 (+5.4%)153 (-6.7%)178 (-7.3%)293 (-32%) 💚1019
WO1020 steps151031 (-5.5%)151031 (-5.5%)151031 (-5.5%)151031 (-5.5%)1
SLstream latency103 (-8.0%)185 🔴 (-2.1%)218 🔴 (-50%) 💚327 🔴 (-44%) 💚30
SOstream overhead (text)150 (+9.5%)326 🔴 (+37%) 🔻504 🔴 (+36%) 🔻3837 🔴 (+205%) 🔻30
SOstream overhead (structured)137 (-9.9%)238 (-8.8%)307 (-19%) 💚343 (-50%) 💚30
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: is main, marks where this run lands, bridges the gap when this run has more samples in a bucket.

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@NathanColosimoNathanColosimo changed the title feat(core): add atomic start hook admission[core] Add atomic start Hook admissionAug 10, 2026
Comment threadpackages/core/src/runtime/start.ts Outdated
@github-actions

github-actionsBot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 Mint-ordered log — 6 fail of 41 total

log=mint-ordered · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted171.0mMISMATCH1
stale-read-equal-step-countscompleted141.0mMISMATCH1
step-vs-step-forkcompleted120msMISMATCH1
step-vs-step-fork-fencedcompleted120msMISMATCH1
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mMISMATCH1
in-flight-before-decision-countedcompleted201.0mok0
in-flight-after-decisionfailed142.0mMISMATCH1
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-mint.txt

🟢 Append-only log — 0 fail of 41 total

log=append-only · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted201.0mok0
stale-read-equal-step-countscompleted141.0mok0
step-vs-step-forkcompleted120msok0
step-vs-step-fork-fencedcompleted120msok0
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mok0
in-flight-before-decision-countedcompleted171.0mok0
in-flight-after-decisioncompleted192.0mok0
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-append-only.txt

Comment threadpackages/core/src/runtime.ts Outdated
if (
(err instanceof WorkflowWorldError ||
WorkflowWorldError.is(err)) &&
!isRetryableWorldError(err)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should this not check for the specific error? This seems like any non-retriable error will surface as a hook admission rejected

@VaguelySeriousVaguelySeriousAug 11, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI: Blocking The new runInput?.startHook !== undefined block sits before the EntityConflictError/RunExpiredError check and before the getWorkflowSetupErrorCode → recordFatalRunError path. EntityConflictError, RunExpiredError, and PreconditionFailedError all extend WorkflowWorldError and all classify as non-retryable, so the prelude shadows every one of them.

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Human pass LGTM. Agent pass coming in a few minutes probably

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI review: blocking issues found

return;
}
} catch (err) {
if (runInput?.startHook !== undefined) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

This prelude sits before the EntityConflictError/RunExpiredError check and before the getWorkflowSetupErrorCode -> recordFatalRunError path. EntityConflictError, RunExpiredError, and PreconditionFailedError all extend WorkflowWorldError and all classify as non-retryable, so err instanceof WorkflowWorldError && !isRetryableWorldError(err) shadows every one of them.

For a world-contract error the pre-existing path writes run_failed; the atomic path logs at ERROR and acks the message. The run stays in pending forever with no terminal event and no retry.

I confirmed this against the driveTurbo harness in this PR (scratch test, not committed):

non-atomic run + WorkflowWorldError{code: WORLD_CONTRACT_ERROR} -> 1 run_failed
atomic-start run + the identical error -> 0 run_failed

A second scratch test showed RunExpiredError in the atomic path now logs error: "Atomic start Hook admission rejected queued candidate" and never logs the pre-existing info: "Run already finished during setup, skipping" — same outcome, wrong severity, misleading message.

The second branch is strictly worse than falling through for contract errors, since the fall-through already stops the retry loop and records the failure. Narrowing the swallow to HookConflictError.is(err) alone fixes the case that matters. If you also want to stop queue retries for non-contract, non-retryable world errors, route those through recordFatalRunError rather than a bare return.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Resolved by 91d04bb. Narrowing to WorkflowWorldError.is(err) && err.code === START_HOOK_ADMISSION_REJECTED fixes it, and using the name-exact .is() rather than instanceof is the right call: subclasses no longer match, so the terminal-run families reach their own branch.

Verified with the same paired scratch tests, run against both heads (45239c8 pre-fix, 91d04bb post-fix):

 45239c8 (pre) 91d04bb (post)
EntityConflictError rejectedLog=true skipLog=false rejectedLog=false skipLog=true
RunExpiredError rejectedLog=true skipLog=false rejectedLog=false skipLog=true
PreconditionFailedError acked 204, no rethrow, no event rethrown (queue retries)

All three assertions fail on the pre-fix head and pass here, so the fix is load-bearing rather than incidental. PreconditionFailedError was the clearest strand: previously acked with neither a terminal event nor a retry.

The two tests added alongside the fix cover the tagged-rejection and unrelated-contract-error cases. Neither covers the terminal-run families, which are what the original prelude actually shadowed. A parametrized case over EntityConflictError/RunExpiredError/PreconditionFailedError asserting the INFO path would keep a future re-widening of this condition from going unnoticed. Note that runtimeLogger.info only reaches a sink when DEBUG matches workflow:runtime:info, so such a test has to set it.

// No probe channel to the target — cannot attest the consumer honors
// `hookInput`, so leave the marker off (fail closed to sequential).
targetHookResumeInputVersion = undefined;
} else {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

The else if (typeof world.streams?.get !== 'function') branch and its comment were deleted here. This fires for every cross-deployment start, with or without hook.

A world with no stream channel now enters healthCheck(), which enqueues a health-check message nobody can answer, then loops world.streams.get(...) -> TypeError -> sleep HEALTH_CHECK_POLL_INTERVAL, 20 times, for the full CROSS_DEPLOYMENT_CAPABILITY_PROBE_TIMEOUT_MS of 2000ms, before falling back to exactly the values the deleted branch set synchronously.

Measured on start.test.ts, same file, base vs head: 34ms -> 8.15s total, with four pre-existing tests each at ~2020ms:

should pass explicit deploymentId from opts to getEncryptionKeyForRun 2022ms
should resolve "latest" to the actual deployment ID via resolveLatestDeploymentId 2015ms
should pass the resolved deployment ID to getEncryptionKeyForRun when using "latest" 2017ms
should not call resolveLatestDeploymentId when a normal deploymentId is provided 2019ms

The guard was added deliberately in 303b6da (#1853). Nothing in the PR body explains removing it, so this reads as collateral from the refactor.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

Still open on 91d04bb. packages/core/src/runtime/start.ts is unchanged between 45239c8 and this head, and the else if (typeof world.streams?.get !== 'function') branch is still absent while it is present on the PR base (codex/atomic-start-capabilities, 459e34b, line 357).

Re-measured start.test.ts on both, same install and same build:

459e34b (PR base) 58 tests 36ms of test time
91d04bb (this head) 72 tests 8.16s of test time

with the same four pre-existing tests pinned at the probe timeout:

✓ should pass explicit deploymentId from opts to getEncryptionKeyForRun 2021ms
✓ should resolve "latest" to the actual deployment ID via resolveLatestDeploymentId 2023ms
✓ should pass the resolved deployment ID to getEncryptionKeyForRun when using "latest" 2029ms
✓ should not call resolveLatestDeploymentId when a normal deploymentId is provided 2029ms

None of those tests involve hook. Every cross-deployment start on a World without a stream channel now pays 2s and one undeliverable queue write to arrive at the values the deleted branch set synchronously. The suite passes, so CI will not catch it.

specVersion: _specVersion,
...data
} = runInput;
runCreationData = data;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Behavior change that applies to every run, not just atomic-start ones.

Replacing the explicit six-field pick with {...runInput} minus environment/specVersion means run_started now also carries encryptionPublicKey on the resilient-start path. Verified empirically by porting the new assertion on line 2348 back to the base branch:

base: expected undefined to be 'test-public-key'
head: passes

This looks like a fix, and it matches what the (now-deleted) RunStartedEventSchema comment described: on the resilient path the run is created from this event, and without the key it silently loses the ability to receive sealed writes. But it ships unannounced — the changeset doesn't mention it, and the only coverage is an added assertion inside a test named for turbo optimistic start. Worth its own changeset line and a test that names the behavior, so a future refactor doesn't drop it again.

The spread also means any field added to RunInput later is auto-forwarded into run_started rather than opted in. That's the mechanism that just quietly changed the payload here.

deploymentId: z.string(),
workflowName: z.string(),
export const RunInputSchema = RunCreationDataSchema.extend({
specVersion: z.number(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

RunInputSchema moving from a standalone object to RunCreationDataSchema.extend(...) changes input from optional to required at the type level. z.unknown() infers input?: unknown; SerializedDataSchema infers input: unknown.

Verified both halves:

  • Type: constructing a RunInput without input now fails with TS2741: Property 'input' is missing.
  • Runtime: RunInputSchema.safeParse({deploymentId, workflowName, specVersion}) still succeeds, so no parse behavior changed.

RunInputSchema and RunInput are public exports of @workflow/world, so this is a source-breaking type change for external consumers (community worlds, test fixtures) riding a minor bump. Either restore optionality with .partial({ input: true })-style handling, or call it out in the changeset.

}).catch(() => undefined);
if (
startHook &&
probe?.capabilities?.atomicStartHook?.active !== true

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

healthCheck(...).catch(() => undefined) collapses a network blip, a slow target, and a genuinely old deployment into the same probe === undefined, which then throws WORLD_CONTRACT_ERROR: "The target deployment does not support atomic start Hooks."

A transient probe failure is exactly the uncertainty WorkflowStartError was added for. Classifying it as a deterministic contract error tells the caller not to retry something that is retryable, and the message points at a capability gap that may not exist. Worth distinguishing "probe returned and said no" from "probe did not return".

meta.executionContext = input.executionContext;
}
if (input.attributes !== undefined) meta.attributes = input.attributes;
if (input.startHook !== undefined) meta.startHook = input.startHook;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

The v4 meta contract is two-sided: a field only survives if the receiving parser also knows it, and unknown meta keys are dropped silently rather than rejected. I checked the receiving side and it has no startHook handling today.

Inert on merge because no world advertises atomicStartHook, but the world-enabling PR has to land both halves together or admission data vanishes on the wire with no error. Worth a note in the follow-up PR description so it isn't discovered at E2E time.

(error as Error & { cause?: unknown }).cause = value.cause;
}
return error;
return makeWebError(value.name, value);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Unrelated to startHook, and it changes every hydrated error in the o11y UI, not just the new one.

The old base Error reviver assigned error.cause = value.cause after construction; makeWebError passes {cause} to the constructor instead. InstallErrorCause defines cause as non-enumerable, a plain assignment defines it as enumerable:

old: {"writable":true,"enumerable":true,"configurable":true} Object.keys -> ['name','cause']
new: {"writable":true,"enumerable":false,"configurable":true} Object.keys -> ['name']

Anything that enumerates own properties (spread, Object.keys, JSON.stringify) stops seeing cause. This is arguably a consistency fix, since FatalError and the other revivers already used the constructor form. Flagging it because it's a silent semantic change in a shared hydration path and nothing in the PR calls it out.

);
}
// Pin the run to the VM engine selected when it starts.
const workflowVm = getWorkflowVmFromEnv();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Two ordering changes here apply to all starts, not just atomic ones. Both look like improvements; noting them because neither is mentioned and both change observable behavior:

  1. Attribute/lineage/replayedFromRunId/WORKFLOW_VM validation now runs before world.getDeploymentId(). When both would fail, the validation error now wins where the deployment lookup used to.
  2. safeWaitUntil(Promise.all(ops), ...) moved ahead of admission, so the stream flush is registered even if the code between the old and new positions throws.

attributes: z.record(z.string(), z.string()).optional(),
allowReservedAttributes: z.literal(true).optional(),
startHook: StartHookSchema.optional(),
/** Public key used by cross-run writers to seal payloads to this run. */

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

The extraction into RunCreationDataSchema dropped several accurate explanatory comments: the X25519/sealed-envelope rationale on run_created.encryptionPublicKey, the resilient-start rationale on run_started.encryptionPublicKey, and in queue.ts the allowReservedAttributes mirroring note and "Initial plaintext run attributes, for resilient run creation".

start.ts lost a similar batch in the same refactor: the "<=1% of cases" note on the 409 branch, the 429/5xx/transport rationale on the retryable branch, "Queue failure is always fatal", the per-region queue routing note on opts.region, and the WORKFLOW_VM pinning note.

None of it was stale — it documents current behavior, which is what the repo guidance asks comments to do. Worth carrying forward into the new shapes.

}

function normalizeStartHook(options: StartHookOptions): StartHook {
if (options.token.length === 0) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Nit

options.token.length === 0 doesn't reject a non-string. A JS caller passing a number gets undefined === 0 -> false and the value flows through to the world. typeof options.token !== 'string' || options.token.length === 0 matches what the doc comment on line 111 already promises.

@VaguelySerious

Copy link
Copy Markdown
Member

AI Review: Note

Not anchorable inline since these files aren't in the diff. Four docs pages still describe the workaround this PR is replacing, each with the phrase "until native atomic start-and-hook registration exists":

  • docs/content/docs/v4/api-reference/workflow-api/start.mdx
  • docs/content/docs/v4/api-reference/workflow-api/get-hook-by-token.mdx
  • docs/content/docs/v5/api-reference/workflow-api/start.mdx
  • docs/content/docs/v5/api-reference/workflow-api/get-hook-by-token.mdx

Fine to leave while the capability is inert everywhere, but hook is a user-facing start() option landing in this PR and the repo guidance is that user-facing changes are documented in docs. Worth confirming #3440 covers all four, or adding a short "experimental, requires a world that advertises the capability" line here.

@vercelvercelBot 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:

Removal of the world.streams?.get guard in the cross-deployment start path forces every streams-less world (minimal worlds / test mocks) through healthCheck(), which busy-loops until the 2s probe timeout and leaves an orphaned health-check queue message.

Fix on Vercel

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI review: blocking issues found

}
if (
WorkflowWorldError.is(err) &&
err.code === START_HOOK_ADMISSION_REJECTED

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Follow-up on the narrowed condition (which is the right fix). The escape hatch only fires when the World tags the rejection, and no shipped World sets START_HOOK_ADMISSION_REJECTED today. The constant appears in exactly three places: its declaration in @workflow/errors, this check, and the prose contract on Storage.events.create. No implementation produces it.

So for any World that rejects admission with a plain 400 — the shape a validation failure naturally takes — the error falls past this check, past the EntityConflictError/RunExpiredError branch, and into getWorkflowSetupErrorCode, which returns null because isWorldContractError requires the absence of a status. That rethrows, and the queue retries a permanently-invalid request forever.

Scratch test on this head:

atomic start + WorkflowWorldError{status: 400} -> threw WorkflowWorldError, runFailed=0
atomic start + WorkflowWorldError{status: 400, code: START_HOOK_ADMISSION_REJECTED} -> acked 204, runFailed=0

Pre-existing behavior for 400s in general, so not a regression. Flagging it because this PR introduces a new category of permanent 400 and the handling for it is inert until a World opts in. Worth stating in the PR body that the code is a contract for World implementors to adopt, so the follow-up doesn't ship admission rejection without it.

@VaguelySerious

Copy link
Copy Markdown
Member

AI Review: Note

Status of the earlier findings on 91d04bb. The head was force-pushed to a single squashed commit on the same base; the only files that changed since the last review are runtime.ts, errors/src/index.ts, world/src/interfaces.ts, and two test files.

Addressed:

  • The atomic prelude shadowing terminal-run and contract errors. Details and before/after evidence in the thread.

Still open, all re-verified on this head:

  • The deleted world.streams?.get short-circuit in start.ts. Re-measured, thread updated.
  • run_started now carrying encryptionPublicKey on every resilient start, unannounced.
  • RunInput['input'] still required rather than optional: TS2741: Property 'input' is missing ... but required.
  • cause still installed non-enumerably by makeWebError.
  • The probe-timeout / capability-gap conflation in the cross-deployment branch.
  • Validation hoisted above getDeploymentId(), and safeWaitUntil moved ahead of admission.
  • The deleted explanatory comments in events.ts, queue.ts, and start.ts.
  • The token.length === 0 check still admits a non-string.
  • All four docs pages still carry "until native atomic start-and-hook registration exists".

No reply on any of those threads yet, so I have left them as-is rather than reposting.

CI on this head is green apart from Vercel – workbench-python-workflow, which fails on main and is unrelated.

Sign up for freeto 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

@NathanColosimo@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' [core] Add atomic start Hook admission by NathanColosimo · Pull Request #3426 · vercel/workflow · GitHub
Skip to content

[core] Add atomic start Hook admission - #3426

Open
NathanColosimo wants to merge 1 commit into
codex/atomic-start-capabilitiesfrom
codex/atomic-start-core
Open

[core] Add atomic start Hook admission#3426
NathanColosimo wants to merge 1 commit into
codex/atomic-start-capabilitiesfrom
codex/atomic-start-core

Conversation

@NathanColosimo

@NathanColosimoNathanColosimo commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

API

construn=awaitstart(processOrder,[orderId],{hook: {token: `order:${orderId}`,experimental_minRetention: "30 days",},});

A successful call returns the existing Run type. A duplicate throws HookConflictError with conflictingRunId. If queueing or admission cannot be confirmed, WorkflowStartError identifies the candidate run and uncertain stage.

Implementation

start() queues the candidate first, then calls events.create() to atomically reserve the token and create the run. The direct event and durable queue input carry the same normalized Hook data, so either path can complete admission. A queued loser exits before creating a run or executing user code.

The caller World and a cross-deployment target must advertise atomicStartHook. Requests with experimental_minRetention must also advertise hookRetention. This PR defines the public API, wire format, runtime behavior, and World contract; no World advertises support yet.

Plan

  1. [world] Expose capabilities in deployment health checks #3425 exposes World capabilities locally and through cross-deployment health checks.
  2. This PR defines atomic start Hook admission.
  3. Follow-up Local, Postgres, and Vercel PRs implement the atomic World operation and progressively enable one shared E2E test.
  4. [docs] Document atomic start Hook admission #3440 documents the feature and stays draft until a World implementation is ready.

@changeset-bot

changeset-botBot commented Aug 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 91d04bb

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

This PR includes changesets to release 21 packages
NameType
workflowMinor
@workflow/coreMinor
@workflow/errorsMinor
@workflow/worldMinor
@workflow/cliPatch
@workflow/web-sharedPatch
@workflow/world-vercelPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/webPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

vercelBot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

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

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewAug 12, 2026 12:03am
example-nextjs-workflow-webpackReadyReadyPreviewAug 12, 2026 12:03am
example-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-astro-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-express-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-fastify-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-hono-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-nestjs-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-nitro-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-nuxt-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-python-workflowErrorErrorAug 12, 2026 12:03am
workbench-sveltekit-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-tanstack-start-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-vite-workflowReadyReadyPreviewAug 12, 2026 12:03am
workflow-docsReadyReadyPreview, v0Aug 12, 2026 12:03am
workflow-swc-playgroundReadyReadyPreviewAug 12, 2026 12:03am
workflow-tarballsReadyReadyPreviewAug 12, 2026 12:03am
workflow-webReadyReadyPreviewAug 12, 2026 12:03am

@github-actions

github-actionsBot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production346605904056
✅ 💻 Local Development381005584368
✅ 📦 Local Production381005584368
✅ 🐘 Local Postgres381005584368
✅ 🪟 Windows31200312
✅ vercel-multi-region270027
Total152350226417499
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node128028
✅ astro-quickjs128028
✅ example-node128028
✅ example-quickjs128028
✅ express-node128028
✅ express-quickjs128028
✅ fastify-node128028
✅ fastify-quickjs128028
✅ hono-node128028
✅ hono-quickjs128028
✅ nest-node128028
✅ nest-quickjs128028
✅ nextjs-turbopack-node15303
✅ nextjs-turbopack-quickjs15303
✅ nextjs-webpack-node15303
✅ nextjs-webpack-quickjs15303
✅ nitro-node128028
✅ nitro-quickjs128028
✅ nuxt-node128028
✅ nuxt-quickjs128028
✅ sveltekit-node14709
✅ sveltekit-quickjs14709
✅ tanstack-start-node128028
✅ tanstack-start-quickjs128028
✅ vite-node128028
✅ vite-quickjs128028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack-node15600
✅ nextjs-turbopack-quickjs15600

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@github-actions

github-actionsBot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 91d04bb · Wed, 12 Aug 2026 00:17:07 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep324 (±0%)1369 🔴 (+30%) 🔻1393 🔴 (+24%) 🔻1446 🔴 (+3.4%)30
TTFSstream1303 (+719%) 🔻1363 🔴 (+27%) 🔻1378 🔴 (+26%) 🔻1450 🔴 (+17%) 🔻30
TTFShook + stream1569 (+17%) 🔻1643 🔴 (+13%)1661 🔴 (+11%)1688 🔴 (+7.4%)30
STSO1020 steps (inline)114 (-10%)159 (-16%) 💚183 (-15%) 💚491 (+16%) 🔻1019
WO1020 steps168782 (-8.4%)168782 (-8.4%)168782 (-8.4%)168782 (-8.4%)1
SLstream latency84 (-3.4%)105 🔴 (-17%) 💚122 🔴 (-11%)179 🔴 (-63%) 💚30
SOstream overhead (text)100 (-7.4%)149 (-26%) 💚160 (-49%) 💚190 (-60%) 💚30
SOstream overhead (structured)110 (+2.8%)146 (-18%) 💚160 (-33%) 💚203 (-54%) 💚30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 183209ms → this run 167452ms (Δ -15757ms, -9%)

 100-150 ms ██████░░░░░░░░░░░░░░░┃ main 168 this 606 +438
150-200 ms ███████████┃████████████ main 675 this 346 -329
200-250 ms ┃████ main 129 this 31 -98
250-300 ms ┃ main 23 this 10 -13
300-350 ms ┃ main 8 this 7 -1
350-400 ms ┃ main 3 this 7 +4
400-450 ms ┃ main 5 this 1 -4
450-500 ms ┃ main 3 this 1 -2
500-550 ms ┃ main 1 this 4 +3
550-600 ms ┃ main 0 this 1 +1
650-700 ms ┃ main 1 this 0 -1
800-850 ms ┃ main 1 this 0 -1
850-900 ms ┃ main 1 this 1 +0
900-950 ms ┃ main 1 this 0 -1
1000-1050 ms ┃ main 0 this 1 +1
1100-1150 ms ┃ main 0 this 1 +1
1650-1700 ms ┃ main 0 this 1 +1
4600-4650 ms ┃ main 0 this 1 +1
📜 Previous results (7)

45239c8

Tue, 11 Aug 2026 21:59:50 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep220 (-32%) 💚1404 🔴 (+33%) 🔻1449 🔴 (+29%) 🔻1799 🔴 (+29%) 🔻30
TTFSstream224 (+41%) 🔻1378 🔴 (+28%) 🔻1394 🔴 (+28%) 🔻1451 🔴 (+17%) 🔻30
TTFShook + stream411 (-69%) 💚1665 🔴 (+14%)1754 🔴 (+17%) 🔻1846 🔴 (+18%) 🔻30
STSO1020 steps (inline)136 (+7.1%)213 (+13%)249 (+15%) 🔻442 (+4.5%)1019
WO1020 steps213097 (+16%) 🔻213097 (+16%) 🔻213097 (+16%) 🔻213097 (+16%) 🔻1
SLstream latency100 (+15%)171 🔴 (+35%) 🔻192 🔴 (+40%) 🔻980 🔴 (+102%) 🔻30
SOstream overhead (text)131 (+21%) 🔻273 🔴 (+36%) 🔻420 (+34%) 🔻913 (+92%) 🔻30
SOstream overhead (structured)142 (+33%) 🔻264 🔴 (+49%) 🔻448 (+87%) 🔻977 (+122%) 🔻30

adf58eb

Tue, 11 Aug 2026 05:11:23 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1333 (+26%) 🔻1498 🔴 (+27%) 🔻1566 🔴 (+30%) 🔻1860 🔴 (+8.8%)30
TTFSstream1420 (+36%) 🔻1496 🔴 (+31%) 🔻1545 🔴 (+33%) 🔻1598 🔴 (+32%) 🔻30
TTFShook + stream1638 (+31%) 🔻1797 🔴 (+31%) 🔻1832 🔴 (+31%) 🔻2111 🔴 (+40%) 🔻30
STSO1020 steps (inline)115 (+20%) 🔻153 (-6.7%)169 (-17%) 💚253 (-67%) 💚1019
WO1020 steps154672 (-9.8%)154672 (-9.8%)154672 (-9.8%)154672 (-9.8%)1
SLstream latency119 (+31%) 🔻140 🔴 (-9.1%)170 🔴 (-1.2%)362 🔴 (-35%) 💚30
SOstream overhead (text)134 (+1.5%)214 (-22%) 💚224 (-32%) 💚290 (-63%) 💚30
SOstream overhead (structured)133 (+9.0%)187 (-24%) 💚209 (-33%) 💚319 (-29%) 💚30

7825269

Tue, 11 Aug 2026 00:39:16 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep224 (-73%) 💚1512 🔴 (+36%) 🔻1522 🔴 (+33%) 🔻1758 🔴 (+49%) 🔻30
TTFSstream246 (+19%) 🔻1487 🔴 (+35%) 🔻1505 🔴 (+36%) 🔻1534 🔴 (+33%) 🔻30
TTFShook + stream401 (-68%) 💚1674 🔴 (+21%) 🔻1742 🔴 (+22%) 🔻1808 🔴 (+17%) 🔻30
STSO1020 steps (inline)103 (+2.0%)153 (+3.4%)178 (+4.7%)298 (-0.7%)1019
WO1020 steps149862 (+5.7%)149862 (+5.7%)149862 (+5.7%)149862 (+5.7%)1
SLstream latency98 (+13%)193 🔴 (+71%) 🔻446 🔴 (+257%) 🔻1484 🔴 (+945%) 🔻30
SOstream overhead (text)122 (+11%)250 (+33%) 🔻376 (+77%) 🔻1304 🔴 (+446%) 🔻30
SOstream overhead (structured)124 (+12%)231 (+43%) 🔻343 (+85%) 🔻864 (+251%) 🔻30

f9614bf

Mon, 10 Aug 2026 23:58:39 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1220 (+45%) 🔻1308 🔴 (+18%) 🔻1332 🔴 (+17%) 🔻1394 🔴 (+18%) 🔻30
TTFSstream1213 (+486%) 🔻1384 🔴 (+26%) 🔻1404 🔴 (+27%) 🔻1434 🔴 (+24%) 🔻30
TTFShook + stream1448 (+16%) 🔻1646 🔴 (+19%) 🔻1750 🔴 (+23%) 🔻1822 🔴 (+18%) 🔻30
STSO1020 steps (inline)96 (-5.0%)143 (-3.4%)162 (-4.7%)398 (+33%) 🔻1019
WO1020 steps144978 (+2.2%)144978 (+2.2%)144978 (+2.2%)144978 (+2.2%)1
SLstream latency104 (+20%) 🔻141 🔴 (+25%) 🔻248 🔴 (+98%) 🔻4139 🔴 (+2815%) 🔻30
SOstream overhead (text)133 (+21%) 🔻254 🔴 (+35%) 🔻330 (+56%) 🔻587 (+146%) 🔻30
SOstream overhead (structured)114 (+2.7%)211 (+30%) 🔻302 (+63%) 🔻813 (+230%) 🔻30

77ce876

Mon, 10 Aug 2026 23:17:12 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1322 (+291%) 🔻1401 🔴 (+25%) 🔻1419 🔴 (+24%) 🔻1531 🔴 (±0%)30
TTFSstream997 (±0%)1398 🔴 (+34%) 🔻1415 🔴 (+34%) 🔻1475 🔴 (+36%) 🔻30
TTFShook + stream453 (-64%) 💚1656 🔴 (+19%) 🔻1691 🔴 (+15%)1823 🔴 (+8.3%)30
STSO1020 steps (inline)103 (+8.4%)134 (-11%)151 (-16%) 💚267 (-25%) 💚1019
WO1020 steps135938 (-8.2%)135938 (-8.2%)135938 (-8.2%)135938 (-8.2%)1
SLstream latency94 (+9.3%)129 🔴 (-0.8%)191 🔴 (+9.1%)422 🔴 (+94%) 🔻30
SOstream overhead (text)118 (+28%) 🔻209 (+12%)223 (+4.2%)330 (+31%) 🔻30
SOstream overhead (structured)120 (+17%) 🔻177 (+13%)284 (+47%) 🔻423 (+75%) 🔻30

06bd237

Mon, 10 Aug 2026 21:45:13 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1294 (+525%) 🔻1356 🔴 (+25%) 🔻1372 🔴 (+22%) 🔻1514 🔴 (-6.5%)30
TTFSstream1292 (+576%) 🔻1344 🔴 (+25%) 🔻1352 🔴 (+24%) 🔻1368 🔴 (+23%) 🔻30
TTFShook + stream1555 (+69%) 🔻1654 🔴 (+21%) 🔻1705 🔴 (+16%) 🔻1800 🔴 (+18%) 🔻30
STSO1020 steps (inline)101 (+31%) 🔻126 (-14%)143 (-19%) 💚245 (-51%) 💚1019
WO1020 steps127611 (-17%) 💚127611 (-17%) 💚127611 (-17%) 💚127611 (-17%) 💚1
SLstream latency88 (+2.3%)121 🔴 (-18%) 💚137 🔴 (-15%)152 🔴 (-50%) 💚30
SOstream overhead (text)105 (-15%) 💚163 (-39%) 💚229 (-30%) 💚251 (-85%) 💚30
SOstream overhead (structured)112 (+1.8%)163 (-42%) 💚185 (-80%) 💚652 (-68%) 💚30

3da6597

Mon, 10 Aug 2026 21:05:53 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep232 (-76%) 💚1445 🔴 (+20%) 🔻1560 🔴 (+25%) 🔻1871 🔴 (+40%) 🔻30
TTFSstream246 (-5.0%)1423 🔴 (+26%) 🔻1464 🔴 (+23%) 🔻1551 🔴 (+11%)30
TTFShook + stream406 (-5.4%)1665 🔴 (+10%)1684 🔴 (+3.0%)2292 🔴 (+8.4%)30
STSO1020 steps (inline)97 (+5.4%)153 (-6.7%)178 (-7.3%)293 (-32%) 💚1019
WO1020 steps151031 (-5.5%)151031 (-5.5%)151031 (-5.5%)151031 (-5.5%)1
SLstream latency103 (-8.0%)185 🔴 (-2.1%)218 🔴 (-50%) 💚327 🔴 (-44%) 💚30
SOstream overhead (text)150 (+9.5%)326 🔴 (+37%) 🔻504 🔴 (+36%) 🔻3837 🔴 (+205%) 🔻30
SOstream overhead (structured)137 (-9.9%)238 (-8.8%)307 (-19%) 💚343 (-50%) 💚30
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: is main, marks where this run lands, bridges the gap when this run has more samples in a bucket.

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@NathanColosimoNathanColosimo changed the title feat(core): add atomic start hook admission[core] Add atomic start Hook admissionAug 10, 2026
Comment threadpackages/core/src/runtime/start.ts Outdated
@github-actions

github-actionsBot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 Mint-ordered log — 6 fail of 41 total

log=mint-ordered · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted171.0mMISMATCH1
stale-read-equal-step-countscompleted141.0mMISMATCH1
step-vs-step-forkcompleted120msMISMATCH1
step-vs-step-fork-fencedcompleted120msMISMATCH1
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mMISMATCH1
in-flight-before-decision-countedcompleted201.0mok0
in-flight-after-decisionfailed142.0mMISMATCH1
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-mint.txt

🟢 Append-only log — 0 fail of 41 total

log=append-only · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted201.0mok0
stale-read-equal-step-countscompleted141.0mok0
step-vs-step-forkcompleted120msok0
step-vs-step-fork-fencedcompleted120msok0
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mok0
in-flight-before-decision-countedcompleted171.0mok0
in-flight-after-decisioncompleted192.0mok0
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-append-only.txt

Comment threadpackages/core/src/runtime.ts Outdated
if (
(err instanceof WorkflowWorldError ||
WorkflowWorldError.is(err)) &&
!isRetryableWorldError(err)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should this not check for the specific error? This seems like any non-retriable error will surface as a hook admission rejected

@VaguelySeriousVaguelySeriousAug 11, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI: Blocking The new runInput?.startHook !== undefined block sits before the EntityConflictError/RunExpiredError check and before the getWorkflowSetupErrorCode → recordFatalRunError path. EntityConflictError, RunExpiredError, and PreconditionFailedError all extend WorkflowWorldError and all classify as non-retryable, so the prelude shadows every one of them.

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Human pass LGTM. Agent pass coming in a few minutes probably

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI review: blocking issues found

return;
}
} catch (err) {
if (runInput?.startHook !== undefined) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

This prelude sits before the EntityConflictError/RunExpiredError check and before the getWorkflowSetupErrorCode -> recordFatalRunError path. EntityConflictError, RunExpiredError, and PreconditionFailedError all extend WorkflowWorldError and all classify as non-retryable, so err instanceof WorkflowWorldError && !isRetryableWorldError(err) shadows every one of them.

For a world-contract error the pre-existing path writes run_failed; the atomic path logs at ERROR and acks the message. The run stays in pending forever with no terminal event and no retry.

I confirmed this against the driveTurbo harness in this PR (scratch test, not committed):

non-atomic run + WorkflowWorldError{code: WORLD_CONTRACT_ERROR} -> 1 run_failed
atomic-start run + the identical error -> 0 run_failed

A second scratch test showed RunExpiredError in the atomic path now logs error: "Atomic start Hook admission rejected queued candidate" and never logs the pre-existing info: "Run already finished during setup, skipping" — same outcome, wrong severity, misleading message.

The second branch is strictly worse than falling through for contract errors, since the fall-through already stops the retry loop and records the failure. Narrowing the swallow to HookConflictError.is(err) alone fixes the case that matters. If you also want to stop queue retries for non-contract, non-retryable world errors, route those through recordFatalRunError rather than a bare return.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Resolved by 91d04bb. Narrowing to WorkflowWorldError.is(err) && err.code === START_HOOK_ADMISSION_REJECTED fixes it, and using the name-exact .is() rather than instanceof is the right call: subclasses no longer match, so the terminal-run families reach their own branch.

Verified with the same paired scratch tests, run against both heads (45239c8 pre-fix, 91d04bb post-fix):

 45239c8 (pre) 91d04bb (post)
EntityConflictError rejectedLog=true skipLog=false rejectedLog=false skipLog=true
RunExpiredError rejectedLog=true skipLog=false rejectedLog=false skipLog=true
PreconditionFailedError acked 204, no rethrow, no event rethrown (queue retries)

All three assertions fail on the pre-fix head and pass here, so the fix is load-bearing rather than incidental. PreconditionFailedError was the clearest strand: previously acked with neither a terminal event nor a retry.

The two tests added alongside the fix cover the tagged-rejection and unrelated-contract-error cases. Neither covers the terminal-run families, which are what the original prelude actually shadowed. A parametrized case over EntityConflictError/RunExpiredError/PreconditionFailedError asserting the INFO path would keep a future re-widening of this condition from going unnoticed. Note that runtimeLogger.info only reaches a sink when DEBUG matches workflow:runtime:info, so such a test has to set it.

// No probe channel to the target — cannot attest the consumer honors
// `hookInput`, so leave the marker off (fail closed to sequential).
targetHookResumeInputVersion = undefined;
} else {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

The else if (typeof world.streams?.get !== 'function') branch and its comment were deleted here. This fires for every cross-deployment start, with or without hook.

A world with no stream channel now enters healthCheck(), which enqueues a health-check message nobody can answer, then loops world.streams.get(...) -> TypeError -> sleep HEALTH_CHECK_POLL_INTERVAL, 20 times, for the full CROSS_DEPLOYMENT_CAPABILITY_PROBE_TIMEOUT_MS of 2000ms, before falling back to exactly the values the deleted branch set synchronously.

Measured on start.test.ts, same file, base vs head: 34ms -> 8.15s total, with four pre-existing tests each at ~2020ms:

should pass explicit deploymentId from opts to getEncryptionKeyForRun 2022ms
should resolve "latest" to the actual deployment ID via resolveLatestDeploymentId 2015ms
should pass the resolved deployment ID to getEncryptionKeyForRun when using "latest" 2017ms
should not call resolveLatestDeploymentId when a normal deploymentId is provided 2019ms

The guard was added deliberately in 303b6da (#1853). Nothing in the PR body explains removing it, so this reads as collateral from the refactor.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

Still open on 91d04bb. packages/core/src/runtime/start.ts is unchanged between 45239c8 and this head, and the else if (typeof world.streams?.get !== 'function') branch is still absent while it is present on the PR base (codex/atomic-start-capabilities, 459e34b, line 357).

Re-measured start.test.ts on both, same install and same build:

459e34b (PR base) 58 tests 36ms of test time
91d04bb (this head) 72 tests 8.16s of test time

with the same four pre-existing tests pinned at the probe timeout:

✓ should pass explicit deploymentId from opts to getEncryptionKeyForRun 2021ms
✓ should resolve "latest" to the actual deployment ID via resolveLatestDeploymentId 2023ms
✓ should pass the resolved deployment ID to getEncryptionKeyForRun when using "latest" 2029ms
✓ should not call resolveLatestDeploymentId when a normal deploymentId is provided 2029ms

None of those tests involve hook. Every cross-deployment start on a World without a stream channel now pays 2s and one undeliverable queue write to arrive at the values the deleted branch set synchronously. The suite passes, so CI will not catch it.

specVersion: _specVersion,
...data
} = runInput;
runCreationData = data;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Behavior change that applies to every run, not just atomic-start ones.

Replacing the explicit six-field pick with {...runInput} minus environment/specVersion means run_started now also carries encryptionPublicKey on the resilient-start path. Verified empirically by porting the new assertion on line 2348 back to the base branch:

base: expected undefined to be 'test-public-key'
head: passes

This looks like a fix, and it matches what the (now-deleted) RunStartedEventSchema comment described: on the resilient path the run is created from this event, and without the key it silently loses the ability to receive sealed writes. But it ships unannounced — the changeset doesn't mention it, and the only coverage is an added assertion inside a test named for turbo optimistic start. Worth its own changeset line and a test that names the behavior, so a future refactor doesn't drop it again.

The spread also means any field added to RunInput later is auto-forwarded into run_started rather than opted in. That's the mechanism that just quietly changed the payload here.

deploymentId: z.string(),
workflowName: z.string(),
export const RunInputSchema = RunCreationDataSchema.extend({
specVersion: z.number(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

RunInputSchema moving from a standalone object to RunCreationDataSchema.extend(...) changes input from optional to required at the type level. z.unknown() infers input?: unknown; SerializedDataSchema infers input: unknown.

Verified both halves:

  • Type: constructing a RunInput without input now fails with TS2741: Property 'input' is missing.
  • Runtime: RunInputSchema.safeParse({deploymentId, workflowName, specVersion}) still succeeds, so no parse behavior changed.

RunInputSchema and RunInput are public exports of @workflow/world, so this is a source-breaking type change for external consumers (community worlds, test fixtures) riding a minor bump. Either restore optionality with .partial({ input: true })-style handling, or call it out in the changeset.

}).catch(() => undefined);
if (
startHook &&
probe?.capabilities?.atomicStartHook?.active !== true

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

healthCheck(...).catch(() => undefined) collapses a network blip, a slow target, and a genuinely old deployment into the same probe === undefined, which then throws WORLD_CONTRACT_ERROR: "The target deployment does not support atomic start Hooks."

A transient probe failure is exactly the uncertainty WorkflowStartError was added for. Classifying it as a deterministic contract error tells the caller not to retry something that is retryable, and the message points at a capability gap that may not exist. Worth distinguishing "probe returned and said no" from "probe did not return".

meta.executionContext = input.executionContext;
}
if (input.attributes !== undefined) meta.attributes = input.attributes;
if (input.startHook !== undefined) meta.startHook = input.startHook;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

The v4 meta contract is two-sided: a field only survives if the receiving parser also knows it, and unknown meta keys are dropped silently rather than rejected. I checked the receiving side and it has no startHook handling today.

Inert on merge because no world advertises atomicStartHook, but the world-enabling PR has to land both halves together or admission data vanishes on the wire with no error. Worth a note in the follow-up PR description so it isn't discovered at E2E time.

(error as Error & { cause?: unknown }).cause = value.cause;
}
return error;
return makeWebError(value.name, value);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Unrelated to startHook, and it changes every hydrated error in the o11y UI, not just the new one.

The old base Error reviver assigned error.cause = value.cause after construction; makeWebError passes {cause} to the constructor instead. InstallErrorCause defines cause as non-enumerable, a plain assignment defines it as enumerable:

old: {"writable":true,"enumerable":true,"configurable":true} Object.keys -> ['name','cause']
new: {"writable":true,"enumerable":false,"configurable":true} Object.keys -> ['name']

Anything that enumerates own properties (spread, Object.keys, JSON.stringify) stops seeing cause. This is arguably a consistency fix, since FatalError and the other revivers already used the constructor form. Flagging it because it's a silent semantic change in a shared hydration path and nothing in the PR calls it out.

);
}
// Pin the run to the VM engine selected when it starts.
const workflowVm = getWorkflowVmFromEnv();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Two ordering changes here apply to all starts, not just atomic ones. Both look like improvements; noting them because neither is mentioned and both change observable behavior:

  1. Attribute/lineage/replayedFromRunId/WORKFLOW_VM validation now runs before world.getDeploymentId(). When both would fail, the validation error now wins where the deployment lookup used to.
  2. safeWaitUntil(Promise.all(ops), ...) moved ahead of admission, so the stream flush is registered even if the code between the old and new positions throws.

attributes: z.record(z.string(), z.string()).optional(),
allowReservedAttributes: z.literal(true).optional(),
startHook: StartHookSchema.optional(),
/** Public key used by cross-run writers to seal payloads to this run. */

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

The extraction into RunCreationDataSchema dropped several accurate explanatory comments: the X25519/sealed-envelope rationale on run_created.encryptionPublicKey, the resilient-start rationale on run_started.encryptionPublicKey, and in queue.ts the allowReservedAttributes mirroring note and "Initial plaintext run attributes, for resilient run creation".

start.ts lost a similar batch in the same refactor: the "<=1% of cases" note on the 409 branch, the 429/5xx/transport rationale on the retryable branch, "Queue failure is always fatal", the per-region queue routing note on opts.region, and the WORKFLOW_VM pinning note.

None of it was stale — it documents current behavior, which is what the repo guidance asks comments to do. Worth carrying forward into the new shapes.

}

function normalizeStartHook(options: StartHookOptions): StartHook {
if (options.token.length === 0) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Nit

options.token.length === 0 doesn't reject a non-string. A JS caller passing a number gets undefined === 0 -> false and the value flows through to the world. typeof options.token !== 'string' || options.token.length === 0 matches what the doc comment on line 111 already promises.

@VaguelySerious

Copy link
Copy Markdown
Member

AI Review: Note

Not anchorable inline since these files aren't in the diff. Four docs pages still describe the workaround this PR is replacing, each with the phrase "until native atomic start-and-hook registration exists":

  • docs/content/docs/v4/api-reference/workflow-api/start.mdx
  • docs/content/docs/v4/api-reference/workflow-api/get-hook-by-token.mdx
  • docs/content/docs/v5/api-reference/workflow-api/start.mdx
  • docs/content/docs/v5/api-reference/workflow-api/get-hook-by-token.mdx

Fine to leave while the capability is inert everywhere, but hook is a user-facing start() option landing in this PR and the repo guidance is that user-facing changes are documented in docs. Worth confirming #3440 covers all four, or adding a short "experimental, requires a world that advertises the capability" line here.

@vercelvercelBot 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:

Removal of the world.streams?.get guard in the cross-deployment start path forces every streams-less world (minimal worlds / test mocks) through healthCheck(), which busy-loops until the 2s probe timeout and leaves an orphaned health-check queue message.

Fix on Vercel

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI review: blocking issues found

}
if (
WorkflowWorldError.is(err) &&
err.code === START_HOOK_ADMISSION_REJECTED

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Follow-up on the narrowed condition (which is the right fix). The escape hatch only fires when the World tags the rejection, and no shipped World sets START_HOOK_ADMISSION_REJECTED today. The constant appears in exactly three places: its declaration in @workflow/errors, this check, and the prose contract on Storage.events.create. No implementation produces it.

So for any World that rejects admission with a plain 400 — the shape a validation failure naturally takes — the error falls past this check, past the EntityConflictError/RunExpiredError branch, and into getWorkflowSetupErrorCode, which returns null because isWorldContractError requires the absence of a status. That rethrows, and the queue retries a permanently-invalid request forever.

Scratch test on this head:

atomic start + WorkflowWorldError{status: 400} -> threw WorkflowWorldError, runFailed=0
atomic start + WorkflowWorldError{status: 400, code: START_HOOK_ADMISSION_REJECTED} -> acked 204, runFailed=0

Pre-existing behavior for 400s in general, so not a regression. Flagging it because this PR introduces a new category of permanent 400 and the handling for it is inert until a World opts in. Worth stating in the PR body that the code is a contract for World implementors to adopt, so the follow-up doesn't ship admission rejection without it.

@VaguelySerious

Copy link
Copy Markdown
Member

AI Review: Note

Status of the earlier findings on 91d04bb. The head was force-pushed to a single squashed commit on the same base; the only files that changed since the last review are runtime.ts, errors/src/index.ts, world/src/interfaces.ts, and two test files.

Addressed:

  • The atomic prelude shadowing terminal-run and contract errors. Details and before/after evidence in the thread.

Still open, all re-verified on this head:

  • The deleted world.streams?.get short-circuit in start.ts. Re-measured, thread updated.
  • run_started now carrying encryptionPublicKey on every resilient start, unannounced.
  • RunInput['input'] still required rather than optional: TS2741: Property 'input' is missing ... but required.
  • cause still installed non-enumerably by makeWebError.
  • The probe-timeout / capability-gap conflation in the cross-deployment branch.
  • Validation hoisted above getDeploymentId(), and safeWaitUntil moved ahead of admission.
  • The deleted explanatory comments in events.ts, queue.ts, and start.ts.
  • The token.length === 0 check still admits a non-string.
  • All four docs pages still carry "until native atomic start-and-hook registration exists".

No reply on any of those threads yet, so I have left them as-is rather than reposting.

CI on this head is green apart from Vercel – workbench-python-workflow, which fails on main and is unrelated.

Sign up for freeto 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

@NathanColosimo@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [core] Add atomic start Hook admission by NathanColosimo · Pull Request #3426 · vercel/workflow · GitHub
Skip to content

[core] Add atomic start Hook admission - #3426

Open
NathanColosimo wants to merge 1 commit into
codex/atomic-start-capabilitiesfrom
codex/atomic-start-core
Open

[core] Add atomic start Hook admission#3426
NathanColosimo wants to merge 1 commit into
codex/atomic-start-capabilitiesfrom
codex/atomic-start-core

Conversation

@NathanColosimo

@NathanColosimoNathanColosimo commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

API

construn=awaitstart(processOrder,[orderId],{hook: {token: `order:${orderId}`,experimental_minRetention: "30 days",},});

A successful call returns the existing Run type. A duplicate throws HookConflictError with conflictingRunId. If queueing or admission cannot be confirmed, WorkflowStartError identifies the candidate run and uncertain stage.

Implementation

start() queues the candidate first, then calls events.create() to atomically reserve the token and create the run. The direct event and durable queue input carry the same normalized Hook data, so either path can complete admission. A queued loser exits before creating a run or executing user code.

The caller World and a cross-deployment target must advertise atomicStartHook. Requests with experimental_minRetention must also advertise hookRetention. This PR defines the public API, wire format, runtime behavior, and World contract; no World advertises support yet.

Plan

  1. [world] Expose capabilities in deployment health checks #3425 exposes World capabilities locally and through cross-deployment health checks.
  2. This PR defines atomic start Hook admission.
  3. Follow-up Local, Postgres, and Vercel PRs implement the atomic World operation and progressively enable one shared E2E test.
  4. [docs] Document atomic start Hook admission #3440 documents the feature and stays draft until a World implementation is ready.

@changeset-bot

changeset-botBot commented Aug 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 91d04bb

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

This PR includes changesets to release 21 packages
NameType
workflowMinor
@workflow/coreMinor
@workflow/errorsMinor
@workflow/worldMinor
@workflow/cliPatch
@workflow/web-sharedPatch
@workflow/world-vercelPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/webPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

vercelBot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

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

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewAug 12, 2026 12:03am
example-nextjs-workflow-webpackReadyReadyPreviewAug 12, 2026 12:03am
example-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-astro-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-express-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-fastify-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-hono-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-nestjs-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-nitro-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-nuxt-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-python-workflowErrorErrorAug 12, 2026 12:03am
workbench-sveltekit-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-tanstack-start-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-vite-workflowReadyReadyPreviewAug 12, 2026 12:03am
workflow-docsReadyReadyPreview, v0Aug 12, 2026 12:03am
workflow-swc-playgroundReadyReadyPreviewAug 12, 2026 12:03am
workflow-tarballsReadyReadyPreviewAug 12, 2026 12:03am
workflow-webReadyReadyPreviewAug 12, 2026 12:03am

@github-actions

github-actionsBot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production346605904056
✅ 💻 Local Development381005584368
✅ 📦 Local Production381005584368
✅ 🐘 Local Postgres381005584368
✅ 🪟 Windows31200312
✅ vercel-multi-region270027
Total152350226417499
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node128028
✅ astro-quickjs128028
✅ example-node128028
✅ example-quickjs128028
✅ express-node128028
✅ express-quickjs128028
✅ fastify-node128028
✅ fastify-quickjs128028
✅ hono-node128028
✅ hono-quickjs128028
✅ nest-node128028
✅ nest-quickjs128028
✅ nextjs-turbopack-node15303
✅ nextjs-turbopack-quickjs15303
✅ nextjs-webpack-node15303
✅ nextjs-webpack-quickjs15303
✅ nitro-node128028
✅ nitro-quickjs128028
✅ nuxt-node128028
✅ nuxt-quickjs128028
✅ sveltekit-node14709
✅ sveltekit-quickjs14709
✅ tanstack-start-node128028
✅ tanstack-start-quickjs128028
✅ vite-node128028
✅ vite-quickjs128028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack-node15600
✅ nextjs-turbopack-quickjs15600

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@github-actions

github-actionsBot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 91d04bb · Wed, 12 Aug 2026 00:17:07 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep324 (±0%)1369 🔴 (+30%) 🔻1393 🔴 (+24%) 🔻1446 🔴 (+3.4%)30
TTFSstream1303 (+719%) 🔻1363 🔴 (+27%) 🔻1378 🔴 (+26%) 🔻1450 🔴 (+17%) 🔻30
TTFShook + stream1569 (+17%) 🔻1643 🔴 (+13%)1661 🔴 (+11%)1688 🔴 (+7.4%)30
STSO1020 steps (inline)114 (-10%)159 (-16%) 💚183 (-15%) 💚491 (+16%) 🔻1019
WO1020 steps168782 (-8.4%)168782 (-8.4%)168782 (-8.4%)168782 (-8.4%)1
SLstream latency84 (-3.4%)105 🔴 (-17%) 💚122 🔴 (-11%)179 🔴 (-63%) 💚30
SOstream overhead (text)100 (-7.4%)149 (-26%) 💚160 (-49%) 💚190 (-60%) 💚30
SOstream overhead (structured)110 (+2.8%)146 (-18%) 💚160 (-33%) 💚203 (-54%) 💚30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 183209ms → this run 167452ms (Δ -15757ms, -9%)

 100-150 ms ██████░░░░░░░░░░░░░░░┃ main 168 this 606 +438
150-200 ms ███████████┃████████████ main 675 this 346 -329
200-250 ms ┃████ main 129 this 31 -98
250-300 ms ┃ main 23 this 10 -13
300-350 ms ┃ main 8 this 7 -1
350-400 ms ┃ main 3 this 7 +4
400-450 ms ┃ main 5 this 1 -4
450-500 ms ┃ main 3 this 1 -2
500-550 ms ┃ main 1 this 4 +3
550-600 ms ┃ main 0 this 1 +1
650-700 ms ┃ main 1 this 0 -1
800-850 ms ┃ main 1 this 0 -1
850-900 ms ┃ main 1 this 1 +0
900-950 ms ┃ main 1 this 0 -1
1000-1050 ms ┃ main 0 this 1 +1
1100-1150 ms ┃ main 0 this 1 +1
1650-1700 ms ┃ main 0 this 1 +1
4600-4650 ms ┃ main 0 this 1 +1
📜 Previous results (7)

45239c8

Tue, 11 Aug 2026 21:59:50 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep220 (-32%) 💚1404 🔴 (+33%) 🔻1449 🔴 (+29%) 🔻1799 🔴 (+29%) 🔻30
TTFSstream224 (+41%) 🔻1378 🔴 (+28%) 🔻1394 🔴 (+28%) 🔻1451 🔴 (+17%) 🔻30
TTFShook + stream411 (-69%) 💚1665 🔴 (+14%)1754 🔴 (+17%) 🔻1846 🔴 (+18%) 🔻30
STSO1020 steps (inline)136 (+7.1%)213 (+13%)249 (+15%) 🔻442 (+4.5%)1019
WO1020 steps213097 (+16%) 🔻213097 (+16%) 🔻213097 (+16%) 🔻213097 (+16%) 🔻1
SLstream latency100 (+15%)171 🔴 (+35%) 🔻192 🔴 (+40%) 🔻980 🔴 (+102%) 🔻30
SOstream overhead (text)131 (+21%) 🔻273 🔴 (+36%) 🔻420 (+34%) 🔻913 (+92%) 🔻30
SOstream overhead (structured)142 (+33%) 🔻264 🔴 (+49%) 🔻448 (+87%) 🔻977 (+122%) 🔻30

adf58eb

Tue, 11 Aug 2026 05:11:23 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1333 (+26%) 🔻1498 🔴 (+27%) 🔻1566 🔴 (+30%) 🔻1860 🔴 (+8.8%)30
TTFSstream1420 (+36%) 🔻1496 🔴 (+31%) 🔻1545 🔴 (+33%) 🔻1598 🔴 (+32%) 🔻30
TTFShook + stream1638 (+31%) 🔻1797 🔴 (+31%) 🔻1832 🔴 (+31%) 🔻2111 🔴 (+40%) 🔻30
STSO1020 steps (inline)115 (+20%) 🔻153 (-6.7%)169 (-17%) 💚253 (-67%) 💚1019
WO1020 steps154672 (-9.8%)154672 (-9.8%)154672 (-9.8%)154672 (-9.8%)1
SLstream latency119 (+31%) 🔻140 🔴 (-9.1%)170 🔴 (-1.2%)362 🔴 (-35%) 💚30
SOstream overhead (text)134 (+1.5%)214 (-22%) 💚224 (-32%) 💚290 (-63%) 💚30
SOstream overhead (structured)133 (+9.0%)187 (-24%) 💚209 (-33%) 💚319 (-29%) 💚30

7825269

Tue, 11 Aug 2026 00:39:16 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep224 (-73%) 💚1512 🔴 (+36%) 🔻1522 🔴 (+33%) 🔻1758 🔴 (+49%) 🔻30
TTFSstream246 (+19%) 🔻1487 🔴 (+35%) 🔻1505 🔴 (+36%) 🔻1534 🔴 (+33%) 🔻30
TTFShook + stream401 (-68%) 💚1674 🔴 (+21%) 🔻1742 🔴 (+22%) 🔻1808 🔴 (+17%) 🔻30
STSO1020 steps (inline)103 (+2.0%)153 (+3.4%)178 (+4.7%)298 (-0.7%)1019
WO1020 steps149862 (+5.7%)149862 (+5.7%)149862 (+5.7%)149862 (+5.7%)1
SLstream latency98 (+13%)193 🔴 (+71%) 🔻446 🔴 (+257%) 🔻1484 🔴 (+945%) 🔻30
SOstream overhead (text)122 (+11%)250 (+33%) 🔻376 (+77%) 🔻1304 🔴 (+446%) 🔻30
SOstream overhead (structured)124 (+12%)231 (+43%) 🔻343 (+85%) 🔻864 (+251%) 🔻30

f9614bf

Mon, 10 Aug 2026 23:58:39 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1220 (+45%) 🔻1308 🔴 (+18%) 🔻1332 🔴 (+17%) 🔻1394 🔴 (+18%) 🔻30
TTFSstream1213 (+486%) 🔻1384 🔴 (+26%) 🔻1404 🔴 (+27%) 🔻1434 🔴 (+24%) 🔻30
TTFShook + stream1448 (+16%) 🔻1646 🔴 (+19%) 🔻1750 🔴 (+23%) 🔻1822 🔴 (+18%) 🔻30
STSO1020 steps (inline)96 (-5.0%)143 (-3.4%)162 (-4.7%)398 (+33%) 🔻1019
WO1020 steps144978 (+2.2%)144978 (+2.2%)144978 (+2.2%)144978 (+2.2%)1
SLstream latency104 (+20%) 🔻141 🔴 (+25%) 🔻248 🔴 (+98%) 🔻4139 🔴 (+2815%) 🔻30
SOstream overhead (text)133 (+21%) 🔻254 🔴 (+35%) 🔻330 (+56%) 🔻587 (+146%) 🔻30
SOstream overhead (structured)114 (+2.7%)211 (+30%) 🔻302 (+63%) 🔻813 (+230%) 🔻30

77ce876

Mon, 10 Aug 2026 23:17:12 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1322 (+291%) 🔻1401 🔴 (+25%) 🔻1419 🔴 (+24%) 🔻1531 🔴 (±0%)30
TTFSstream997 (±0%)1398 🔴 (+34%) 🔻1415 🔴 (+34%) 🔻1475 🔴 (+36%) 🔻30
TTFShook + stream453 (-64%) 💚1656 🔴 (+19%) 🔻1691 🔴 (+15%)1823 🔴 (+8.3%)30
STSO1020 steps (inline)103 (+8.4%)134 (-11%)151 (-16%) 💚267 (-25%) 💚1019
WO1020 steps135938 (-8.2%)135938 (-8.2%)135938 (-8.2%)135938 (-8.2%)1
SLstream latency94 (+9.3%)129 🔴 (-0.8%)191 🔴 (+9.1%)422 🔴 (+94%) 🔻30
SOstream overhead (text)118 (+28%) 🔻209 (+12%)223 (+4.2%)330 (+31%) 🔻30
SOstream overhead (structured)120 (+17%) 🔻177 (+13%)284 (+47%) 🔻423 (+75%) 🔻30

06bd237

Mon, 10 Aug 2026 21:45:13 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1294 (+525%) 🔻1356 🔴 (+25%) 🔻1372 🔴 (+22%) 🔻1514 🔴 (-6.5%)30
TTFSstream1292 (+576%) 🔻1344 🔴 (+25%) 🔻1352 🔴 (+24%) 🔻1368 🔴 (+23%) 🔻30
TTFShook + stream1555 (+69%) 🔻1654 🔴 (+21%) 🔻1705 🔴 (+16%) 🔻1800 🔴 (+18%) 🔻30
STSO1020 steps (inline)101 (+31%) 🔻126 (-14%)143 (-19%) 💚245 (-51%) 💚1019
WO1020 steps127611 (-17%) 💚127611 (-17%) 💚127611 (-17%) 💚127611 (-17%) 💚1
SLstream latency88 (+2.3%)121 🔴 (-18%) 💚137 🔴 (-15%)152 🔴 (-50%) 💚30
SOstream overhead (text)105 (-15%) 💚163 (-39%) 💚229 (-30%) 💚251 (-85%) 💚30
SOstream overhead (structured)112 (+1.8%)163 (-42%) 💚185 (-80%) 💚652 (-68%) 💚30

3da6597

Mon, 10 Aug 2026 21:05:53 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep232 (-76%) 💚1445 🔴 (+20%) 🔻1560 🔴 (+25%) 🔻1871 🔴 (+40%) 🔻30
TTFSstream246 (-5.0%)1423 🔴 (+26%) 🔻1464 🔴 (+23%) 🔻1551 🔴 (+11%)30
TTFShook + stream406 (-5.4%)1665 🔴 (+10%)1684 🔴 (+3.0%)2292 🔴 (+8.4%)30
STSO1020 steps (inline)97 (+5.4%)153 (-6.7%)178 (-7.3%)293 (-32%) 💚1019
WO1020 steps151031 (-5.5%)151031 (-5.5%)151031 (-5.5%)151031 (-5.5%)1
SLstream latency103 (-8.0%)185 🔴 (-2.1%)218 🔴 (-50%) 💚327 🔴 (-44%) 💚30
SOstream overhead (text)150 (+9.5%)326 🔴 (+37%) 🔻504 🔴 (+36%) 🔻3837 🔴 (+205%) 🔻30
SOstream overhead (structured)137 (-9.9%)238 (-8.8%)307 (-19%) 💚343 (-50%) 💚30
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: is main, marks where this run lands, bridges the gap when this run has more samples in a bucket.

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@NathanColosimoNathanColosimo changed the title feat(core): add atomic start hook admission[core] Add atomic start Hook admissionAug 10, 2026
Comment threadpackages/core/src/runtime/start.ts Outdated
@github-actions

github-actionsBot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 Mint-ordered log — 6 fail of 41 total

log=mint-ordered · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted171.0mMISMATCH1
stale-read-equal-step-countscompleted141.0mMISMATCH1
step-vs-step-forkcompleted120msMISMATCH1
step-vs-step-fork-fencedcompleted120msMISMATCH1
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mMISMATCH1
in-flight-before-decision-countedcompleted201.0mok0
in-flight-after-decisionfailed142.0mMISMATCH1
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-mint.txt

🟢 Append-only log — 0 fail of 41 total

log=append-only · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted201.0mok0
stale-read-equal-step-countscompleted141.0mok0
step-vs-step-forkcompleted120msok0
step-vs-step-fork-fencedcompleted120msok0
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mok0
in-flight-before-decision-countedcompleted171.0mok0
in-flight-after-decisioncompleted192.0mok0
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-append-only.txt

Comment threadpackages/core/src/runtime.ts Outdated
if (
(err instanceof WorkflowWorldError ||
WorkflowWorldError.is(err)) &&
!isRetryableWorldError(err)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should this not check for the specific error? This seems like any non-retriable error will surface as a hook admission rejected

@VaguelySeriousVaguelySeriousAug 11, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI: Blocking The new runInput?.startHook !== undefined block sits before the EntityConflictError/RunExpiredError check and before the getWorkflowSetupErrorCode → recordFatalRunError path. EntityConflictError, RunExpiredError, and PreconditionFailedError all extend WorkflowWorldError and all classify as non-retryable, so the prelude shadows every one of them.

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Human pass LGTM. Agent pass coming in a few minutes probably

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI review: blocking issues found

return;
}
} catch (err) {
if (runInput?.startHook !== undefined) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

This prelude sits before the EntityConflictError/RunExpiredError check and before the getWorkflowSetupErrorCode -> recordFatalRunError path. EntityConflictError, RunExpiredError, and PreconditionFailedError all extend WorkflowWorldError and all classify as non-retryable, so err instanceof WorkflowWorldError && !isRetryableWorldError(err) shadows every one of them.

For a world-contract error the pre-existing path writes run_failed; the atomic path logs at ERROR and acks the message. The run stays in pending forever with no terminal event and no retry.

I confirmed this against the driveTurbo harness in this PR (scratch test, not committed):

non-atomic run + WorkflowWorldError{code: WORLD_CONTRACT_ERROR} -> 1 run_failed
atomic-start run + the identical error -> 0 run_failed

A second scratch test showed RunExpiredError in the atomic path now logs error: "Atomic start Hook admission rejected queued candidate" and never logs the pre-existing info: "Run already finished during setup, skipping" — same outcome, wrong severity, misleading message.

The second branch is strictly worse than falling through for contract errors, since the fall-through already stops the retry loop and records the failure. Narrowing the swallow to HookConflictError.is(err) alone fixes the case that matters. If you also want to stop queue retries for non-contract, non-retryable world errors, route those through recordFatalRunError rather than a bare return.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Resolved by 91d04bb. Narrowing to WorkflowWorldError.is(err) && err.code === START_HOOK_ADMISSION_REJECTED fixes it, and using the name-exact .is() rather than instanceof is the right call: subclasses no longer match, so the terminal-run families reach their own branch.

Verified with the same paired scratch tests, run against both heads (45239c8 pre-fix, 91d04bb post-fix):

 45239c8 (pre) 91d04bb (post)
EntityConflictError rejectedLog=true skipLog=false rejectedLog=false skipLog=true
RunExpiredError rejectedLog=true skipLog=false rejectedLog=false skipLog=true
PreconditionFailedError acked 204, no rethrow, no event rethrown (queue retries)

All three assertions fail on the pre-fix head and pass here, so the fix is load-bearing rather than incidental. PreconditionFailedError was the clearest strand: previously acked with neither a terminal event nor a retry.

The two tests added alongside the fix cover the tagged-rejection and unrelated-contract-error cases. Neither covers the terminal-run families, which are what the original prelude actually shadowed. A parametrized case over EntityConflictError/RunExpiredError/PreconditionFailedError asserting the INFO path would keep a future re-widening of this condition from going unnoticed. Note that runtimeLogger.info only reaches a sink when DEBUG matches workflow:runtime:info, so such a test has to set it.

// No probe channel to the target — cannot attest the consumer honors
// `hookInput`, so leave the marker off (fail closed to sequential).
targetHookResumeInputVersion = undefined;
} else {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

The else if (typeof world.streams?.get !== 'function') branch and its comment were deleted here. This fires for every cross-deployment start, with or without hook.

A world with no stream channel now enters healthCheck(), which enqueues a health-check message nobody can answer, then loops world.streams.get(...) -> TypeError -> sleep HEALTH_CHECK_POLL_INTERVAL, 20 times, for the full CROSS_DEPLOYMENT_CAPABILITY_PROBE_TIMEOUT_MS of 2000ms, before falling back to exactly the values the deleted branch set synchronously.

Measured on start.test.ts, same file, base vs head: 34ms -> 8.15s total, with four pre-existing tests each at ~2020ms:

should pass explicit deploymentId from opts to getEncryptionKeyForRun 2022ms
should resolve "latest" to the actual deployment ID via resolveLatestDeploymentId 2015ms
should pass the resolved deployment ID to getEncryptionKeyForRun when using "latest" 2017ms
should not call resolveLatestDeploymentId when a normal deploymentId is provided 2019ms

The guard was added deliberately in 303b6da (#1853). Nothing in the PR body explains removing it, so this reads as collateral from the refactor.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

Still open on 91d04bb. packages/core/src/runtime/start.ts is unchanged between 45239c8 and this head, and the else if (typeof world.streams?.get !== 'function') branch is still absent while it is present on the PR base (codex/atomic-start-capabilities, 459e34b, line 357).

Re-measured start.test.ts on both, same install and same build:

459e34b (PR base) 58 tests 36ms of test time
91d04bb (this head) 72 tests 8.16s of test time

with the same four pre-existing tests pinned at the probe timeout:

✓ should pass explicit deploymentId from opts to getEncryptionKeyForRun 2021ms
✓ should resolve "latest" to the actual deployment ID via resolveLatestDeploymentId 2023ms
✓ should pass the resolved deployment ID to getEncryptionKeyForRun when using "latest" 2029ms
✓ should not call resolveLatestDeploymentId when a normal deploymentId is provided 2029ms

None of those tests involve hook. Every cross-deployment start on a World without a stream channel now pays 2s and one undeliverable queue write to arrive at the values the deleted branch set synchronously. The suite passes, so CI will not catch it.

specVersion: _specVersion,
...data
} = runInput;
runCreationData = data;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Behavior change that applies to every run, not just atomic-start ones.

Replacing the explicit six-field pick with {...runInput} minus environment/specVersion means run_started now also carries encryptionPublicKey on the resilient-start path. Verified empirically by porting the new assertion on line 2348 back to the base branch:

base: expected undefined to be 'test-public-key'
head: passes

This looks like a fix, and it matches what the (now-deleted) RunStartedEventSchema comment described: on the resilient path the run is created from this event, and without the key it silently loses the ability to receive sealed writes. But it ships unannounced — the changeset doesn't mention it, and the only coverage is an added assertion inside a test named for turbo optimistic start. Worth its own changeset line and a test that names the behavior, so a future refactor doesn't drop it again.

The spread also means any field added to RunInput later is auto-forwarded into run_started rather than opted in. That's the mechanism that just quietly changed the payload here.

deploymentId: z.string(),
workflowName: z.string(),
export const RunInputSchema = RunCreationDataSchema.extend({
specVersion: z.number(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

RunInputSchema moving from a standalone object to RunCreationDataSchema.extend(...) changes input from optional to required at the type level. z.unknown() infers input?: unknown; SerializedDataSchema infers input: unknown.

Verified both halves:

  • Type: constructing a RunInput without input now fails with TS2741: Property 'input' is missing.
  • Runtime: RunInputSchema.safeParse({deploymentId, workflowName, specVersion}) still succeeds, so no parse behavior changed.

RunInputSchema and RunInput are public exports of @workflow/world, so this is a source-breaking type change for external consumers (community worlds, test fixtures) riding a minor bump. Either restore optionality with .partial({ input: true })-style handling, or call it out in the changeset.

}).catch(() => undefined);
if (
startHook &&
probe?.capabilities?.atomicStartHook?.active !== true

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

healthCheck(...).catch(() => undefined) collapses a network blip, a slow target, and a genuinely old deployment into the same probe === undefined, which then throws WORLD_CONTRACT_ERROR: "The target deployment does not support atomic start Hooks."

A transient probe failure is exactly the uncertainty WorkflowStartError was added for. Classifying it as a deterministic contract error tells the caller not to retry something that is retryable, and the message points at a capability gap that may not exist. Worth distinguishing "probe returned and said no" from "probe did not return".

meta.executionContext = input.executionContext;
}
if (input.attributes !== undefined) meta.attributes = input.attributes;
if (input.startHook !== undefined) meta.startHook = input.startHook;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

The v4 meta contract is two-sided: a field only survives if the receiving parser also knows it, and unknown meta keys are dropped silently rather than rejected. I checked the receiving side and it has no startHook handling today.

Inert on merge because no world advertises atomicStartHook, but the world-enabling PR has to land both halves together or admission data vanishes on the wire with no error. Worth a note in the follow-up PR description so it isn't discovered at E2E time.

(error as Error & { cause?: unknown }).cause = value.cause;
}
return error;
return makeWebError(value.name, value);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Unrelated to startHook, and it changes every hydrated error in the o11y UI, not just the new one.

The old base Error reviver assigned error.cause = value.cause after construction; makeWebError passes {cause} to the constructor instead. InstallErrorCause defines cause as non-enumerable, a plain assignment defines it as enumerable:

old: {"writable":true,"enumerable":true,"configurable":true} Object.keys -> ['name','cause']
new: {"writable":true,"enumerable":false,"configurable":true} Object.keys -> ['name']

Anything that enumerates own properties (spread, Object.keys, JSON.stringify) stops seeing cause. This is arguably a consistency fix, since FatalError and the other revivers already used the constructor form. Flagging it because it's a silent semantic change in a shared hydration path and nothing in the PR calls it out.

);
}
// Pin the run to the VM engine selected when it starts.
const workflowVm = getWorkflowVmFromEnv();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Two ordering changes here apply to all starts, not just atomic ones. Both look like improvements; noting them because neither is mentioned and both change observable behavior:

  1. Attribute/lineage/replayedFromRunId/WORKFLOW_VM validation now runs before world.getDeploymentId(). When both would fail, the validation error now wins where the deployment lookup used to.
  2. safeWaitUntil(Promise.all(ops), ...) moved ahead of admission, so the stream flush is registered even if the code between the old and new positions throws.

attributes: z.record(z.string(), z.string()).optional(),
allowReservedAttributes: z.literal(true).optional(),
startHook: StartHookSchema.optional(),
/** Public key used by cross-run writers to seal payloads to this run. */

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

The extraction into RunCreationDataSchema dropped several accurate explanatory comments: the X25519/sealed-envelope rationale on run_created.encryptionPublicKey, the resilient-start rationale on run_started.encryptionPublicKey, and in queue.ts the allowReservedAttributes mirroring note and "Initial plaintext run attributes, for resilient run creation".

start.ts lost a similar batch in the same refactor: the "<=1% of cases" note on the 409 branch, the 429/5xx/transport rationale on the retryable branch, "Queue failure is always fatal", the per-region queue routing note on opts.region, and the WORKFLOW_VM pinning note.

None of it was stale — it documents current behavior, which is what the repo guidance asks comments to do. Worth carrying forward into the new shapes.

}

function normalizeStartHook(options: StartHookOptions): StartHook {
if (options.token.length === 0) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Nit

options.token.length === 0 doesn't reject a non-string. A JS caller passing a number gets undefined === 0 -> false and the value flows through to the world. typeof options.token !== 'string' || options.token.length === 0 matches what the doc comment on line 111 already promises.

@VaguelySerious

Copy link
Copy Markdown
Member

AI Review: Note

Not anchorable inline since these files aren't in the diff. Four docs pages still describe the workaround this PR is replacing, each with the phrase "until native atomic start-and-hook registration exists":

  • docs/content/docs/v4/api-reference/workflow-api/start.mdx
  • docs/content/docs/v4/api-reference/workflow-api/get-hook-by-token.mdx
  • docs/content/docs/v5/api-reference/workflow-api/start.mdx
  • docs/content/docs/v5/api-reference/workflow-api/get-hook-by-token.mdx

Fine to leave while the capability is inert everywhere, but hook is a user-facing start() option landing in this PR and the repo guidance is that user-facing changes are documented in docs. Worth confirming #3440 covers all four, or adding a short "experimental, requires a world that advertises the capability" line here.

@vercelvercelBot 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:

Removal of the world.streams?.get guard in the cross-deployment start path forces every streams-less world (minimal worlds / test mocks) through healthCheck(), which busy-loops until the 2s probe timeout and leaves an orphaned health-check queue message.

Fix on Vercel

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI review: blocking issues found

}
if (
WorkflowWorldError.is(err) &&
err.code === START_HOOK_ADMISSION_REJECTED

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Follow-up on the narrowed condition (which is the right fix). The escape hatch only fires when the World tags the rejection, and no shipped World sets START_HOOK_ADMISSION_REJECTED today. The constant appears in exactly three places: its declaration in @workflow/errors, this check, and the prose contract on Storage.events.create. No implementation produces it.

So for any World that rejects admission with a plain 400 — the shape a validation failure naturally takes — the error falls past this check, past the EntityConflictError/RunExpiredError branch, and into getWorkflowSetupErrorCode, which returns null because isWorldContractError requires the absence of a status. That rethrows, and the queue retries a permanently-invalid request forever.

Scratch test on this head:

atomic start + WorkflowWorldError{status: 400} -> threw WorkflowWorldError, runFailed=0
atomic start + WorkflowWorldError{status: 400, code: START_HOOK_ADMISSION_REJECTED} -> acked 204, runFailed=0

Pre-existing behavior for 400s in general, so not a regression. Flagging it because this PR introduces a new category of permanent 400 and the handling for it is inert until a World opts in. Worth stating in the PR body that the code is a contract for World implementors to adopt, so the follow-up doesn't ship admission rejection without it.

@VaguelySerious

Copy link
Copy Markdown
Member

AI Review: Note

Status of the earlier findings on 91d04bb. The head was force-pushed to a single squashed commit on the same base; the only files that changed since the last review are runtime.ts, errors/src/index.ts, world/src/interfaces.ts, and two test files.

Addressed:

  • The atomic prelude shadowing terminal-run and contract errors. Details and before/after evidence in the thread.

Still open, all re-verified on this head:

  • The deleted world.streams?.get short-circuit in start.ts. Re-measured, thread updated.
  • run_started now carrying encryptionPublicKey on every resilient start, unannounced.
  • RunInput['input'] still required rather than optional: TS2741: Property 'input' is missing ... but required.
  • cause still installed non-enumerably by makeWebError.
  • The probe-timeout / capability-gap conflation in the cross-deployment branch.
  • Validation hoisted above getDeploymentId(), and safeWaitUntil moved ahead of admission.
  • The deleted explanatory comments in events.ts, queue.ts, and start.ts.
  • The token.length === 0 check still admits a non-string.
  • All four docs pages still carry "until native atomic start-and-hook registration exists".

No reply on any of those threads yet, so I have left them as-is rather than reposting.

CI on this head is green apart from Vercel – workbench-python-workflow, which fails on main and is unrelated.

Sign up for freeto 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

@NathanColosimo@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [core] Add atomic start Hook admission by NathanColosimo · Pull Request #3426 · vercel/workflow · GitHub
Skip to content

[core] Add atomic start Hook admission - #3426

Open
NathanColosimo wants to merge 1 commit into
codex/atomic-start-capabilitiesfrom
codex/atomic-start-core
Open

[core] Add atomic start Hook admission#3426
NathanColosimo wants to merge 1 commit into
codex/atomic-start-capabilitiesfrom
codex/atomic-start-core

Conversation

@NathanColosimo

@NathanColosimoNathanColosimo commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

API

construn=awaitstart(processOrder,[orderId],{hook: {token: `order:${orderId}`,experimental_minRetention: "30 days",},});

A successful call returns the existing Run type. A duplicate throws HookConflictError with conflictingRunId. If queueing or admission cannot be confirmed, WorkflowStartError identifies the candidate run and uncertain stage.

Implementation

start() queues the candidate first, then calls events.create() to atomically reserve the token and create the run. The direct event and durable queue input carry the same normalized Hook data, so either path can complete admission. A queued loser exits before creating a run or executing user code.

The caller World and a cross-deployment target must advertise atomicStartHook. Requests with experimental_minRetention must also advertise hookRetention. This PR defines the public API, wire format, runtime behavior, and World contract; no World advertises support yet.

Plan

  1. [world] Expose capabilities in deployment health checks #3425 exposes World capabilities locally and through cross-deployment health checks.
  2. This PR defines atomic start Hook admission.
  3. Follow-up Local, Postgres, and Vercel PRs implement the atomic World operation and progressively enable one shared E2E test.
  4. [docs] Document atomic start Hook admission #3440 documents the feature and stays draft until a World implementation is ready.

@changeset-bot

changeset-botBot commented Aug 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 91d04bb

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

This PR includes changesets to release 21 packages
NameType
workflowMinor
@workflow/coreMinor
@workflow/errorsMinor
@workflow/worldMinor
@workflow/cliPatch
@workflow/web-sharedPatch
@workflow/world-vercelPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/webPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

vercelBot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

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

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewAug 12, 2026 12:03am
example-nextjs-workflow-webpackReadyReadyPreviewAug 12, 2026 12:03am
example-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-astro-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-express-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-fastify-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-hono-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-nestjs-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-nitro-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-nuxt-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-python-workflowErrorErrorAug 12, 2026 12:03am
workbench-sveltekit-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-tanstack-start-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-vite-workflowReadyReadyPreviewAug 12, 2026 12:03am
workflow-docsReadyReadyPreview, v0Aug 12, 2026 12:03am
workflow-swc-playgroundReadyReadyPreviewAug 12, 2026 12:03am
workflow-tarballsReadyReadyPreviewAug 12, 2026 12:03am
workflow-webReadyReadyPreviewAug 12, 2026 12:03am

@github-actions

github-actionsBot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production346605904056
✅ 💻 Local Development381005584368
✅ 📦 Local Production381005584368
✅ 🐘 Local Postgres381005584368
✅ 🪟 Windows31200312
✅ vercel-multi-region270027
Total152350226417499
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node128028
✅ astro-quickjs128028
✅ example-node128028
✅ example-quickjs128028
✅ express-node128028
✅ express-quickjs128028
✅ fastify-node128028
✅ fastify-quickjs128028
✅ hono-node128028
✅ hono-quickjs128028
✅ nest-node128028
✅ nest-quickjs128028
✅ nextjs-turbopack-node15303
✅ nextjs-turbopack-quickjs15303
✅ nextjs-webpack-node15303
✅ nextjs-webpack-quickjs15303
✅ nitro-node128028
✅ nitro-quickjs128028
✅ nuxt-node128028
✅ nuxt-quickjs128028
✅ sveltekit-node14709
✅ sveltekit-quickjs14709
✅ tanstack-start-node128028
✅ tanstack-start-quickjs128028
✅ vite-node128028
✅ vite-quickjs128028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack-node15600
✅ nextjs-turbopack-quickjs15600

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@github-actions

github-actionsBot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 91d04bb · Wed, 12 Aug 2026 00:17:07 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep324 (±0%)1369 🔴 (+30%) 🔻1393 🔴 (+24%) 🔻1446 🔴 (+3.4%)30
TTFSstream1303 (+719%) 🔻1363 🔴 (+27%) 🔻1378 🔴 (+26%) 🔻1450 🔴 (+17%) 🔻30
TTFShook + stream1569 (+17%) 🔻1643 🔴 (+13%)1661 🔴 (+11%)1688 🔴 (+7.4%)30
STSO1020 steps (inline)114 (-10%)159 (-16%) 💚183 (-15%) 💚491 (+16%) 🔻1019
WO1020 steps168782 (-8.4%)168782 (-8.4%)168782 (-8.4%)168782 (-8.4%)1
SLstream latency84 (-3.4%)105 🔴 (-17%) 💚122 🔴 (-11%)179 🔴 (-63%) 💚30
SOstream overhead (text)100 (-7.4%)149 (-26%) 💚160 (-49%) 💚190 (-60%) 💚30
SOstream overhead (structured)110 (+2.8%)146 (-18%) 💚160 (-33%) 💚203 (-54%) 💚30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 183209ms → this run 167452ms (Δ -15757ms, -9%)

 100-150 ms ██████░░░░░░░░░░░░░░░┃ main 168 this 606 +438
150-200 ms ███████████┃████████████ main 675 this 346 -329
200-250 ms ┃████ main 129 this 31 -98
250-300 ms ┃ main 23 this 10 -13
300-350 ms ┃ main 8 this 7 -1
350-400 ms ┃ main 3 this 7 +4
400-450 ms ┃ main 5 this 1 -4
450-500 ms ┃ main 3 this 1 -2
500-550 ms ┃ main 1 this 4 +3
550-600 ms ┃ main 0 this 1 +1
650-700 ms ┃ main 1 this 0 -1
800-850 ms ┃ main 1 this 0 -1
850-900 ms ┃ main 1 this 1 +0
900-950 ms ┃ main 1 this 0 -1
1000-1050 ms ┃ main 0 this 1 +1
1100-1150 ms ┃ main 0 this 1 +1
1650-1700 ms ┃ main 0 this 1 +1
4600-4650 ms ┃ main 0 this 1 +1
📜 Previous results (7)

45239c8

Tue, 11 Aug 2026 21:59:50 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep220 (-32%) 💚1404 🔴 (+33%) 🔻1449 🔴 (+29%) 🔻1799 🔴 (+29%) 🔻30
TTFSstream224 (+41%) 🔻1378 🔴 (+28%) 🔻1394 🔴 (+28%) 🔻1451 🔴 (+17%) 🔻30
TTFShook + stream411 (-69%) 💚1665 🔴 (+14%)1754 🔴 (+17%) 🔻1846 🔴 (+18%) 🔻30
STSO1020 steps (inline)136 (+7.1%)213 (+13%)249 (+15%) 🔻442 (+4.5%)1019
WO1020 steps213097 (+16%) 🔻213097 (+16%) 🔻213097 (+16%) 🔻213097 (+16%) 🔻1
SLstream latency100 (+15%)171 🔴 (+35%) 🔻192 🔴 (+40%) 🔻980 🔴 (+102%) 🔻30
SOstream overhead (text)131 (+21%) 🔻273 🔴 (+36%) 🔻420 (+34%) 🔻913 (+92%) 🔻30
SOstream overhead (structured)142 (+33%) 🔻264 🔴 (+49%) 🔻448 (+87%) 🔻977 (+122%) 🔻30

adf58eb

Tue, 11 Aug 2026 05:11:23 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1333 (+26%) 🔻1498 🔴 (+27%) 🔻1566 🔴 (+30%) 🔻1860 🔴 (+8.8%)30
TTFSstream1420 (+36%) 🔻1496 🔴 (+31%) 🔻1545 🔴 (+33%) 🔻1598 🔴 (+32%) 🔻30
TTFShook + stream1638 (+31%) 🔻1797 🔴 (+31%) 🔻1832 🔴 (+31%) 🔻2111 🔴 (+40%) 🔻30
STSO1020 steps (inline)115 (+20%) 🔻153 (-6.7%)169 (-17%) 💚253 (-67%) 💚1019
WO1020 steps154672 (-9.8%)154672 (-9.8%)154672 (-9.8%)154672 (-9.8%)1
SLstream latency119 (+31%) 🔻140 🔴 (-9.1%)170 🔴 (-1.2%)362 🔴 (-35%) 💚30
SOstream overhead (text)134 (+1.5%)214 (-22%) 💚224 (-32%) 💚290 (-63%) 💚30
SOstream overhead (structured)133 (+9.0%)187 (-24%) 💚209 (-33%) 💚319 (-29%) 💚30

7825269

Tue, 11 Aug 2026 00:39:16 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep224 (-73%) 💚1512 🔴 (+36%) 🔻1522 🔴 (+33%) 🔻1758 🔴 (+49%) 🔻30
TTFSstream246 (+19%) 🔻1487 🔴 (+35%) 🔻1505 🔴 (+36%) 🔻1534 🔴 (+33%) 🔻30
TTFShook + stream401 (-68%) 💚1674 🔴 (+21%) 🔻1742 🔴 (+22%) 🔻1808 🔴 (+17%) 🔻30
STSO1020 steps (inline)103 (+2.0%)153 (+3.4%)178 (+4.7%)298 (-0.7%)1019
WO1020 steps149862 (+5.7%)149862 (+5.7%)149862 (+5.7%)149862 (+5.7%)1
SLstream latency98 (+13%)193 🔴 (+71%) 🔻446 🔴 (+257%) 🔻1484 🔴 (+945%) 🔻30
SOstream overhead (text)122 (+11%)250 (+33%) 🔻376 (+77%) 🔻1304 🔴 (+446%) 🔻30
SOstream overhead (structured)124 (+12%)231 (+43%) 🔻343 (+85%) 🔻864 (+251%) 🔻30

f9614bf

Mon, 10 Aug 2026 23:58:39 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1220 (+45%) 🔻1308 🔴 (+18%) 🔻1332 🔴 (+17%) 🔻1394 🔴 (+18%) 🔻30
TTFSstream1213 (+486%) 🔻1384 🔴 (+26%) 🔻1404 🔴 (+27%) 🔻1434 🔴 (+24%) 🔻30
TTFShook + stream1448 (+16%) 🔻1646 🔴 (+19%) 🔻1750 🔴 (+23%) 🔻1822 🔴 (+18%) 🔻30
STSO1020 steps (inline)96 (-5.0%)143 (-3.4%)162 (-4.7%)398 (+33%) 🔻1019
WO1020 steps144978 (+2.2%)144978 (+2.2%)144978 (+2.2%)144978 (+2.2%)1
SLstream latency104 (+20%) 🔻141 🔴 (+25%) 🔻248 🔴 (+98%) 🔻4139 🔴 (+2815%) 🔻30
SOstream overhead (text)133 (+21%) 🔻254 🔴 (+35%) 🔻330 (+56%) 🔻587 (+146%) 🔻30
SOstream overhead (structured)114 (+2.7%)211 (+30%) 🔻302 (+63%) 🔻813 (+230%) 🔻30

77ce876

Mon, 10 Aug 2026 23:17:12 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1322 (+291%) 🔻1401 🔴 (+25%) 🔻1419 🔴 (+24%) 🔻1531 🔴 (±0%)30
TTFSstream997 (±0%)1398 🔴 (+34%) 🔻1415 🔴 (+34%) 🔻1475 🔴 (+36%) 🔻30
TTFShook + stream453 (-64%) 💚1656 🔴 (+19%) 🔻1691 🔴 (+15%)1823 🔴 (+8.3%)30
STSO1020 steps (inline)103 (+8.4%)134 (-11%)151 (-16%) 💚267 (-25%) 💚1019
WO1020 steps135938 (-8.2%)135938 (-8.2%)135938 (-8.2%)135938 (-8.2%)1
SLstream latency94 (+9.3%)129 🔴 (-0.8%)191 🔴 (+9.1%)422 🔴 (+94%) 🔻30
SOstream overhead (text)118 (+28%) 🔻209 (+12%)223 (+4.2%)330 (+31%) 🔻30
SOstream overhead (structured)120 (+17%) 🔻177 (+13%)284 (+47%) 🔻423 (+75%) 🔻30

06bd237

Mon, 10 Aug 2026 21:45:13 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1294 (+525%) 🔻1356 🔴 (+25%) 🔻1372 🔴 (+22%) 🔻1514 🔴 (-6.5%)30
TTFSstream1292 (+576%) 🔻1344 🔴 (+25%) 🔻1352 🔴 (+24%) 🔻1368 🔴 (+23%) 🔻30
TTFShook + stream1555 (+69%) 🔻1654 🔴 (+21%) 🔻1705 🔴 (+16%) 🔻1800 🔴 (+18%) 🔻30
STSO1020 steps (inline)101 (+31%) 🔻126 (-14%)143 (-19%) 💚245 (-51%) 💚1019
WO1020 steps127611 (-17%) 💚127611 (-17%) 💚127611 (-17%) 💚127611 (-17%) 💚1
SLstream latency88 (+2.3%)121 🔴 (-18%) 💚137 🔴 (-15%)152 🔴 (-50%) 💚30
SOstream overhead (text)105 (-15%) 💚163 (-39%) 💚229 (-30%) 💚251 (-85%) 💚30
SOstream overhead (structured)112 (+1.8%)163 (-42%) 💚185 (-80%) 💚652 (-68%) 💚30

3da6597

Mon, 10 Aug 2026 21:05:53 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep232 (-76%) 💚1445 🔴 (+20%) 🔻1560 🔴 (+25%) 🔻1871 🔴 (+40%) 🔻30
TTFSstream246 (-5.0%)1423 🔴 (+26%) 🔻1464 🔴 (+23%) 🔻1551 🔴 (+11%)30
TTFShook + stream406 (-5.4%)1665 🔴 (+10%)1684 🔴 (+3.0%)2292 🔴 (+8.4%)30
STSO1020 steps (inline)97 (+5.4%)153 (-6.7%)178 (-7.3%)293 (-32%) 💚1019
WO1020 steps151031 (-5.5%)151031 (-5.5%)151031 (-5.5%)151031 (-5.5%)1
SLstream latency103 (-8.0%)185 🔴 (-2.1%)218 🔴 (-50%) 💚327 🔴 (-44%) 💚30
SOstream overhead (text)150 (+9.5%)326 🔴 (+37%) 🔻504 🔴 (+36%) 🔻3837 🔴 (+205%) 🔻30
SOstream overhead (structured)137 (-9.9%)238 (-8.8%)307 (-19%) 💚343 (-50%) 💚30
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: is main, marks where this run lands, bridges the gap when this run has more samples in a bucket.

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@NathanColosimoNathanColosimo changed the title feat(core): add atomic start hook admission[core] Add atomic start Hook admissionAug 10, 2026
Comment threadpackages/core/src/runtime/start.ts Outdated
@github-actions

github-actionsBot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 Mint-ordered log — 6 fail of 41 total

log=mint-ordered · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted171.0mMISMATCH1
stale-read-equal-step-countscompleted141.0mMISMATCH1
step-vs-step-forkcompleted120msMISMATCH1
step-vs-step-fork-fencedcompleted120msMISMATCH1
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mMISMATCH1
in-flight-before-decision-countedcompleted201.0mok0
in-flight-after-decisionfailed142.0mMISMATCH1
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-mint.txt

🟢 Append-only log — 0 fail of 41 total

log=append-only · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted201.0mok0
stale-read-equal-step-countscompleted141.0mok0
step-vs-step-forkcompleted120msok0
step-vs-step-fork-fencedcompleted120msok0
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mok0
in-flight-before-decision-countedcompleted171.0mok0
in-flight-after-decisioncompleted192.0mok0
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-append-only.txt

Comment threadpackages/core/src/runtime.ts Outdated
if (
(err instanceof WorkflowWorldError ||
WorkflowWorldError.is(err)) &&
!isRetryableWorldError(err)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should this not check for the specific error? This seems like any non-retriable error will surface as a hook admission rejected

@VaguelySeriousVaguelySeriousAug 11, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI: Blocking The new runInput?.startHook !== undefined block sits before the EntityConflictError/RunExpiredError check and before the getWorkflowSetupErrorCode → recordFatalRunError path. EntityConflictError, RunExpiredError, and PreconditionFailedError all extend WorkflowWorldError and all classify as non-retryable, so the prelude shadows every one of them.

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Human pass LGTM. Agent pass coming in a few minutes probably

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI review: blocking issues found

return;
}
} catch (err) {
if (runInput?.startHook !== undefined) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

This prelude sits before the EntityConflictError/RunExpiredError check and before the getWorkflowSetupErrorCode -> recordFatalRunError path. EntityConflictError, RunExpiredError, and PreconditionFailedError all extend WorkflowWorldError and all classify as non-retryable, so err instanceof WorkflowWorldError && !isRetryableWorldError(err) shadows every one of them.

For a world-contract error the pre-existing path writes run_failed; the atomic path logs at ERROR and acks the message. The run stays in pending forever with no terminal event and no retry.

I confirmed this against the driveTurbo harness in this PR (scratch test, not committed):

non-atomic run + WorkflowWorldError{code: WORLD_CONTRACT_ERROR} -> 1 run_failed
atomic-start run + the identical error -> 0 run_failed

A second scratch test showed RunExpiredError in the atomic path now logs error: "Atomic start Hook admission rejected queued candidate" and never logs the pre-existing info: "Run already finished during setup, skipping" — same outcome, wrong severity, misleading message.

The second branch is strictly worse than falling through for contract errors, since the fall-through already stops the retry loop and records the failure. Narrowing the swallow to HookConflictError.is(err) alone fixes the case that matters. If you also want to stop queue retries for non-contract, non-retryable world errors, route those through recordFatalRunError rather than a bare return.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Resolved by 91d04bb. Narrowing to WorkflowWorldError.is(err) && err.code === START_HOOK_ADMISSION_REJECTED fixes it, and using the name-exact .is() rather than instanceof is the right call: subclasses no longer match, so the terminal-run families reach their own branch.

Verified with the same paired scratch tests, run against both heads (45239c8 pre-fix, 91d04bb post-fix):

 45239c8 (pre) 91d04bb (post)
EntityConflictError rejectedLog=true skipLog=false rejectedLog=false skipLog=true
RunExpiredError rejectedLog=true skipLog=false rejectedLog=false skipLog=true
PreconditionFailedError acked 204, no rethrow, no event rethrown (queue retries)

All three assertions fail on the pre-fix head and pass here, so the fix is load-bearing rather than incidental. PreconditionFailedError was the clearest strand: previously acked with neither a terminal event nor a retry.

The two tests added alongside the fix cover the tagged-rejection and unrelated-contract-error cases. Neither covers the terminal-run families, which are what the original prelude actually shadowed. A parametrized case over EntityConflictError/RunExpiredError/PreconditionFailedError asserting the INFO path would keep a future re-widening of this condition from going unnoticed. Note that runtimeLogger.info only reaches a sink when DEBUG matches workflow:runtime:info, so such a test has to set it.

// No probe channel to the target — cannot attest the consumer honors
// `hookInput`, so leave the marker off (fail closed to sequential).
targetHookResumeInputVersion = undefined;
} else {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

The else if (typeof world.streams?.get !== 'function') branch and its comment were deleted here. This fires for every cross-deployment start, with or without hook.

A world with no stream channel now enters healthCheck(), which enqueues a health-check message nobody can answer, then loops world.streams.get(...) -> TypeError -> sleep HEALTH_CHECK_POLL_INTERVAL, 20 times, for the full CROSS_DEPLOYMENT_CAPABILITY_PROBE_TIMEOUT_MS of 2000ms, before falling back to exactly the values the deleted branch set synchronously.

Measured on start.test.ts, same file, base vs head: 34ms -> 8.15s total, with four pre-existing tests each at ~2020ms:

should pass explicit deploymentId from opts to getEncryptionKeyForRun 2022ms
should resolve "latest" to the actual deployment ID via resolveLatestDeploymentId 2015ms
should pass the resolved deployment ID to getEncryptionKeyForRun when using "latest" 2017ms
should not call resolveLatestDeploymentId when a normal deploymentId is provided 2019ms

The guard was added deliberately in 303b6da (#1853). Nothing in the PR body explains removing it, so this reads as collateral from the refactor.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

Still open on 91d04bb. packages/core/src/runtime/start.ts is unchanged between 45239c8 and this head, and the else if (typeof world.streams?.get !== 'function') branch is still absent while it is present on the PR base (codex/atomic-start-capabilities, 459e34b, line 357).

Re-measured start.test.ts on both, same install and same build:

459e34b (PR base) 58 tests 36ms of test time
91d04bb (this head) 72 tests 8.16s of test time

with the same four pre-existing tests pinned at the probe timeout:

✓ should pass explicit deploymentId from opts to getEncryptionKeyForRun 2021ms
✓ should resolve "latest" to the actual deployment ID via resolveLatestDeploymentId 2023ms
✓ should pass the resolved deployment ID to getEncryptionKeyForRun when using "latest" 2029ms
✓ should not call resolveLatestDeploymentId when a normal deploymentId is provided 2029ms

None of those tests involve hook. Every cross-deployment start on a World without a stream channel now pays 2s and one undeliverable queue write to arrive at the values the deleted branch set synchronously. The suite passes, so CI will not catch it.

specVersion: _specVersion,
...data
} = runInput;
runCreationData = data;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Behavior change that applies to every run, not just atomic-start ones.

Replacing the explicit six-field pick with {...runInput} minus environment/specVersion means run_started now also carries encryptionPublicKey on the resilient-start path. Verified empirically by porting the new assertion on line 2348 back to the base branch:

base: expected undefined to be 'test-public-key'
head: passes

This looks like a fix, and it matches what the (now-deleted) RunStartedEventSchema comment described: on the resilient path the run is created from this event, and without the key it silently loses the ability to receive sealed writes. But it ships unannounced — the changeset doesn't mention it, and the only coverage is an added assertion inside a test named for turbo optimistic start. Worth its own changeset line and a test that names the behavior, so a future refactor doesn't drop it again.

The spread also means any field added to RunInput later is auto-forwarded into run_started rather than opted in. That's the mechanism that just quietly changed the payload here.

deploymentId: z.string(),
workflowName: z.string(),
export const RunInputSchema = RunCreationDataSchema.extend({
specVersion: z.number(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

RunInputSchema moving from a standalone object to RunCreationDataSchema.extend(...) changes input from optional to required at the type level. z.unknown() infers input?: unknown; SerializedDataSchema infers input: unknown.

Verified both halves:

  • Type: constructing a RunInput without input now fails with TS2741: Property 'input' is missing.
  • Runtime: RunInputSchema.safeParse({deploymentId, workflowName, specVersion}) still succeeds, so no parse behavior changed.

RunInputSchema and RunInput are public exports of @workflow/world, so this is a source-breaking type change for external consumers (community worlds, test fixtures) riding a minor bump. Either restore optionality with .partial({ input: true })-style handling, or call it out in the changeset.

}).catch(() => undefined);
if (
startHook &&
probe?.capabilities?.atomicStartHook?.active !== true

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

healthCheck(...).catch(() => undefined) collapses a network blip, a slow target, and a genuinely old deployment into the same probe === undefined, which then throws WORLD_CONTRACT_ERROR: "The target deployment does not support atomic start Hooks."

A transient probe failure is exactly the uncertainty WorkflowStartError was added for. Classifying it as a deterministic contract error tells the caller not to retry something that is retryable, and the message points at a capability gap that may not exist. Worth distinguishing "probe returned and said no" from "probe did not return".

meta.executionContext = input.executionContext;
}
if (input.attributes !== undefined) meta.attributes = input.attributes;
if (input.startHook !== undefined) meta.startHook = input.startHook;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

The v4 meta contract is two-sided: a field only survives if the receiving parser also knows it, and unknown meta keys are dropped silently rather than rejected. I checked the receiving side and it has no startHook handling today.

Inert on merge because no world advertises atomicStartHook, but the world-enabling PR has to land both halves together or admission data vanishes on the wire with no error. Worth a note in the follow-up PR description so it isn't discovered at E2E time.

(error as Error & { cause?: unknown }).cause = value.cause;
}
return error;
return makeWebError(value.name, value);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Unrelated to startHook, and it changes every hydrated error in the o11y UI, not just the new one.

The old base Error reviver assigned error.cause = value.cause after construction; makeWebError passes {cause} to the constructor instead. InstallErrorCause defines cause as non-enumerable, a plain assignment defines it as enumerable:

old: {"writable":true,"enumerable":true,"configurable":true} Object.keys -> ['name','cause']
new: {"writable":true,"enumerable":false,"configurable":true} Object.keys -> ['name']

Anything that enumerates own properties (spread, Object.keys, JSON.stringify) stops seeing cause. This is arguably a consistency fix, since FatalError and the other revivers already used the constructor form. Flagging it because it's a silent semantic change in a shared hydration path and nothing in the PR calls it out.

);
}
// Pin the run to the VM engine selected when it starts.
const workflowVm = getWorkflowVmFromEnv();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Two ordering changes here apply to all starts, not just atomic ones. Both look like improvements; noting them because neither is mentioned and both change observable behavior:

  1. Attribute/lineage/replayedFromRunId/WORKFLOW_VM validation now runs before world.getDeploymentId(). When both would fail, the validation error now wins where the deployment lookup used to.
  2. safeWaitUntil(Promise.all(ops), ...) moved ahead of admission, so the stream flush is registered even if the code between the old and new positions throws.

attributes: z.record(z.string(), z.string()).optional(),
allowReservedAttributes: z.literal(true).optional(),
startHook: StartHookSchema.optional(),
/** Public key used by cross-run writers to seal payloads to this run. */

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

The extraction into RunCreationDataSchema dropped several accurate explanatory comments: the X25519/sealed-envelope rationale on run_created.encryptionPublicKey, the resilient-start rationale on run_started.encryptionPublicKey, and in queue.ts the allowReservedAttributes mirroring note and "Initial plaintext run attributes, for resilient run creation".

start.ts lost a similar batch in the same refactor: the "<=1% of cases" note on the 409 branch, the 429/5xx/transport rationale on the retryable branch, "Queue failure is always fatal", the per-region queue routing note on opts.region, and the WORKFLOW_VM pinning note.

None of it was stale — it documents current behavior, which is what the repo guidance asks comments to do. Worth carrying forward into the new shapes.

}

function normalizeStartHook(options: StartHookOptions): StartHook {
if (options.token.length === 0) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Nit

options.token.length === 0 doesn't reject a non-string. A JS caller passing a number gets undefined === 0 -> false and the value flows through to the world. typeof options.token !== 'string' || options.token.length === 0 matches what the doc comment on line 111 already promises.

@VaguelySerious

Copy link
Copy Markdown
Member

AI Review: Note

Not anchorable inline since these files aren't in the diff. Four docs pages still describe the workaround this PR is replacing, each with the phrase "until native atomic start-and-hook registration exists":

  • docs/content/docs/v4/api-reference/workflow-api/start.mdx
  • docs/content/docs/v4/api-reference/workflow-api/get-hook-by-token.mdx
  • docs/content/docs/v5/api-reference/workflow-api/start.mdx
  • docs/content/docs/v5/api-reference/workflow-api/get-hook-by-token.mdx

Fine to leave while the capability is inert everywhere, but hook is a user-facing start() option landing in this PR and the repo guidance is that user-facing changes are documented in docs. Worth confirming #3440 covers all four, or adding a short "experimental, requires a world that advertises the capability" line here.

@vercelvercelBot 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:

Removal of the world.streams?.get guard in the cross-deployment start path forces every streams-less world (minimal worlds / test mocks) through healthCheck(), which busy-loops until the 2s probe timeout and leaves an orphaned health-check queue message.

Fix on Vercel

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI review: blocking issues found

}
if (
WorkflowWorldError.is(err) &&
err.code === START_HOOK_ADMISSION_REJECTED

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Follow-up on the narrowed condition (which is the right fix). The escape hatch only fires when the World tags the rejection, and no shipped World sets START_HOOK_ADMISSION_REJECTED today. The constant appears in exactly three places: its declaration in @workflow/errors, this check, and the prose contract on Storage.events.create. No implementation produces it.

So for any World that rejects admission with a plain 400 — the shape a validation failure naturally takes — the error falls past this check, past the EntityConflictError/RunExpiredError branch, and into getWorkflowSetupErrorCode, which returns null because isWorldContractError requires the absence of a status. That rethrows, and the queue retries a permanently-invalid request forever.

Scratch test on this head:

atomic start + WorkflowWorldError{status: 400} -> threw WorkflowWorldError, runFailed=0
atomic start + WorkflowWorldError{status: 400, code: START_HOOK_ADMISSION_REJECTED} -> acked 204, runFailed=0

Pre-existing behavior for 400s in general, so not a regression. Flagging it because this PR introduces a new category of permanent 400 and the handling for it is inert until a World opts in. Worth stating in the PR body that the code is a contract for World implementors to adopt, so the follow-up doesn't ship admission rejection without it.

@VaguelySerious

Copy link
Copy Markdown
Member

AI Review: Note

Status of the earlier findings on 91d04bb. The head was force-pushed to a single squashed commit on the same base; the only files that changed since the last review are runtime.ts, errors/src/index.ts, world/src/interfaces.ts, and two test files.

Addressed:

  • The atomic prelude shadowing terminal-run and contract errors. Details and before/after evidence in the thread.

Still open, all re-verified on this head:

  • The deleted world.streams?.get short-circuit in start.ts. Re-measured, thread updated.
  • run_started now carrying encryptionPublicKey on every resilient start, unannounced.
  • RunInput['input'] still required rather than optional: TS2741: Property 'input' is missing ... but required.
  • cause still installed non-enumerably by makeWebError.
  • The probe-timeout / capability-gap conflation in the cross-deployment branch.
  • Validation hoisted above getDeploymentId(), and safeWaitUntil moved ahead of admission.
  • The deleted explanatory comments in events.ts, queue.ts, and start.ts.
  • The token.length === 0 check still admits a non-string.
  • All four docs pages still carry "until native atomic start-and-hook registration exists".

No reply on any of those threads yet, so I have left them as-is rather than reposting.

CI on this head is green apart from Vercel – workbench-python-workflow, which fails on main and is unrelated.

Sign up for freeto 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

@NathanColosimo@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); [core] Add atomic start Hook admission by NathanColosimo · Pull Request #3426 · vercel/workflow · GitHub
Skip to content

[core] Add atomic start Hook admission - #3426

Open
NathanColosimo wants to merge 1 commit into
codex/atomic-start-capabilitiesfrom
codex/atomic-start-core
Open

[core] Add atomic start Hook admission#3426
NathanColosimo wants to merge 1 commit into
codex/atomic-start-capabilitiesfrom
codex/atomic-start-core

Conversation

@NathanColosimo

@NathanColosimoNathanColosimo commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

API

construn=awaitstart(processOrder,[orderId],{hook: {token: `order:${orderId}`,experimental_minRetention: "30 days",},});

A successful call returns the existing Run type. A duplicate throws HookConflictError with conflictingRunId. If queueing or admission cannot be confirmed, WorkflowStartError identifies the candidate run and uncertain stage.

Implementation

start() queues the candidate first, then calls events.create() to atomically reserve the token and create the run. The direct event and durable queue input carry the same normalized Hook data, so either path can complete admission. A queued loser exits before creating a run or executing user code.

The caller World and a cross-deployment target must advertise atomicStartHook. Requests with experimental_minRetention must also advertise hookRetention. This PR defines the public API, wire format, runtime behavior, and World contract; no World advertises support yet.

Plan

  1. [world] Expose capabilities in deployment health checks #3425 exposes World capabilities locally and through cross-deployment health checks.
  2. This PR defines atomic start Hook admission.
  3. Follow-up Local, Postgres, and Vercel PRs implement the atomic World operation and progressively enable one shared E2E test.
  4. [docs] Document atomic start Hook admission #3440 documents the feature and stays draft until a World implementation is ready.

@changeset-bot

changeset-botBot commented Aug 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 91d04bb

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

This PR includes changesets to release 21 packages
NameType
workflowMinor
@workflow/coreMinor
@workflow/errorsMinor
@workflow/worldMinor
@workflow/cliPatch
@workflow/web-sharedPatch
@workflow/world-vercelPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/webPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

vercelBot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

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

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewAug 12, 2026 12:03am
example-nextjs-workflow-webpackReadyReadyPreviewAug 12, 2026 12:03am
example-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-astro-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-express-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-fastify-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-hono-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-nestjs-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-nitro-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-nuxt-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-python-workflowErrorErrorAug 12, 2026 12:03am
workbench-sveltekit-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-tanstack-start-workflowReadyReadyPreviewAug 12, 2026 12:03am
workbench-vite-workflowReadyReadyPreviewAug 12, 2026 12:03am
workflow-docsReadyReadyPreview, v0Aug 12, 2026 12:03am
workflow-swc-playgroundReadyReadyPreviewAug 12, 2026 12:03am
workflow-tarballsReadyReadyPreviewAug 12, 2026 12:03am
workflow-webReadyReadyPreviewAug 12, 2026 12:03am

@github-actions

github-actionsBot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production346605904056
✅ 💻 Local Development381005584368
✅ 📦 Local Production381005584368
✅ 🐘 Local Postgres381005584368
✅ 🪟 Windows31200312
✅ vercel-multi-region270027
Total152350226417499
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node128028
✅ astro-quickjs128028
✅ example-node128028
✅ example-quickjs128028
✅ express-node128028
✅ express-quickjs128028
✅ fastify-node128028
✅ fastify-quickjs128028
✅ hono-node128028
✅ hono-quickjs128028
✅ nest-node128028
✅ nest-quickjs128028
✅ nextjs-turbopack-node15303
✅ nextjs-turbopack-quickjs15303
✅ nextjs-webpack-node15303
✅ nextjs-webpack-quickjs15303
✅ nitro-node128028
✅ nitro-quickjs128028
✅ nuxt-node128028
✅ nuxt-quickjs128028
✅ sveltekit-node14709
✅ sveltekit-quickjs14709
✅ tanstack-start-node128028
✅ tanstack-start-quickjs128028
✅ vite-node128028
✅ vite-quickjs128028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack-node15600
✅ nextjs-turbopack-quickjs15600

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@github-actions

github-actionsBot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 91d04bb · Wed, 12 Aug 2026 00:17:07 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep324 (±0%)1369 🔴 (+30%) 🔻1393 🔴 (+24%) 🔻1446 🔴 (+3.4%)30
TTFSstream1303 (+719%) 🔻1363 🔴 (+27%) 🔻1378 🔴 (+26%) 🔻1450 🔴 (+17%) 🔻30
TTFShook + stream1569 (+17%) 🔻1643 🔴 (+13%)1661 🔴 (+11%)1688 🔴 (+7.4%)30
STSO1020 steps (inline)114 (-10%)159 (-16%) 💚183 (-15%) 💚491 (+16%) 🔻1019
WO1020 steps168782 (-8.4%)168782 (-8.4%)168782 (-8.4%)168782 (-8.4%)1
SLstream latency84 (-3.4%)105 🔴 (-17%) 💚122 🔴 (-11%)179 🔴 (-63%) 💚30
SOstream overhead (text)100 (-7.4%)149 (-26%) 💚160 (-49%) 💚190 (-60%) 💚30
SOstream overhead (structured)110 (+2.8%)146 (-18%) 💚160 (-33%) 💚203 (-54%) 💚30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 183209ms → this run 167452ms (Δ -15757ms, -9%)

 100-150 ms ██████░░░░░░░░░░░░░░░┃ main 168 this 606 +438
150-200 ms ███████████┃████████████ main 675 this 346 -329
200-250 ms ┃████ main 129 this 31 -98
250-300 ms ┃ main 23 this 10 -13
300-350 ms ┃ main 8 this 7 -1
350-400 ms ┃ main 3 this 7 +4
400-450 ms ┃ main 5 this 1 -4
450-500 ms ┃ main 3 this 1 -2
500-550 ms ┃ main 1 this 4 +3
550-600 ms ┃ main 0 this 1 +1
650-700 ms ┃ main 1 this 0 -1
800-850 ms ┃ main 1 this 0 -1
850-900 ms ┃ main 1 this 1 +0
900-950 ms ┃ main 1 this 0 -1
1000-1050 ms ┃ main 0 this 1 +1
1100-1150 ms ┃ main 0 this 1 +1
1650-1700 ms ┃ main 0 this 1 +1
4600-4650 ms ┃ main 0 this 1 +1
📜 Previous results (7)

45239c8

Tue, 11 Aug 2026 21:59:50 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep220 (-32%) 💚1404 🔴 (+33%) 🔻1449 🔴 (+29%) 🔻1799 🔴 (+29%) 🔻30
TTFSstream224 (+41%) 🔻1378 🔴 (+28%) 🔻1394 🔴 (+28%) 🔻1451 🔴 (+17%) 🔻30
TTFShook + stream411 (-69%) 💚1665 🔴 (+14%)1754 🔴 (+17%) 🔻1846 🔴 (+18%) 🔻30
STSO1020 steps (inline)136 (+7.1%)213 (+13%)249 (+15%) 🔻442 (+4.5%)1019
WO1020 steps213097 (+16%) 🔻213097 (+16%) 🔻213097 (+16%) 🔻213097 (+16%) 🔻1
SLstream latency100 (+15%)171 🔴 (+35%) 🔻192 🔴 (+40%) 🔻980 🔴 (+102%) 🔻30
SOstream overhead (text)131 (+21%) 🔻273 🔴 (+36%) 🔻420 (+34%) 🔻913 (+92%) 🔻30
SOstream overhead (structured)142 (+33%) 🔻264 🔴 (+49%) 🔻448 (+87%) 🔻977 (+122%) 🔻30

adf58eb

Tue, 11 Aug 2026 05:11:23 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1333 (+26%) 🔻1498 🔴 (+27%) 🔻1566 🔴 (+30%) 🔻1860 🔴 (+8.8%)30
TTFSstream1420 (+36%) 🔻1496 🔴 (+31%) 🔻1545 🔴 (+33%) 🔻1598 🔴 (+32%) 🔻30
TTFShook + stream1638 (+31%) 🔻1797 🔴 (+31%) 🔻1832 🔴 (+31%) 🔻2111 🔴 (+40%) 🔻30
STSO1020 steps (inline)115 (+20%) 🔻153 (-6.7%)169 (-17%) 💚253 (-67%) 💚1019
WO1020 steps154672 (-9.8%)154672 (-9.8%)154672 (-9.8%)154672 (-9.8%)1
SLstream latency119 (+31%) 🔻140 🔴 (-9.1%)170 🔴 (-1.2%)362 🔴 (-35%) 💚30
SOstream overhead (text)134 (+1.5%)214 (-22%) 💚224 (-32%) 💚290 (-63%) 💚30
SOstream overhead (structured)133 (+9.0%)187 (-24%) 💚209 (-33%) 💚319 (-29%) 💚30

7825269

Tue, 11 Aug 2026 00:39:16 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep224 (-73%) 💚1512 🔴 (+36%) 🔻1522 🔴 (+33%) 🔻1758 🔴 (+49%) 🔻30
TTFSstream246 (+19%) 🔻1487 🔴 (+35%) 🔻1505 🔴 (+36%) 🔻1534 🔴 (+33%) 🔻30
TTFShook + stream401 (-68%) 💚1674 🔴 (+21%) 🔻1742 🔴 (+22%) 🔻1808 🔴 (+17%) 🔻30
STSO1020 steps (inline)103 (+2.0%)153 (+3.4%)178 (+4.7%)298 (-0.7%)1019
WO1020 steps149862 (+5.7%)149862 (+5.7%)149862 (+5.7%)149862 (+5.7%)1
SLstream latency98 (+13%)193 🔴 (+71%) 🔻446 🔴 (+257%) 🔻1484 🔴 (+945%) 🔻30
SOstream overhead (text)122 (+11%)250 (+33%) 🔻376 (+77%) 🔻1304 🔴 (+446%) 🔻30
SOstream overhead (structured)124 (+12%)231 (+43%) 🔻343 (+85%) 🔻864 (+251%) 🔻30

f9614bf

Mon, 10 Aug 2026 23:58:39 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1220 (+45%) 🔻1308 🔴 (+18%) 🔻1332 🔴 (+17%) 🔻1394 🔴 (+18%) 🔻30
TTFSstream1213 (+486%) 🔻1384 🔴 (+26%) 🔻1404 🔴 (+27%) 🔻1434 🔴 (+24%) 🔻30
TTFShook + stream1448 (+16%) 🔻1646 🔴 (+19%) 🔻1750 🔴 (+23%) 🔻1822 🔴 (+18%) 🔻30
STSO1020 steps (inline)96 (-5.0%)143 (-3.4%)162 (-4.7%)398 (+33%) 🔻1019
WO1020 steps144978 (+2.2%)144978 (+2.2%)144978 (+2.2%)144978 (+2.2%)1
SLstream latency104 (+20%) 🔻141 🔴 (+25%) 🔻248 🔴 (+98%) 🔻4139 🔴 (+2815%) 🔻30
SOstream overhead (text)133 (+21%) 🔻254 🔴 (+35%) 🔻330 (+56%) 🔻587 (+146%) 🔻30
SOstream overhead (structured)114 (+2.7%)211 (+30%) 🔻302 (+63%) 🔻813 (+230%) 🔻30

77ce876

Mon, 10 Aug 2026 23:17:12 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1322 (+291%) 🔻1401 🔴 (+25%) 🔻1419 🔴 (+24%) 🔻1531 🔴 (±0%)30
TTFSstream997 (±0%)1398 🔴 (+34%) 🔻1415 🔴 (+34%) 🔻1475 🔴 (+36%) 🔻30
TTFShook + stream453 (-64%) 💚1656 🔴 (+19%) 🔻1691 🔴 (+15%)1823 🔴 (+8.3%)30
STSO1020 steps (inline)103 (+8.4%)134 (-11%)151 (-16%) 💚267 (-25%) 💚1019
WO1020 steps135938 (-8.2%)135938 (-8.2%)135938 (-8.2%)135938 (-8.2%)1
SLstream latency94 (+9.3%)129 🔴 (-0.8%)191 🔴 (+9.1%)422 🔴 (+94%) 🔻30
SOstream overhead (text)118 (+28%) 🔻209 (+12%)223 (+4.2%)330 (+31%) 🔻30
SOstream overhead (structured)120 (+17%) 🔻177 (+13%)284 (+47%) 🔻423 (+75%) 🔻30

06bd237

Mon, 10 Aug 2026 21:45:13 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1294 (+525%) 🔻1356 🔴 (+25%) 🔻1372 🔴 (+22%) 🔻1514 🔴 (-6.5%)30
TTFSstream1292 (+576%) 🔻1344 🔴 (+25%) 🔻1352 🔴 (+24%) 🔻1368 🔴 (+23%) 🔻30
TTFShook + stream1555 (+69%) 🔻1654 🔴 (+21%) 🔻1705 🔴 (+16%) 🔻1800 🔴 (+18%) 🔻30
STSO1020 steps (inline)101 (+31%) 🔻126 (-14%)143 (-19%) 💚245 (-51%) 💚1019
WO1020 steps127611 (-17%) 💚127611 (-17%) 💚127611 (-17%) 💚127611 (-17%) 💚1
SLstream latency88 (+2.3%)121 🔴 (-18%) 💚137 🔴 (-15%)152 🔴 (-50%) 💚30
SOstream overhead (text)105 (-15%) 💚163 (-39%) 💚229 (-30%) 💚251 (-85%) 💚30
SOstream overhead (structured)112 (+1.8%)163 (-42%) 💚185 (-80%) 💚652 (-68%) 💚30

3da6597

Mon, 10 Aug 2026 21:05:53 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep232 (-76%) 💚1445 🔴 (+20%) 🔻1560 🔴 (+25%) 🔻1871 🔴 (+40%) 🔻30
TTFSstream246 (-5.0%)1423 🔴 (+26%) 🔻1464 🔴 (+23%) 🔻1551 🔴 (+11%)30
TTFShook + stream406 (-5.4%)1665 🔴 (+10%)1684 🔴 (+3.0%)2292 🔴 (+8.4%)30
STSO1020 steps (inline)97 (+5.4%)153 (-6.7%)178 (-7.3%)293 (-32%) 💚1019
WO1020 steps151031 (-5.5%)151031 (-5.5%)151031 (-5.5%)151031 (-5.5%)1
SLstream latency103 (-8.0%)185 🔴 (-2.1%)218 🔴 (-50%) 💚327 🔴 (-44%) 💚30
SOstream overhead (text)150 (+9.5%)326 🔴 (+37%) 🔻504 🔴 (+36%) 🔻3837 🔴 (+205%) 🔻30
SOstream overhead (structured)137 (-9.9%)238 (-8.8%)307 (-19%) 💚343 (-50%) 💚30
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: is main, marks where this run lands, bridges the gap when this run has more samples in a bucket.

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@NathanColosimoNathanColosimo changed the title feat(core): add atomic start hook admission[core] Add atomic start Hook admissionAug 10, 2026
Comment threadpackages/core/src/runtime/start.ts Outdated
@github-actions

github-actionsBot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 Mint-ordered log — 6 fail of 41 total

log=mint-ordered · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted171.0mMISMATCH1
stale-read-equal-step-countscompleted141.0mMISMATCH1
step-vs-step-forkcompleted120msMISMATCH1
step-vs-step-fork-fencedcompleted120msMISMATCH1
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mMISMATCH1
in-flight-before-decision-countedcompleted201.0mok0
in-flight-after-decisionfailed142.0mMISMATCH1
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-mint.txt

🟢 Append-only log — 0 fail of 41 total

log=append-only · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted201.0mok0
stale-read-equal-step-countscompleted141.0mok0
step-vs-step-forkcompleted120msok0
step-vs-step-fork-fencedcompleted120msok0
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mok0
in-flight-before-decision-countedcompleted171.0mok0
in-flight-after-decisioncompleted192.0mok0
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-append-only.txt

Comment threadpackages/core/src/runtime.ts Outdated
if (
(err instanceof WorkflowWorldError ||
WorkflowWorldError.is(err)) &&
!isRetryableWorldError(err)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should this not check for the specific error? This seems like any non-retriable error will surface as a hook admission rejected

@VaguelySeriousVaguelySeriousAug 11, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI: Blocking The new runInput?.startHook !== undefined block sits before the EntityConflictError/RunExpiredError check and before the getWorkflowSetupErrorCode → recordFatalRunError path. EntityConflictError, RunExpiredError, and PreconditionFailedError all extend WorkflowWorldError and all classify as non-retryable, so the prelude shadows every one of them.

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Human pass LGTM. Agent pass coming in a few minutes probably

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI review: blocking issues found

return;
}
} catch (err) {
if (runInput?.startHook !== undefined) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

This prelude sits before the EntityConflictError/RunExpiredError check and before the getWorkflowSetupErrorCode -> recordFatalRunError path. EntityConflictError, RunExpiredError, and PreconditionFailedError all extend WorkflowWorldError and all classify as non-retryable, so err instanceof WorkflowWorldError && !isRetryableWorldError(err) shadows every one of them.

For a world-contract error the pre-existing path writes run_failed; the atomic path logs at ERROR and acks the message. The run stays in pending forever with no terminal event and no retry.

I confirmed this against the driveTurbo harness in this PR (scratch test, not committed):

non-atomic run + WorkflowWorldError{code: WORLD_CONTRACT_ERROR} -> 1 run_failed
atomic-start run + the identical error -> 0 run_failed

A second scratch test showed RunExpiredError in the atomic path now logs error: "Atomic start Hook admission rejected queued candidate" and never logs the pre-existing info: "Run already finished during setup, skipping" — same outcome, wrong severity, misleading message.

The second branch is strictly worse than falling through for contract errors, since the fall-through already stops the retry loop and records the failure. Narrowing the swallow to HookConflictError.is(err) alone fixes the case that matters. If you also want to stop queue retries for non-contract, non-retryable world errors, route those through recordFatalRunError rather than a bare return.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Resolved by 91d04bb. Narrowing to WorkflowWorldError.is(err) && err.code === START_HOOK_ADMISSION_REJECTED fixes it, and using the name-exact .is() rather than instanceof is the right call: subclasses no longer match, so the terminal-run families reach their own branch.

Verified with the same paired scratch tests, run against both heads (45239c8 pre-fix, 91d04bb post-fix):

 45239c8 (pre) 91d04bb (post)
EntityConflictError rejectedLog=true skipLog=false rejectedLog=false skipLog=true
RunExpiredError rejectedLog=true skipLog=false rejectedLog=false skipLog=true
PreconditionFailedError acked 204, no rethrow, no event rethrown (queue retries)

All three assertions fail on the pre-fix head and pass here, so the fix is load-bearing rather than incidental. PreconditionFailedError was the clearest strand: previously acked with neither a terminal event nor a retry.

The two tests added alongside the fix cover the tagged-rejection and unrelated-contract-error cases. Neither covers the terminal-run families, which are what the original prelude actually shadowed. A parametrized case over EntityConflictError/RunExpiredError/PreconditionFailedError asserting the INFO path would keep a future re-widening of this condition from going unnoticed. Note that runtimeLogger.info only reaches a sink when DEBUG matches workflow:runtime:info, so such a test has to set it.

// No probe channel to the target — cannot attest the consumer honors
// `hookInput`, so leave the marker off (fail closed to sequential).
targetHookResumeInputVersion = undefined;
} else {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

The else if (typeof world.streams?.get !== 'function') branch and its comment were deleted here. This fires for every cross-deployment start, with or without hook.

A world with no stream channel now enters healthCheck(), which enqueues a health-check message nobody can answer, then loops world.streams.get(...) -> TypeError -> sleep HEALTH_CHECK_POLL_INTERVAL, 20 times, for the full CROSS_DEPLOYMENT_CAPABILITY_PROBE_TIMEOUT_MS of 2000ms, before falling back to exactly the values the deleted branch set synchronously.

Measured on start.test.ts, same file, base vs head: 34ms -> 8.15s total, with four pre-existing tests each at ~2020ms:

should pass explicit deploymentId from opts to getEncryptionKeyForRun 2022ms
should resolve "latest" to the actual deployment ID via resolveLatestDeploymentId 2015ms
should pass the resolved deployment ID to getEncryptionKeyForRun when using "latest" 2017ms
should not call resolveLatestDeploymentId when a normal deploymentId is provided 2019ms

The guard was added deliberately in 303b6da (#1853). Nothing in the PR body explains removing it, so this reads as collateral from the refactor.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

Still open on 91d04bb. packages/core/src/runtime/start.ts is unchanged between 45239c8 and this head, and the else if (typeof world.streams?.get !== 'function') branch is still absent while it is present on the PR base (codex/atomic-start-capabilities, 459e34b, line 357).

Re-measured start.test.ts on both, same install and same build:

459e34b (PR base) 58 tests 36ms of test time
91d04bb (this head) 72 tests 8.16s of test time

with the same four pre-existing tests pinned at the probe timeout:

✓ should pass explicit deploymentId from opts to getEncryptionKeyForRun 2021ms
✓ should resolve "latest" to the actual deployment ID via resolveLatestDeploymentId 2023ms
✓ should pass the resolved deployment ID to getEncryptionKeyForRun when using "latest" 2029ms
✓ should not call resolveLatestDeploymentId when a normal deploymentId is provided 2029ms

None of those tests involve hook. Every cross-deployment start on a World without a stream channel now pays 2s and one undeliverable queue write to arrive at the values the deleted branch set synchronously. The suite passes, so CI will not catch it.

specVersion: _specVersion,
...data
} = runInput;
runCreationData = data;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Behavior change that applies to every run, not just atomic-start ones.

Replacing the explicit six-field pick with {...runInput} minus environment/specVersion means run_started now also carries encryptionPublicKey on the resilient-start path. Verified empirically by porting the new assertion on line 2348 back to the base branch:

base: expected undefined to be 'test-public-key'
head: passes

This looks like a fix, and it matches what the (now-deleted) RunStartedEventSchema comment described: on the resilient path the run is created from this event, and without the key it silently loses the ability to receive sealed writes. But it ships unannounced — the changeset doesn't mention it, and the only coverage is an added assertion inside a test named for turbo optimistic start. Worth its own changeset line and a test that names the behavior, so a future refactor doesn't drop it again.

The spread also means any field added to RunInput later is auto-forwarded into run_started rather than opted in. That's the mechanism that just quietly changed the payload here.

deploymentId: z.string(),
workflowName: z.string(),
export const RunInputSchema = RunCreationDataSchema.extend({
specVersion: z.number(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

RunInputSchema moving from a standalone object to RunCreationDataSchema.extend(...) changes input from optional to required at the type level. z.unknown() infers input?: unknown; SerializedDataSchema infers input: unknown.

Verified both halves:

  • Type: constructing a RunInput without input now fails with TS2741: Property 'input' is missing.
  • Runtime: RunInputSchema.safeParse({deploymentId, workflowName, specVersion}) still succeeds, so no parse behavior changed.

RunInputSchema and RunInput are public exports of @workflow/world, so this is a source-breaking type change for external consumers (community worlds, test fixtures) riding a minor bump. Either restore optionality with .partial({ input: true })-style handling, or call it out in the changeset.

}).catch(() => undefined);
if (
startHook &&
probe?.capabilities?.atomicStartHook?.active !== true

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

healthCheck(...).catch(() => undefined) collapses a network blip, a slow target, and a genuinely old deployment into the same probe === undefined, which then throws WORLD_CONTRACT_ERROR: "The target deployment does not support atomic start Hooks."

A transient probe failure is exactly the uncertainty WorkflowStartError was added for. Classifying it as a deterministic contract error tells the caller not to retry something that is retryable, and the message points at a capability gap that may not exist. Worth distinguishing "probe returned and said no" from "probe did not return".

meta.executionContext = input.executionContext;
}
if (input.attributes !== undefined) meta.attributes = input.attributes;
if (input.startHook !== undefined) meta.startHook = input.startHook;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

The v4 meta contract is two-sided: a field only survives if the receiving parser also knows it, and unknown meta keys are dropped silently rather than rejected. I checked the receiving side and it has no startHook handling today.

Inert on merge because no world advertises atomicStartHook, but the world-enabling PR has to land both halves together or admission data vanishes on the wire with no error. Worth a note in the follow-up PR description so it isn't discovered at E2E time.

(error as Error & { cause?: unknown }).cause = value.cause;
}
return error;
return makeWebError(value.name, value);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Unrelated to startHook, and it changes every hydrated error in the o11y UI, not just the new one.

The old base Error reviver assigned error.cause = value.cause after construction; makeWebError passes {cause} to the constructor instead. InstallErrorCause defines cause as non-enumerable, a plain assignment defines it as enumerable:

old: {"writable":true,"enumerable":true,"configurable":true} Object.keys -> ['name','cause']
new: {"writable":true,"enumerable":false,"configurable":true} Object.keys -> ['name']

Anything that enumerates own properties (spread, Object.keys, JSON.stringify) stops seeing cause. This is arguably a consistency fix, since FatalError and the other revivers already used the constructor form. Flagging it because it's a silent semantic change in a shared hydration path and nothing in the PR calls it out.

);
}
// Pin the run to the VM engine selected when it starts.
const workflowVm = getWorkflowVmFromEnv();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Two ordering changes here apply to all starts, not just atomic ones. Both look like improvements; noting them because neither is mentioned and both change observable behavior:

  1. Attribute/lineage/replayedFromRunId/WORKFLOW_VM validation now runs before world.getDeploymentId(). When both would fail, the validation error now wins where the deployment lookup used to.
  2. safeWaitUntil(Promise.all(ops), ...) moved ahead of admission, so the stream flush is registered even if the code between the old and new positions throws.

attributes: z.record(z.string(), z.string()).optional(),
allowReservedAttributes: z.literal(true).optional(),
startHook: StartHookSchema.optional(),
/** Public key used by cross-run writers to seal payloads to this run. */

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

The extraction into RunCreationDataSchema dropped several accurate explanatory comments: the X25519/sealed-envelope rationale on run_created.encryptionPublicKey, the resilient-start rationale on run_started.encryptionPublicKey, and in queue.ts the allowReservedAttributes mirroring note and "Initial plaintext run attributes, for resilient run creation".

start.ts lost a similar batch in the same refactor: the "<=1% of cases" note on the 409 branch, the 429/5xx/transport rationale on the retryable branch, "Queue failure is always fatal", the per-region queue routing note on opts.region, and the WORKFLOW_VM pinning note.

None of it was stale — it documents current behavior, which is what the repo guidance asks comments to do. Worth carrying forward into the new shapes.

}

function normalizeStartHook(options: StartHookOptions): StartHook {
if (options.token.length === 0) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Nit

options.token.length === 0 doesn't reject a non-string. A JS caller passing a number gets undefined === 0 -> false and the value flows through to the world. typeof options.token !== 'string' || options.token.length === 0 matches what the doc comment on line 111 already promises.

@VaguelySerious

Copy link
Copy Markdown
Member

AI Review: Note

Not anchorable inline since these files aren't in the diff. Four docs pages still describe the workaround this PR is replacing, each with the phrase "until native atomic start-and-hook registration exists":

  • docs/content/docs/v4/api-reference/workflow-api/start.mdx
  • docs/content/docs/v4/api-reference/workflow-api/get-hook-by-token.mdx
  • docs/content/docs/v5/api-reference/workflow-api/start.mdx
  • docs/content/docs/v5/api-reference/workflow-api/get-hook-by-token.mdx

Fine to leave while the capability is inert everywhere, but hook is a user-facing start() option landing in this PR and the repo guidance is that user-facing changes are documented in docs. Worth confirming #3440 covers all four, or adding a short "experimental, requires a world that advertises the capability" line here.

@vercelvercelBot 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:

Removal of the world.streams?.get guard in the cross-deployment start path forces every streams-less world (minimal worlds / test mocks) through healthCheck(), which busy-loops until the 2s probe timeout and leaves an orphaned health-check queue message.

Fix on Vercel

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI review: blocking issues found

}
if (
WorkflowWorldError.is(err) &&
err.code === START_HOOK_ADMISSION_REJECTED

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Follow-up on the narrowed condition (which is the right fix). The escape hatch only fires when the World tags the rejection, and no shipped World sets START_HOOK_ADMISSION_REJECTED today. The constant appears in exactly three places: its declaration in @workflow/errors, this check, and the prose contract on Storage.events.create. No implementation produces it.

So for any World that rejects admission with a plain 400 — the shape a validation failure naturally takes — the error falls past this check, past the EntityConflictError/RunExpiredError branch, and into getWorkflowSetupErrorCode, which returns null because isWorldContractError requires the absence of a status. That rethrows, and the queue retries a permanently-invalid request forever.

Scratch test on this head:

atomic start + WorkflowWorldError{status: 400} -> threw WorkflowWorldError, runFailed=0
atomic start + WorkflowWorldError{status: 400, code: START_HOOK_ADMISSION_REJECTED} -> acked 204, runFailed=0

Pre-existing behavior for 400s in general, so not a regression. Flagging it because this PR introduces a new category of permanent 400 and the handling for it is inert until a World opts in. Worth stating in the PR body that the code is a contract for World implementors to adopt, so the follow-up doesn't ship admission rejection without it.

@VaguelySerious

Copy link
Copy Markdown
Member

AI Review: Note

Status of the earlier findings on 91d04bb. The head was force-pushed to a single squashed commit on the same base; the only files that changed since the last review are runtime.ts, errors/src/index.ts, world/src/interfaces.ts, and two test files.

Addressed:

  • The atomic prelude shadowing terminal-run and contract errors. Details and before/after evidence in the thread.

Still open, all re-verified on this head:

  • The deleted world.streams?.get short-circuit in start.ts. Re-measured, thread updated.
  • run_started now carrying encryptionPublicKey on every resilient start, unannounced.
  • RunInput['input'] still required rather than optional: TS2741: Property 'input' is missing ... but required.
  • cause still installed non-enumerably by makeWebError.
  • The probe-timeout / capability-gap conflation in the cross-deployment branch.
  • Validation hoisted above getDeploymentId(), and safeWaitUntil moved ahead of admission.
  • The deleted explanatory comments in events.ts, queue.ts, and start.ts.
  • The token.length === 0 check still admits a non-string.
  • All four docs pages still carry "until native atomic start-and-hook registration exists".

No reply on any of those threads yet, so I have left them as-is rather than reposting.

CI on this head is green apart from Vercel – workbench-python-workflow, which fails on main and is unrelated.

Sign up for freeto 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

@NathanColosimo@VaguelySerious