Skip to content

fix: version stream tail cursors across worlds - #3475

Open
NathanColosimo wants to merge 9 commits into
mainfrom
codex/stream-tail-worlds
Open

fix: version stream tail cursors across worlds#3475
NathanColosimo wants to merge 9 commits into
mainfrom
codex/stream-tail-worlds

Conversation

@NathanColosimo

@NathanColosimo NathanColosimo commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Description

Define a stream cursor as the checkpoint after a returned page. A non-empty page at the tail of an open stream keeps that checkpoint so a later read resumes after the delivered chunks. A completed final page returns cursor: null, including when its size exactly equals the requested limit.

Apply that contract to the Local, Postgres, Sim, and Vercel Worlds. The Vercel World reads chunk pages through the versioned v3 API. Local reads begin directly at the cursor index, while Postgres decodes one cursor containing both the row key and logical chunk index.

Release workflow and the three concrete Worlds together. The acyclic peer constraints are workflow -> Local/Vercel (optional) and Postgres -> workflow. Literal peers in both directions are not representable in this Turborepo package graph because workflow already depends on Local/Vercel through core, while Postgres test infrastructure already depends on workflow; reverse peer edges form build cycles.

This is the bottom PR in the stack; #3430 updates dashboard and core consumers on top.

How did you test your changes?

  • Full 29-package build passed (28 build tasks).
  • Affected package typechecks passed.
  • Local World test suite passed (547 tests).
  • Vercel World test suite passed (502 tests), including the v3 chunk endpoint.
  • Package tarballs were inspected to verify workspace peer ranges publish as concrete caret ranges.
  • Postgres tests require a local container runtime and remain covered by GitHub CI.

PR Checklist - Required to merge

  • 📦 pnpm changeset was run to create a changelog for this PR
  • 🔒 DCO sign-off passes
  • 📝 Ping @vercel/workflow in a comment once the PR is ready, and the above checklist is complete

@NathanColosimo
NathanColosimo requested a review from a team as a code owner August 11, 2026 21:38
@changeset-bot

changeset-bot Bot commented Aug 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e15cc02

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

This PR includes changesets to release 20 packages
Name Type
@workflow/world Patch
@workflow/world-local Patch
@workflow/world-postgres Patch
@workflow/world-vercel Patch
workflow Patch
@workflow/cli Patch
@workflow/core Patch
@workflow/vitest Patch
@workflow/web-shared Patch
@workflow/web Patch
@workflow/world-testing Patch
@workflow/builders Patch
@workflow/next Patch
@workflow/nitro 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 11, 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 13, 2026 11:24pm
example-nextjs-workflow-webpack Ready Ready Preview Aug 13, 2026 11:24pm
example-workflow Ready Ready Preview Aug 13, 2026 11:24pm
workbench-astro-workflow Ready Ready Preview Aug 13, 2026 11:24pm
workbench-express-workflow Ready Ready Preview Aug 13, 2026 11:24pm
workbench-fastify-workflow Ready Ready Preview Aug 13, 2026 11:24pm
workbench-hono-workflow Ready Ready Preview Aug 13, 2026 11:24pm
workbench-nestjs-workflow Ready Ready Preview Aug 13, 2026 11:24pm
workbench-nitro-workflow Ready Ready Preview Aug 13, 2026 11:24pm
workbench-nuxt-workflow Ready Ready Preview Aug 13, 2026 11:24pm
workbench-python-workflow Error Error Aug 13, 2026 11:24pm
workbench-sveltekit-workflow Ready Ready Preview Aug 13, 2026 11:24pm
workbench-tanstack-start-workflow Ready Ready Preview Aug 13, 2026 11:24pm
workbench-vite-workflow Ready Ready Preview Aug 13, 2026 11:24pm
workflow-docs Ready Ready Preview, v0 Aug 13, 2026 11:24pm
workflow-swc-playground Ready Ready Preview Aug 13, 2026 11:24pm
workflow-tarballs Ready Ready Preview Aug 13, 2026 11:24pm
workflow-web Ready Ready Preview Aug 13, 2026 11:24pm

@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

❌ Failed E2E Tests

▲ Vercel Production (27 failed)

astro-node (1 failed):

  • outputStreamWorkflow - getTailIndex and getChunks getChunks returns same content as reading the stream

astro-quickjs (2 failed):

  • outputStreamWorkflow - getTailIndex and getChunks getChunks returns same content as reading the stream
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control) | wrun_41KZYSNAH70GVTYX8542J4C4A7 | 🔍 observability

example-node (1 failed):

  • outputStreamWorkflow - getTailIndex and getChunks getChunks returns same content as reading the stream

example-quickjs (1 failed):

  • outputStreamWorkflow - getTailIndex and getChunks getChunks returns same content as reading the stream

express-node (1 failed):

  • outputStreamWorkflow - getTailIndex and getChunks getChunks returns same content as reading the stream

express-quickjs (1 failed):

  • outputStreamWorkflow - getTailIndex and getChunks getChunks returns same content as reading the stream

fastify-node (1 failed):

  • outputStreamWorkflow - getTailIndex and getChunks getChunks returns same content as reading the stream

fastify-quickjs (1 failed):

  • outputStreamWorkflow - getTailIndex and getChunks getChunks returns same content as reading the stream

hono-node (1 failed):

  • outputStreamWorkflow - getTailIndex and getChunks getChunks returns same content as reading the stream

hono-quickjs (1 failed):

  • outputStreamWorkflow - getTailIndex and getChunks getChunks returns same content as reading the stream

nest-node (1 failed):

  • outputStreamWorkflow - getTailIndex and getChunks getChunks returns same content as reading the stream

nest-quickjs (1 failed):

  • outputStreamWorkflow - getTailIndex and getChunks getChunks returns same content as reading the stream

nextjs-turbopack-node (1 failed):

  • outputStreamWorkflow - getTailIndex and getChunks getChunks returns same content as reading the stream

nextjs-turbopack-quickjs (1 failed):

  • outputStreamWorkflow - getTailIndex and getChunks getChunks returns same content as reading the stream

nextjs-webpack-node (1 failed):

  • outputStreamWorkflow - getTailIndex and getChunks getChunks returns same content as reading the stream

nextjs-webpack-quickjs (1 failed):

  • outputStreamWorkflow - getTailIndex and getChunks getChunks returns same content as reading the stream

nitro-node (1 failed):

  • outputStreamWorkflow - getTailIndex and getChunks getChunks returns same content as reading the stream

nitro-quickjs (1 failed):

  • outputStreamWorkflow - getTailIndex and getChunks getChunks returns same content as reading the stream

nuxt-node (1 failed):

  • outputStreamWorkflow - getTailIndex and getChunks getChunks returns same content as reading the stream

nuxt-quickjs (1 failed):

  • outputStreamWorkflow - getTailIndex and getChunks getChunks returns same content as reading the stream

sveltekit-node (1 failed):

  • outputStreamWorkflow - getTailIndex and getChunks getChunks returns same content as reading the stream

sveltekit-quickjs (1 failed):

  • outputStreamWorkflow - getTailIndex and getChunks getChunks returns same content as reading the stream

tanstack-start-node (1 failed):

  • outputStreamWorkflow - getTailIndex and getChunks getChunks returns same content as reading the stream

tanstack-start-quickjs (1 failed):

  • outputStreamWorkflow - getTailIndex and getChunks getChunks returns same content as reading the stream

vite-node (1 failed):

  • outputStreamWorkflow - getTailIndex and getChunks getChunks returns same content as reading the stream

vite-quickjs (1 failed):

  • outputStreamWorkflow - getTailIndex and getChunks getChunks returns same content as reading the stream

E2E Test Summary

Summary
Passed Failed Skipped Total
❌ ▲ Vercel Production 3439 27 590 4056
✅ 💻 Local Development 3536 0 520 4056
✅ 📦 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 14934 27 2226 17187
Details by Category

❌ ▲ Vercel Production

App Passed Failed Skipped
❌ astro-node 127 1 28
❌ astro-quickjs 126 2 28
❌ example-node 127 1 28
❌ example-quickjs 127 1 28
❌ express-node 127 1 28
❌ express-quickjs 127 1 28
❌ fastify-node 127 1 28
❌ fastify-quickjs 127 1 28
❌ hono-node 127 1 28
❌ hono-quickjs 127 1 28
❌ nest-node 127 1 28
❌ nest-quickjs 127 1 28
❌ nextjs-turbopack-node 152 1 3
❌ nextjs-turbopack-quickjs 152 1 3
❌ nextjs-webpack-node 152 1 3
❌ nextjs-webpack-quickjs 152 1 3
❌ nitro-node 127 1 28
❌ nitro-quickjs 127 1 28
❌ nuxt-node 127 1 28
❌ nuxt-quickjs 127 1 28
❌ sveltekit-node 146 1 9
❌ sveltekit-quickjs 146 1 9
❌ tanstack-start-node 127 1 28
❌ tanstack-start-quickjs 127 1 28
❌ vite-node 127 1 28
❌ vite-quickjs 127 1 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-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 11, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit e15cc02 · Thu, 13 Aug 2026 23:44:27 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 542 (+42%) 🔻 1466 🔴 (+31%) 🔻 1533 🔴 (+35%) 🔻 1618 🔴 (+5.6%) 30
TTFS stream 1367 (+36%) 🔻 1447 🔴 (+37%) 🔻 1483 🔴 (+39%) 🔻 1521 🔴 (+37%) 🔻 30
TTFS hook + stream 1344 (+5.2%) 1760 🔴 (+27%) 🔻 1776 🔴 (+24%) 🔻 1953 🔴 (+21%) 🔻 30
Fan-out TTFS Promise.all(100 steps) 8931 (±0%) 10325 (+3.7%) 10355 (+2.7%) 15841 (+17%) 🔻 10
Fan-out TTLS Promise.all(100 steps) 17271 (-2.3%) 18871 (±0%) 19137 (+0.7%) 26155 (+12%) 10
STSO 1020 steps (inline) 123 (±0%) 182 (-4.7%) 220 (-3.9%) 421 (-27%) 💚 1019
WO 1020 steps 185235 (-5.2%) 185235 (-5.2%) 185235 (-5.2%) 185235 (-5.2%) 1
SL stream latency 96 (+22%) 🔻 168 🔴 (+53%) 🔻 331 🔴 (+157%) 🔻 539 🔴 (+57%) 🔻 30
SO stream overhead (text) 135 (+22%) 🔻 230 (+28%) 🔻 283 (+37%) 🔻 371 (-39%) 💚 30
SO stream overhead (structured) 134 (+40%) 🔻 342 🔴 (+119%) 🔻 463 (+177%) 🔻 616 (+238%) 🔻 30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 194368ms → this run 183701ms (Δ -10667ms, -5%)

  100-150 ms  █████┃                    main 180  this 162   -18
  150-200 ms  ██████████████████████░┃  main 627  this 693   +66
  200-250 ms  ███┃█                     main 134  this 109   -25
  250-300 ms  ┃                         main  29  this  20    -9
  300-350 ms  ┃                         main  15  this   9    -6
  350-400 ms  ┃                         main  11  this  10    -1
  400-450 ms  ┃                         main   4  this   8    +4
  450-500 ms  ┃                         main   5  this   2    -3
  500-550 ms  ┃                         main   3  this   1    -2
  550-600 ms  ┃                         main   1  this   2    +1
  600-650 ms  ┃                         main   5  this   1    -4
  650-700 ms  ┃                         main   1  this   0    -1
  750-800 ms  ┃                         main   1  this   1    +0
  800-850 ms  ┃                         main   1  this   0    -1
  850-900 ms  ┃                         main   0  this   1    +1
1100-1150 ms  ┃                         main   1  this   0    -1
4450-4500 ms  ┃                         main   1  this   0    -1
📜 Previous results (4)

4f319cc

Thu, 13 Aug 2026 00:17:06 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 255 (+5.8%) 1531 🔴 (+34%) 🔻 1563 🔴 (+34%) 🔻 1769 🔴 (+40%) 🔻 30
TTFS stream 267 (-2.6%) 1540 🔴 (+32%) 🔻 1555 🔴 (+28%) 🔻 1600 🔴 (+2.0%) 30
TTFS hook + stream 394 (-1.5%) 1779 🔴 (+28%) 🔻 1845 🔴 (+30%) 🔻 1920 🔴 (+29%) 🔻 30
STSO 1020 steps (inline) 139 (+23%) 🔻 197 (-6.6%) 231 (-8.3%) 361 (-13%) 1019
WO 1020 steps 195657 (-6.3%) 195657 (-6.3%) 195657 (-6.3%) 195657 (-6.3%) 1
SL stream latency 108 (+11%) 214 🔴 (+33%) 🔻 351 🔴 (+63%) 🔻 418 🔴 (+12%) 30
SO stream overhead (text) 119 (-21%) 💚 248 (-13%) 427 (-30%) 💚 602 (-22%) 💚 30
SO stream overhead (structured) 129 (-2.3%) 225 (-15%) 💚 236 (-35%) 💚 413 (-98%) 💚 30

5d22ad6

Wed, 12 Aug 2026 20:58:44 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1308 (+34%) 🔻 1432 🔴 (+28%) 🔻 1489 🔴 (+24%) 🔻 1851 🔴 (+15%) 30
TTFS stream 644 (-39%) 💚 1415 🔴 (+26%) 🔻 1455 🔴 (+26%) 🔻 1559 🔴 (+29%) 🔻 30
TTFS hook + stream 1565 (+228%) 🔻 1685 🔴 (+17%) 🔻 1776 🔴 (+19%) 🔻 2140 🔴 (+30%) 🔻 30
STSO 1020 steps (inline) 137 (+30%) 🔻 179 (+5.9%) 199 (-0.5%) 334 (-8.2%) 1019
WO 1020 steps 178302 (+7.0%) 178302 (+7.0%) 178302 (+7.0%) 178302 (+7.0%) 1
SL stream latency 104 (+18%) 🔻 138 🔴 (+6.2%) 257 🔴 (+28%) 🔻 379 🔴 (+22%) 🔻 30
SO stream overhead (text) 119 (-3.3%) 210 (-0.9%) 288 (-11%) 500 (-62%) 💚 30
SO stream overhead (structured) 117 (+21%) 🔻 182 (-16%) 💚 202 (-20%) 💚 272 (-98%) 💚 30

b827cd8

Wed, 12 Aug 2026 17:33:55 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1337 (+31%) 🔻 1506 🔴 (+40%) 🔻 1519 🔴 (+34%) 🔻 1572 🔴 (-3.6%) 30
TTFS stream 356 (+37%) 🔻 1442 🔴 (+36%) 🔻 1510 🔴 (+38%) 🔻 1737 🔴 (+42%) 🔻 30
TTFS hook + stream 485 (+10.0%) 1817 🔴 (+31%) 🔻 1900 🔴 (+33%) 🔻 2082 🔴 (+30%) 🔻 30
STSO 1020 steps (inline) 133 (+22%) 🔻 190 (+2.7%) 221 (+2.8%) 392 (±0%) 1019
WO 1020 steps 190170 (+5.3%) 190170 (+5.3%) 190170 (+5.3%) 190170 (+5.3%) 1
SL stream latency 97 (+4.3%) 142 🔴 (-3.4%) 168 🔴 (+9.1%) 186 🔴 (-15%) 30
SO stream overhead (text) 134 (+24%) 🔻 229 (±0%) 343 (+20%) 🔻 558 (-42%) 💚 30
SO stream overhead (structured) 124 (-1.6%) 213 (-17%) 💚 252 (-16%) 💚 351 (-61%) 💚 30

69e8f56

Tue, 11 Aug 2026 21:58:51 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 208 (-36%) 💚 1420 🔴 (+35%) 🔻 1475 🔴 (+32%) 🔻 1492 🔴 (+6.7%) 30
TTFS stream 270 (+70%) 🔻 1426 🔴 (+33%) 🔻 1520 🔴 (+39%) 🔻 1570 🔴 (+27%) 🔻 30
TTFS hook + stream 439 (-67%) 💚 1688 🔴 (+16%) 🔻 1802 🔴 (+21%) 🔻 1859 🔴 (+18%) 🔻 30
STSO 1020 steps (inline) 144 (+13%) 215 (+14%) 253 (+17%) 🔻 446 (+5.4%) 1019
WO 1020 steps 216049 (+17%) 🔻 216049 (+17%) 🔻 216049 (+17%) 🔻 216049 (+17%) 🔻 1
SL stream latency 101 (+16%) 🔻 176 🔴 (+39%) 🔻 226 🔴 (+65%) 🔻 556 🔴 (+15%) 30
SO stream overhead (text) 129 (+19%) 🔻 298 🔴 (+48%) 🔻 420 (+34%) 🔻 743 (+56%) 🔻 30
SO stream overhead (structured) 131 (+22%) 🔻 307 🔴 (+73%) 🔻 373 (+55%) 🔻 411 (-6.8%) 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) · Fan-out TTFS: fan-out time to first step (in-deployment start() → first of the parallel step bodies to complete) · Fan-out TTLS: fan-out time to last step (in-deployment start() → last of the parallel step bodies to complete, i.e. when the Promise.all resolves) · 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 · Promise.all(100 steps): 100 trivial no-op steps started together in a single Promise.all; Fan-out TTFS is the first of them to complete and Fan-out TTLS the last, both from the in-deployment clientStart, so their gap is the spread the runtime adds across the fan-out · 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. Fan-out TTFS/TTLS are the first and last step completions of a single Promise.all over trivial steps, from the same anchor, so the gap between the two rows is the spread the runtime adds across the fan-out. 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.

@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 — 3 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 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 failed 9 1.0m MISMATCH 1
in-flight-before-decision-counted failed 9 1.0m MISMATCH 1
in-flight-after-decision failed 9 1.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

@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

/** Array of stream chunks in index order */
data: StreamChunk[];
/** Cursor for the next page, or `null` when no more pages are available */
/** Position after the returned chunks, or `null` when none were returned or the stream ended */

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

Redefining what cursor means on an already-published interface is a flag day for anyone whose World and consumer versions do not move together, and @workflow/world-local, @workflow/world-postgres and @workflow/web are independently versioned. I tested both skew directions against the world-local implementation in this PR rather than reasoning about it:

  • New World, old consumer. The current @workflow/web reader absorbs the old contract's final-page overlap by skipping as many frames as the previous response contained. With a tail cursor there is no overlap left, so it skips live chunks instead: 3 chunks render and skipFrames becomes 3, two new chunks arrive, getChunks correctly returns just those two, and the reader renders neither. Silent data loss, including the terminal chunk of a completing stream.
  • Old World, new consumer. fix: resume open streams from tail cursors #3430 removes that skip and appends everything returned. Against an old-contract World the open tail has no cursor, so the reader cannot advance and re-appends the whole stream every poll. I measured 3 chunks becoming 12 rendered entries after two polls; it grows quadratically in poll count for as long as the run is active.

This also reaches third-party Worlds. docs/content/worlds/v4/building-a-world.mdx and the v5 copy are the contract community Worlds implement, and any World written against the current text returns cursor: null at the open tail, so it lands in the second case.

The additive alternative avoids all of this: return the checkpoint as a new field (resumeCursor or tailCursor) and leave cursor meaning "next page". Consumers opt in as they upgrade, old Worlds keep working with new consumers, and no version pairing becomes load-bearing. If cursor really has to change meaning in place, this needs a major on @workflow/world plus a peer-version floor on the consumers, not three patches.

Comment thread .changeset/stream-tail-world-cursors.md Outdated
'@workflow/world-postgres': patch
---

Return a checkpoint cursor after every non-empty open-stream chunk page so consumers can resume after the last delivered chunk without replaying it.

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

getChunks is public API and the docs still describe the old contract, so the doc updates belong in this PR (per the repo rule that user-facing changes ship with their docs):

  • docs/content/docs/v4/api-reference/workflow-runtime/world/streams.mdx and the v5 copy, around line 147: cursor is documented as "Cursor for the next page". Under this change it is a resume checkpoint that is present precisely when there is no next page.
  • Same files, around lines 201-209, document the pagination loop as do { ... cursor = result.cursor ?? undefined } while (cursor). Under the new contract that loop terminates only when it observes an empty page, so on a stream producing chunks faster than the loop drains it never terminates and silently becomes an unbounded tail-follow. It needs to become while (result.hasMore).
  • docs/content/worlds/v4/building-a-world.mdx and the v5 copy: the getChunks description for third-party World authors does not say when a cursor must be returned, and now it has to, since a World that gets this wrong breaks the dashboard reader rather than just paginating differently.
  • skills/workflow/SKILL.md around line 694 shows the same { limit, cursor } usage. Per CLAUDE.md, editing a skill file needs its frontmatter version bumped.

One thing worth writing down explicitly wherever the contract lands: an empty page returns cursor: null, so consumers must retain their previous cursor rather than assign the new one. Every consumer in this stack independently reimplements some form of cursor = result.cursor ?? cursor, which is a good sign the contract should carry that rule rather than leaving each caller to rediscover it.

/** Maximum number of chunks to return per page (default: 100, max: 1000) */
limit?: number;
/** Opaque cursor from a previous response to fetch the next page */
/** Opaque cursor from a previous response at which to resume */

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

packages/world-sim/src/streams.ts implements getChunks too and was not updated, so it still returns cursor: next < state.chunks.length ? String(next) : null, which is the old contract exactly. That leaves the simulation World disagreeing with world-local, world-postgres and world-vercel on the one behavior this PR changes. It is the kind of gap that lets a consumer regression pass World Sim and fail everywhere else. Worth updating in the same change, or noting why it is exempt.

data: resultChunks,
cursor: nextCursor,
cursor:
resultChunks.length > 0 && (hasMore || !streamDone)

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

Moving from "walk every file, count data files" to "index directly from startIndex" loses the EOF observation when the cursor is past the end. On main a cursor beyond the last data chunk still walked into the EOF marker and reported done: true; here the loop body never executes. I ran both:

  • main, cursor {"i":50} on a closed 1-chunk stream: {"data":0,"done":true,"cursor":null,"hasMore":false}
  • this PR: {"data":0,"done":false,"cursor":null,"hasMore":false}

A consumer polling until done never terminates on that stream. It is not reachable from a cursor this implementation issued, since the highest index it hands out equals the data-file count and lands exactly on the EOF file, so it takes a cursor that outlived its chunk files: a .workflow directory cleared under a dashboard that still holds a cursor, or a cursor crossing tag scopes. Cheap to close if you want it airtight: after the loop, when !streamDone && startIndex >= chunkFiles.length, peek the last file for the EOF byte.


for (const file of chunkFiles) {
for (
let fileIndex = startIndex;

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 Postgres streamer in this same PR gained real cursor validation (StreamCursorSchema with z.number().int().nonnegative()), but world-local still does startIndex = decoded.i unvalidated, with a catch that only covers base64 and JSON failures. That gap got worse rather than staying neutral, because startIndex now seeds a for loop bound instead of feeding < comparisons:

  • main, cursor {"i":"nope"}: {"data":1,"done":false}, falling back to reading from the start.
  • this PR: {"data":0,"done":false,"cursor":null}, a permanently empty page. And because cursor is null, the consumer cannot tell it apart from an idle tail, so it never recovers.

Reusing the same schema shape you added for Postgres would make the two consistent and turn this into a clean restart-from-zero.

Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>

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

The live stream poll in use-stream-reader.ts passes skipFrames = frameCountRef.current, which under the new getChunks cursor contract skips (silently drops) all freshly-fetched chunks during polling.

Fix on Vercel

Comment thread packages/workflow/package.json
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