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

@NathanColosimo NathanColosimo commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

API

const run = await start(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-bot Bot 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
Name Type
workflow Minor
@workflow/core Minor
@workflow/errors Minor
@workflow/world Minor
@workflow/cli Patch
@workflow/web-shared Patch
@workflow/world-vercel Patch
@workflow/world-testing Patch
@workflow/builders Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/vitest Patch
@workflow/web Patch
@workflow/world-local Patch
@workflow/world-postgres Patch
@workflow/astro Patch
@workflow/nest Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite Patch
@workflow/nuxt Patch

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

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

@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated (UTC)
example-nextjs-workflow-turbopack Ready Ready Preview Aug 12, 2026 12:03am
example-nextjs-workflow-webpack Ready Ready Preview Aug 12, 2026 12:03am
example-workflow Ready Ready Preview Aug 12, 2026 12:03am
workbench-astro-workflow Ready Ready Preview Aug 12, 2026 12:03am
workbench-express-workflow Ready Ready Preview Aug 12, 2026 12:03am
workbench-fastify-workflow Ready Ready Preview Aug 12, 2026 12:03am
workbench-hono-workflow Ready Ready Preview Aug 12, 2026 12:03am
workbench-nestjs-workflow Ready Ready Preview Aug 12, 2026 12:03am
workbench-nitro-workflow Ready Ready Preview Aug 12, 2026 12:03am
workbench-nuxt-workflow Ready Ready Preview Aug 12, 2026 12:03am
workbench-python-workflow Error Error Aug 12, 2026 12:03am
workbench-sveltekit-workflow Ready Ready Preview Aug 12, 2026 12:03am
workbench-tanstack-start-workflow Ready Ready Preview Aug 12, 2026 12:03am
workbench-vite-workflow Ready Ready Preview Aug 12, 2026 12:03am
workflow-docs Ready Ready Preview, v0 Aug 12, 2026 12:03am
workflow-swc-playground Ready Ready Preview Aug 12, 2026 12:03am
workflow-tarballs Ready Ready Preview Aug 12, 2026 12:03am
workflow-web Ready Ready Preview Aug 12, 2026 12:03am

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
Passed Failed Skipped Total
✅ ▲ Vercel Production 3466 0 590 4056
✅ 💻 Local Development 3810 0 558 4368
✅ 📦 Local Production 3810 0 558 4368
✅ 🐘 Local Postgres 3810 0 558 4368
✅ 🪟 Windows 312 0 0 312
✅ vercel-multi-region 27 0 0 27
Total 15235 0 2264 17499
Details by Category

✅ ▲ Vercel Production

App Passed Failed Skipped
✅ astro-node 128 0 28
✅ astro-quickjs 128 0 28
✅ example-node 128 0 28
✅ example-quickjs 128 0 28
✅ express-node 128 0 28
✅ express-quickjs 128 0 28
✅ fastify-node 128 0 28
✅ fastify-quickjs 128 0 28
✅ hono-node 128 0 28
✅ hono-quickjs 128 0 28
✅ nest-node 128 0 28
✅ nest-quickjs 128 0 28
✅ nextjs-turbopack-node 153 0 3
✅ nextjs-turbopack-quickjs 153 0 3
✅ nextjs-webpack-node 153 0 3
✅ nextjs-webpack-quickjs 153 0 3
✅ nitro-node 128 0 28
✅ nitro-quickjs 128 0 28
✅ nuxt-node 128 0 28
✅ nuxt-quickjs 128 0 28
✅ sveltekit-node 147 0 9
✅ sveltekit-quickjs 147 0 9
✅ tanstack-start-node 128 0 28
✅ tanstack-start-quickjs 128 0 28
✅ vite-node 128 0 28
✅ vite-quickjs 128 0 28

✅ 💻 Local Development

App Passed Failed Skipped
✅ astro-stable-node 130 0 26
✅ astro-stable-quickjs 130 0 26
✅ express-stable-node 130 0 26
✅ express-stable-quickjs 130 0 26
✅ fastify-stable-node 130 0 26
✅ fastify-stable-quickjs 130 0 26
✅ hono-stable-node 130 0 26
✅ hono-stable-quickjs 130 0 26
✅ nest-stable-node 130 0 26
✅ nest-stable-quickjs 130 0 26
✅ nextjs-turbopack-canary-node 137 0 19
✅ nextjs-turbopack-canary-quickjs 137 0 19
✅ nextjs-turbopack-stable-node 156 0 0
✅ nextjs-turbopack-stable-quickjs 156 0 0
✅ nextjs-webpack-canary-node 137 0 19
✅ nextjs-webpack-canary-quickjs 137 0 19
✅ nextjs-webpack-stable-node 156 0 0
✅ nextjs-webpack-stable-quickjs 156 0 0
✅ nitro-stable-node 130 0 26
✅ nitro-stable-quickjs 130 0 26
✅ nuxt-stable-node 130 0 26
✅ nuxt-stable-quickjs 130 0 26
✅ sveltekit-stable-node 149 0 7
✅ sveltekit-stable-quickjs 149 0 7
✅ tanstack-start-node 130 0 26
✅ tanstack-start-quickjs 130 0 26
✅ vite-stable-node 130 0 26
✅ vite-stable-quickjs 130 0 26

✅ 📦 Local Production

App Passed Failed Skipped
✅ astro-stable-node 130 0 26
✅ astro-stable-quickjs 130 0 26
✅ express-stable-node 130 0 26
✅ express-stable-quickjs 130 0 26
✅ fastify-stable-node 130 0 26
✅ fastify-stable-quickjs 130 0 26
✅ hono-stable-node 130 0 26
✅ hono-stable-quickjs 130 0 26
✅ nest-stable-node 130 0 26
✅ nest-stable-quickjs 130 0 26
✅ nextjs-turbopack-canary-node 137 0 19
✅ nextjs-turbopack-canary-quickjs 137 0 19
✅ nextjs-turbopack-stable-node 156 0 0
✅ nextjs-turbopack-stable-quickjs 156 0 0
✅ nextjs-webpack-canary-node 137 0 19
✅ nextjs-webpack-canary-quickjs 137 0 19
✅ nextjs-webpack-stable-node 156 0 0
✅ nextjs-webpack-stable-quickjs 156 0 0
✅ nitro-stable-node 130 0 26
✅ nitro-stable-quickjs 130 0 26
✅ nuxt-stable-node 130 0 26
✅ nuxt-stable-quickjs 130 0 26
✅ sveltekit-stable-node 149 0 7
✅ sveltekit-stable-quickjs 149 0 7
✅ tanstack-start-node 130 0 26
✅ tanstack-start-quickjs 130 0 26
✅ vite-stable-node 130 0 26
✅ vite-stable-quickjs 130 0 26

✅ 🐘 Local Postgres

App Passed Failed Skipped
✅ astro-stable-node 130 0 26
✅ astro-stable-quickjs 130 0 26
✅ express-stable-node 130 0 26
✅ express-stable-quickjs 130 0 26
✅ fastify-stable-node 130 0 26
✅ fastify-stable-quickjs 130 0 26
✅ hono-stable-node 130 0 26
✅ hono-stable-quickjs 130 0 26
✅ nest-stable-node 130 0 26
✅ nest-stable-quickjs 130 0 26
✅ nextjs-turbopack-canary-node 137 0 19
✅ nextjs-turbopack-canary-quickjs 137 0 19
✅ nextjs-turbopack-stable-node 156 0 0
✅ nextjs-turbopack-stable-quickjs 156 0 0
✅ nextjs-webpack-canary-node 137 0 19
✅ nextjs-webpack-canary-quickjs 137 0 19
✅ nextjs-webpack-stable-node 156 0 0
✅ nextjs-webpack-stable-quickjs 156 0 0
✅ nitro-stable-node 130 0 26
✅ nitro-stable-quickjs 130 0 26
✅ nuxt-stable-node 130 0 26
✅ nuxt-stable-quickjs 130 0 26
✅ sveltekit-stable-node 149 0 7
✅ sveltekit-stable-quickjs 149 0 7
✅ tanstack-start-node 130 0 26
✅ tanstack-start-quickjs 130 0 26
✅ vite-stable-node 130 0 26
✅ vite-stable-quickjs 130 0 26

✅ 🪟 Windows

App Passed Failed Skipped
✅ nextjs-turbopack-node 156 0 0
✅ nextjs-turbopack-quickjs 156 0 0

✅ vercel-multi-region

App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

📋 View full workflow run

@github-actions

github-actions Bot 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

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 324 (±0%) 1369 🔴 (+30%) 🔻 1393 🔴 (+24%) 🔻 1446 🔴 (+3.4%) 30
TTFS stream 1303 (+719%) 🔻 1363 🔴 (+27%) 🔻 1378 🔴 (+26%) 🔻 1450 🔴 (+17%) 🔻 30
TTFS hook + stream 1569 (+17%) 🔻 1643 🔴 (+13%) 1661 🔴 (+11%) 1688 🔴 (+7.4%) 30
STSO 1020 steps (inline) 114 (-10%) 159 (-16%) 💚 183 (-15%) 💚 491 (+16%) 🔻 1019
WO 1020 steps 168782 (-8.4%) 168782 (-8.4%) 168782 (-8.4%) 168782 (-8.4%) 1
SL stream latency 84 (-3.4%) 105 🔴 (-17%) 💚 122 🔴 (-11%) 179 🔴 (-63%) 💚 30
SO stream overhead (text) 100 (-7.4%) 149 (-26%) 💚 160 (-49%) 💚 190 (-60%) 💚 30
SO stream 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

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 220 (-32%) 💚 1404 🔴 (+33%) 🔻 1449 🔴 (+29%) 🔻 1799 🔴 (+29%) 🔻 30
TTFS stream 224 (+41%) 🔻 1378 🔴 (+28%) 🔻 1394 🔴 (+28%) 🔻 1451 🔴 (+17%) 🔻 30
TTFS hook + stream 411 (-69%) 💚 1665 🔴 (+14%) 1754 🔴 (+17%) 🔻 1846 🔴 (+18%) 🔻 30
STSO 1020 steps (inline) 136 (+7.1%) 213 (+13%) 249 (+15%) 🔻 442 (+4.5%) 1019
WO 1020 steps 213097 (+16%) 🔻 213097 (+16%) 🔻 213097 (+16%) 🔻 213097 (+16%) 🔻 1
SL stream latency 100 (+15%) 171 🔴 (+35%) 🔻 192 🔴 (+40%) 🔻 980 🔴 (+102%) 🔻 30
SO stream overhead (text) 131 (+21%) 🔻 273 🔴 (+36%) 🔻 420 (+34%) 🔻 913 (+92%) 🔻 30
SO stream 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

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1333 (+26%) 🔻 1498 🔴 (+27%) 🔻 1566 🔴 (+30%) 🔻 1860 🔴 (+8.8%) 30
TTFS stream 1420 (+36%) 🔻 1496 🔴 (+31%) 🔻 1545 🔴 (+33%) 🔻 1598 🔴 (+32%) 🔻 30
TTFS hook + stream 1638 (+31%) 🔻 1797 🔴 (+31%) 🔻 1832 🔴 (+31%) 🔻 2111 🔴 (+40%) 🔻 30
STSO 1020 steps (inline) 115 (+20%) 🔻 153 (-6.7%) 169 (-17%) 💚 253 (-67%) 💚 1019
WO 1020 steps 154672 (-9.8%) 154672 (-9.8%) 154672 (-9.8%) 154672 (-9.8%) 1
SL stream latency 119 (+31%) 🔻 140 🔴 (-9.1%) 170 🔴 (-1.2%) 362 🔴 (-35%) 💚 30
SO stream overhead (text) 134 (+1.5%) 214 (-22%) 💚 224 (-32%) 💚 290 (-63%) 💚 30
SO stream 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

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 224 (-73%) 💚 1512 🔴 (+36%) 🔻 1522 🔴 (+33%) 🔻 1758 🔴 (+49%) 🔻 30
TTFS stream 246 (+19%) 🔻 1487 🔴 (+35%) 🔻 1505 🔴 (+36%) 🔻 1534 🔴 (+33%) 🔻 30
TTFS hook + stream 401 (-68%) 💚 1674 🔴 (+21%) 🔻 1742 🔴 (+22%) 🔻 1808 🔴 (+17%) 🔻 30
STSO 1020 steps (inline) 103 (+2.0%) 153 (+3.4%) 178 (+4.7%) 298 (-0.7%) 1019
WO 1020 steps 149862 (+5.7%) 149862 (+5.7%) 149862 (+5.7%) 149862 (+5.7%) 1
SL stream latency 98 (+13%) 193 🔴 (+71%) 🔻 446 🔴 (+257%) 🔻 1484 🔴 (+945%) 🔻 30
SO stream overhead (text) 122 (+11%) 250 (+33%) 🔻 376 (+77%) 🔻 1304 🔴 (+446%) 🔻 30
SO stream 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

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1220 (+45%) 🔻 1308 🔴 (+18%) 🔻 1332 🔴 (+17%) 🔻 1394 🔴 (+18%) 🔻 30
TTFS stream 1213 (+486%) 🔻 1384 🔴 (+26%) 🔻 1404 🔴 (+27%) 🔻 1434 🔴 (+24%) 🔻 30
TTFS hook + stream 1448 (+16%) 🔻 1646 🔴 (+19%) 🔻 1750 🔴 (+23%) 🔻 1822 🔴 (+18%) 🔻 30
STSO 1020 steps (inline) 96 (-5.0%) 143 (-3.4%) 162 (-4.7%) 398 (+33%) 🔻 1019
WO 1020 steps 144978 (+2.2%) 144978 (+2.2%) 144978 (+2.2%) 144978 (+2.2%) 1
SL stream latency 104 (+20%) 🔻 141 🔴 (+25%) 🔻 248 🔴 (+98%) 🔻 4139 🔴 (+2815%) 🔻 30
SO stream overhead (text) 133 (+21%) 🔻 254 🔴 (+35%) 🔻 330 (+56%) 🔻 587 (+146%) 🔻 30
SO stream 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

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1322 (+291%) 🔻 1401 🔴 (+25%) 🔻 1419 🔴 (+24%) 🔻 1531 🔴 (±0%) 30
TTFS stream 997 (±0%) 1398 🔴 (+34%) 🔻 1415 🔴 (+34%) 🔻 1475 🔴 (+36%) 🔻 30
TTFS hook + stream 453 (-64%) 💚 1656 🔴 (+19%) 🔻 1691 🔴 (+15%) 1823 🔴 (+8.3%) 30
STSO 1020 steps (inline) 103 (+8.4%) 134 (-11%) 151 (-16%) 💚 267 (-25%) 💚 1019
WO 1020 steps 135938 (-8.2%) 135938 (-8.2%) 135938 (-8.2%) 135938 (-8.2%) 1
SL stream latency 94 (+9.3%) 129 🔴 (-0.8%) 191 🔴 (+9.1%) 422 🔴 (+94%) 🔻 30
SO stream overhead (text) 118 (+28%) 🔻 209 (+12%) 223 (+4.2%) 330 (+31%) 🔻 30
SO stream 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

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1294 (+525%) 🔻 1356 🔴 (+25%) 🔻 1372 🔴 (+22%) 🔻 1514 🔴 (-6.5%) 30
TTFS stream 1292 (+576%) 🔻 1344 🔴 (+25%) 🔻 1352 🔴 (+24%) 🔻 1368 🔴 (+23%) 🔻 30
TTFS hook + stream 1555 (+69%) 🔻 1654 🔴 (+21%) 🔻 1705 🔴 (+16%) 🔻 1800 🔴 (+18%) 🔻 30
STSO 1020 steps (inline) 101 (+31%) 🔻 126 (-14%) 143 (-19%) 💚 245 (-51%) 💚 1019
WO 1020 steps 127611 (-17%) 💚 127611 (-17%) 💚 127611 (-17%) 💚 127611 (-17%) 💚 1
SL stream latency 88 (+2.3%) 121 🔴 (-18%) 💚 137 🔴 (-15%) 152 🔴 (-50%) 💚 30
SO stream overhead (text) 105 (-15%) 💚 163 (-39%) 💚 229 (-30%) 💚 251 (-85%) 💚 30
SO stream 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

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 232 (-76%) 💚 1445 🔴 (+20%) 🔻 1560 🔴 (+25%) 🔻 1871 🔴 (+40%) 🔻 30
TTFS stream 246 (-5.0%) 1423 🔴 (+26%) 🔻 1464 🔴 (+23%) 🔻 1551 🔴 (+11%) 30
TTFS hook + stream 406 (-5.4%) 1665 🔴 (+10%) 1684 🔴 (+3.0%) 2292 🔴 (+8.4%) 30
STSO 1020 steps (inline) 97 (+5.4%) 153 (-6.7%) 178 (-7.3%) 293 (-32%) 💚 1019
WO 1020 steps 151031 (-5.5%) 151031 (-5.5%) 151031 (-5.5%) 151031 (-5.5%) 1
SL stream latency 103 (-8.0%) 185 🔴 (-2.1%) 218 🔴 (-50%) 💚 327 🔴 (-44%) 💚 30
SO stream overhead (text) 150 (+9.5%) 326 🔴 (+37%) 🔻 504 🔴 (+36%) 🔻 3837 🔴 (+205%) 🔻 30
SO stream 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.

@NathanColosimo NathanColosimo changed the title feat(core): add atomic start hook admission [core] Add atomic start Hook admission Aug 10, 2026
@NathanColosimo
NathanColosimo force-pushed the codex/atomic-start-core branch from 5ea20bb to 3da6597 Compare August 10, 2026 20:47
@NathanColosimo
NathanColosimo force-pushed the codex/atomic-start-core branch from 06bd237 to 176b429 Compare August 10, 2026 22:41
Comment thread packages/core/src/runtime/start.ts Outdated
@github-actions

github-actions Bot 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

scenario outcome events virt replay violations
smoke-no-steps completed 3 0ms ok 0
smoke-one-step completed 6 0ms ok 0
hook-at-step-started completed 12 0ms ok 0
hook-at-step-completed completed 12 0ms ok 0
hook-at-hook-created completed 12 0ms ok 0
deadline-hook-wins completed 7 1.0h ok 0
deadline-expires completed 7 1.0h ok 0
long-sleep completed 11 30.0d ok 0
hook-never-arrives stalled 3 0ms skipped 0
step-retries-twice completed 10 2.0s ok 0
parallel-steps completed 9 0ms ok 0
hook-on-execution-state completed 12 0ms ok 0
peek-hook-before-branch completed 12 0ms ok 0
peek-hook-after-branch completed 12 0ms ok 0
peek-hook-at-registration completed 12 0ms ok 0
race-hook-before-probe completed 12 0ms ok 0
race-hook-after-probe completed 12 0ms ok 0
race-duplicate-delivery completed 13 0ms ok 0
attr-hook-before-step completed 11 0ms ok 0
attr-hook-after-step completed 11 0ms ok 0
attr-from-step-body completed 13 0ms ok 0
fork-hook-after-timeout completed 14 1.0m ok 0
fork-hook-before-timeout completed 14 1.0m ok 0
count-hook-after-timeout completed 17 1.0m ok 0
count-hook-before-timeout completed 20 1.0m ok 0
stale-read-step-count-fork completed 17 1.0m MISMATCH 1
stale-read-equal-step-counts completed 14 1.0m MISMATCH 1
step-vs-step-fork completed 12 0ms MISMATCH 1
step-vs-step-fork-fenced completed 12 0ms MISMATCH 1
fence-catches-benign-direction completed 12 5ms ok 0
in-flight-before-decision completed 17 1.0m MISMATCH 1
in-flight-before-decision-counted completed 20 1.0m ok 0
in-flight-after-decision failed 14 2.0m MISMATCH 1
stale-read-step-count-fork-fenced completed 20 1.0m ok 0
fork-hook-wins completed 13 1.0m ok 0
fork-timeout-wins completed 13 1.0m ok 0
unclaimed-payload-under-fork completed 17 1.0m ok 0
claimed-payload-under-fork completed 17 1.0m ok 0
writers-independent-step-bodies completed 12 0ms ok 0
writers-scripted-tempo completed 12 0ms ok 0
cancel-mid-step cancelled 7 0ms skipped 0

Full trace: world-sim-mint.txt

🟢 Append-only log — 0 fail of 41 total

log=append-only · fence=per-spec

scenario outcome events virt replay violations
smoke-no-steps completed 3 0ms ok 0
smoke-one-step completed 6 0ms ok 0
hook-at-step-started completed 12 0ms ok 0
hook-at-step-completed completed 12 0ms ok 0
hook-at-hook-created completed 12 0ms ok 0
deadline-hook-wins completed 7 1.0h ok 0
deadline-expires completed 7 1.0h ok 0
long-sleep completed 11 30.0d ok 0
hook-never-arrives stalled 3 0ms skipped 0
step-retries-twice completed 10 2.0s ok 0
parallel-steps completed 9 0ms ok 0
hook-on-execution-state completed 12 0ms ok 0
peek-hook-before-branch completed 12 0ms ok 0
peek-hook-after-branch completed 12 0ms ok 0
peek-hook-at-registration completed 12 0ms ok 0
race-hook-before-probe completed 12 0ms ok 0
race-hook-after-probe completed 12 0ms ok 0
race-duplicate-delivery completed 13 0ms ok 0
attr-hook-before-step completed 11 0ms ok 0
attr-hook-after-step completed 11 0ms ok 0
attr-from-step-body completed 13 0ms ok 0
fork-hook-after-timeout completed 14 1.0m ok 0
fork-hook-before-timeout completed 14 1.0m ok 0
count-hook-after-timeout completed 17 1.0m ok 0
count-hook-before-timeout completed 20 1.0m ok 0
stale-read-step-count-fork completed 20 1.0m ok 0
stale-read-equal-step-counts completed 14 1.0m ok 0
step-vs-step-fork completed 12 0ms ok 0
step-vs-step-fork-fenced completed 12 0ms ok 0
fence-catches-benign-direction completed 12 5ms ok 0
in-flight-before-decision completed 17 1.0m ok 0
in-flight-before-decision-counted completed 17 1.0m ok 0
in-flight-after-decision completed 19 2.0m ok 0
stale-read-step-count-fork-fenced completed 20 1.0m ok 0
fork-hook-wins completed 13 1.0m ok 0
fork-timeout-wins completed 13 1.0m ok 0
unclaimed-payload-under-fork completed 17 1.0m ok 0
claimed-payload-under-fork completed 17 1.0m ok 0
writers-independent-step-bodies completed 12 0ms ok 0
writers-scripted-tempo completed 12 0ms ok 0
cancel-mid-step cancelled 7 0ms skipped 0

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

Comment thread packages/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

@VaguelySerious VaguelySerious Aug 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.

@VaguelySerious VaguelySerious 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

@VaguelySerious VaguelySerious 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.

// 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.

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.

@vercel vercel Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Additional Suggestion:

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants