Repository navigation
[core] Detect when out-of-band events can change a replay's path - #4683
Conversation
Classify, per inline-step boundary, whether any event an out-of-band writer could append (hook payloads, AbortController aborts, wait timers, steps run by other executors) is able to change the decisions the suspension leads to. Hooks are classified by whether workflow code is waiting on their next payload; waits by whether their resumeAt falls inside the invocation's inline window. Nothing branches on the result yet. It is recorded on the invocation span and documented as the gate a future run-ahead must consult, including the de-opt and pipeline-depth rules. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 3e3a561 The changes in this PR will be included in the next version bump. This PR includes changesets to release 17 packages
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 |
📊 Workflow Benchmarkscommit Backend:
Streams
📈 STSO distribution (inline / queue-hop histograms)1020 steps (inline) Cumulative STSO time: 154408ms over 1019 samples No 📈 CRTT drill-down (RTT distributions & profiles)No RTT over stream progress (avg per tenth of stream, bars scaled min→max): RTT by chunk size (avg per log size bin, ~160B → ~12KB serialized, bars scaled min→max): Delivery jitter over stream progress (avg positive CDV per tenth of stream, bars scaled min→max): 📜 Previous results (1)d086b1eTue, 06 Oct 2026 19:53:34 GMT · run logs
Streams
ℹ️ Metric definitions & methodologyStreams: first-chunk RTT (the stream-open path, before any buffering/backpressure), CRTT percentiles, and worst delivery stall (CDV max). Cells are medians across iterations; per-run values in the artifacts. No 🔴/🟢 marks until targets attach. The collapsed STSO distribution section above buckets every step gap, split inline (same warm process — pure framework overhead) vs queue-hop (fresh process — dispatch, reinit, replay). The collapsed CRTT drill-down: per-variant RTT histograms (fixed log bins, Metrics — TTFS: time to first step body (in-deployment start() → first step body) · 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) · CRTT: chunk round-trip time (per-chunk write → read latency, one clock domain: deployment → stream backend → same deployment) · CDV: chunk delay variation / delivery jitter (inter-arrival gap minus inter-write gap per seq-adjacent pair; skew-free; the row is each run's MAX positive value, so one stall moves it) 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 · paced control (100/s, 60B): the control: 300 tiny (~60B) deltas metronome-paced at 100/s — zero workload structure, so it reads the transport floor and flush cadence, and disambiguates transport-wide vs workload-specific when a replay row moves · size sweep (100/s, 160B-12KB): same pacing as the control with deltas padded in rotation across seven log-spaced sizes (~160B–12KB) — rotation decouples size from stream position, so it isolates whether chunk size causes latency · replay gateway-gpt-5.4-nano-2000t (1x): raw provider SSE cadence captured at the AI gateway boundary (gpt-5.4-nano, the most popular gateway model; per-token deltas p50 208B = the modal production chunk size), replayed exactly as measured — the typical customer's workload; its CDV is the typical customer's real delivery jitter · replay eve-gpt-5.6-sol-2000t (1x): a captured eve turn (gpt-5.6-sol, the most-used demanding eve model; ~2000 output tokens = production p50 turn length) replayed exactly as measured — eve's envelope protocol re-ships the cumulative message so sizes ramp 142B→13KB; the demanding outlier tenant's reality · replay eve-gpt-5.6-sol-2000t (2x): the same eve capture at 2x — the headroom/stress row; real fast-tier models emit the same chunk sizes at proportionally higher rate, so time compression is a faithful speed model · first chunk (pooled): every run's seq-0 RTT pooled across all stream scenarios — the first chunk precedes any workload differentiation, so pooling samples one shared stream-open path with exact percentiles Replay cadences (semantic sha256) — eve-gpt-5.6-sol-2000t 🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 All timestamps are deployment-side; runs are triggered in-deployment, so the CI runner and api.vercel.com sit outside every measured window. TTFS = Cold starts stay in the numbers (real bursty-workload latency, inflates P75+); Best is the warm floor. |
🧪 E2E Test Results✅ All tests passed
|
| Passed | Failed | Skipped | Total | |
|---|---|---|---|---|
| ✅ ▲ Vercel Production | 3904 | 0 | 875 | 4779 |
| ✅ 💻 Local Development | 4582 | 0 | 551 | 5133 |
| ✅ 📦 Local Production | 4582 | 0 | 551 | 5133 |
| ✅ 🐘 Local Postgres | 4582 | 0 | 551 | 5133 |
| ✅ 🪟 Windows | 342 | 0 | 12 | 354 |
| ✅ 🌐 Cross-language Conformance | 68 | 0 | 84 | 152 |
| ✅ dynamic-runs | 0 | 0 | 0 | 0 |
| ✅ vercel-http-transport | 879 | 0 | 183 | 1062 |
| ✅ vercel-multi-region | 27 | 0 | 0 | 27 |
| ✅ vercel-ws-transport | 595 | 0 | 113 | 708 |
| Total | 19561 | 0 | 2920 | 22481 |
Details by Category
✅ ▲ Vercel Production
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ astro-node | 142 | 0 | 35 |
| ✅ astro-quickjs | 142 | 0 | 35 |
| ✅ example-node | 142 | 0 | 35 |
| ✅ example-quickjs | 142 | 0 | 35 |
| ✅ express-node | 142 | 0 | 35 |
| ✅ express-quickjs | 142 | 0 | 35 |
| ✅ fastify-node | 142 | 0 | 35 |
| ✅ fastify-quickjs | 142 | 0 | 35 |
| ✅ hono-node | 142 | 0 | 35 |
| ✅ hono-quickjs | 142 | 0 | 35 |
| ✅ nest-node | 142 | 0 | 35 |
| ✅ nest-quickjs | 142 | 0 | 35 |
| ✅ nextjs-turbopack-node | 169 | 0 | 8 |
| ✅ nextjs-turbopack-quickjs | 169 | 0 | 8 |
| ✅ nextjs-webpack-node | 169 | 0 | 8 |
| ✅ nextjs-webpack-quickjs | 169 | 0 | 8 |
| ✅ nitro-node | 142 | 0 | 35 |
| ✅ nitro-quickjs | 142 | 0 | 35 |
| ✅ nuxt-node | 142 | 0 | 35 |
| ✅ nuxt-quickjs | 142 | 0 | 35 |
| ✅ python-node | 66 | 0 | 111 |
| ✅ sveltekit-node | 161 | 0 | 16 |
| ✅ sveltekit-quickjs | 161 | 0 | 16 |
| ✅ tanstack-start-node | 142 | 0 | 35 |
| ✅ tanstack-start-quickjs | 142 | 0 | 35 |
| ✅ vite-node | 142 | 0 | 35 |
| ✅ vite-quickjs | 142 | 0 | 35 |
✅ 💻 Local Development
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ astro-stable-node | 148 | 0 | 29 |
| ✅ astro-stable-quickjs | 148 | 0 | 29 |
| ✅ express-stable-node | 148 | 0 | 29 |
| ✅ express-stable-quickjs | 148 | 0 | 29 |
| ✅ fastify-stable-node | 148 | 0 | 29 |
| ✅ fastify-stable-quickjs | 148 | 0 | 29 |
| ✅ hono-stable-node | 148 | 0 | 29 |
| ✅ hono-stable-quickjs | 148 | 0 | 29 |
| ✅ nest-stable-node | 148 | 0 | 29 |
| ✅ nest-stable-quickjs | 148 | 0 | 29 |
| ✅ nextjs-turbopack-canary-node | 176 | 0 | 1 |
| ✅ nextjs-turbopack-canary-quickjs | 176 | 0 | 1 |
| ✅ nextjs-turbopack-quickjs-snapshot | 176 | 0 | 1 |
| ✅ nextjs-turbopack-stable-node | 176 | 0 | 1 |
| ✅ nextjs-turbopack-stable-quickjs | 176 | 0 | 1 |
| ✅ nextjs-webpack-canary-node | 176 | 0 | 1 |
| ✅ nextjs-webpack-canary-quickjs | 176 | 0 | 1 |
| ✅ nextjs-webpack-stable-node | 176 | 0 | 1 |
| ✅ nextjs-webpack-stable-quickjs | 176 | 0 | 1 |
| ✅ nitro-stable-node | 148 | 0 | 29 |
| ✅ nitro-stable-quickjs | 148 | 0 | 29 |
| ✅ nuxt-stable-node | 148 | 0 | 29 |
| ✅ nuxt-stable-quickjs | 148 | 0 | 29 |
| ✅ sveltekit-stable-node | 167 | 0 | 10 |
| ✅ sveltekit-stable-quickjs | 167 | 0 | 10 |
| ✅ tanstack-start-node | 148 | 0 | 29 |
| ✅ tanstack-start-quickjs | 148 | 0 | 29 |
| ✅ vite-stable-node | 148 | 0 | 29 |
| ✅ vite-stable-quickjs | 148 | 0 | 29 |
✅ 📦 Local Production
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ astro-stable-node | 148 | 0 | 29 |
| ✅ astro-stable-quickjs | 148 | 0 | 29 |
| ✅ express-stable-node | 148 | 0 | 29 |
| ✅ express-stable-quickjs | 148 | 0 | 29 |
| ✅ fastify-stable-node | 148 | 0 | 29 |
| ✅ fastify-stable-quickjs | 148 | 0 | 29 |
| ✅ hono-stable-node | 148 | 0 | 29 |
| ✅ hono-stable-quickjs | 148 | 0 | 29 |
| ✅ nest-stable-node | 148 | 0 | 29 |
| ✅ nest-stable-quickjs | 148 | 0 | 29 |
| ✅ nextjs-turbopack-canary-node | 176 | 0 | 1 |
| ✅ nextjs-turbopack-canary-quickjs | 176 | 0 | 1 |
| ✅ nextjs-turbopack-quickjs-snapshot | 176 | 0 | 1 |
| ✅ nextjs-turbopack-stable-node | 176 | 0 | 1 |
| ✅ nextjs-turbopack-stable-quickjs | 176 | 0 | 1 |
| ✅ nextjs-webpack-canary-node | 176 | 0 | 1 |
| ✅ nextjs-webpack-canary-quickjs | 176 | 0 | 1 |
| ✅ nextjs-webpack-stable-node | 176 | 0 | 1 |
| ✅ nextjs-webpack-stable-quickjs | 176 | 0 | 1 |
| ✅ nitro-stable-node | 148 | 0 | 29 |
| ✅ nitro-stable-quickjs | 148 | 0 | 29 |
| ✅ nuxt-stable-node | 148 | 0 | 29 |
| ✅ nuxt-stable-quickjs | 148 | 0 | 29 |
| ✅ sveltekit-stable-node | 167 | 0 | 10 |
| ✅ sveltekit-stable-quickjs | 167 | 0 | 10 |
| ✅ tanstack-start-node | 148 | 0 | 29 |
| ✅ tanstack-start-quickjs | 148 | 0 | 29 |
| ✅ vite-stable-node | 148 | 0 | 29 |
| ✅ vite-stable-quickjs | 148 | 0 | 29 |
✅ 🐘 Local Postgres
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ astro-stable-node | 148 | 0 | 29 |
| ✅ astro-stable-quickjs | 148 | 0 | 29 |
| ✅ express-stable-node | 148 | 0 | 29 |
| ✅ express-stable-quickjs | 148 | 0 | 29 |
| ✅ fastify-stable-node | 148 | 0 | 29 |
| ✅ fastify-stable-quickjs | 148 | 0 | 29 |
| ✅ hono-stable-node | 148 | 0 | 29 |
| ✅ hono-stable-quickjs | 148 | 0 | 29 |
| ✅ nest-stable-node | 148 | 0 | 29 |
| ✅ nest-stable-quickjs | 148 | 0 | 29 |
| ✅ nextjs-turbopack-canary-node | 176 | 0 | 1 |
| ✅ nextjs-turbopack-canary-quickjs | 176 | 0 | 1 |
| ✅ nextjs-turbopack-quickjs-snapshot | 176 | 0 | 1 |
| ✅ nextjs-turbopack-stable-node | 176 | 0 | 1 |
| ✅ nextjs-turbopack-stable-quickjs | 176 | 0 | 1 |
| ✅ nextjs-webpack-canary-node | 176 | 0 | 1 |
| ✅ nextjs-webpack-canary-quickjs | 176 | 0 | 1 |
| ✅ nextjs-webpack-stable-node | 176 | 0 | 1 |
| ✅ nextjs-webpack-stable-quickjs | 176 | 0 | 1 |
| ✅ nitro-stable-node | 148 | 0 | 29 |
| ✅ nitro-stable-quickjs | 148 | 0 | 29 |
| ✅ nuxt-stable-node | 148 | 0 | 29 |
| ✅ nuxt-stable-quickjs | 148 | 0 | 29 |
| ✅ sveltekit-stable-node | 167 | 0 | 10 |
| ✅ sveltekit-stable-quickjs | 167 | 0 | 10 |
| ✅ tanstack-start-node | 148 | 0 | 29 |
| ✅ tanstack-start-quickjs | 148 | 0 | 29 |
| ✅ vite-stable-node | 148 | 0 | 29 |
| ✅ vite-stable-quickjs | 148 | 0 | 29 |
✅ 🪟 Windows
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ nextjs-turbopack-node | 171 | 0 | 6 |
| ✅ nextjs-turbopack-quickjs | 171 | 0 | 6 |
✅ 🌐 Cross-language Conformance
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ python | 68 | 0 | 84 |
✅ dynamic-runs
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ astro-vercel | 0 | 0 | 0 |
| ✅ example-vercel | 0 | 0 | 0 |
| ✅ express-vercel | 0 | 0 | 0 |
| ✅ fastify-vercel | 0 | 0 | 0 |
| ✅ hono-vercel | 0 | 0 | 0 |
| ✅ nest-vercel | 0 | 0 | 0 |
| ✅ nextjs-turbopack-vercel | 0 | 0 | 0 |
| ✅ nextjs-webpack-vercel | 0 | 0 | 0 |
| ✅ nitro-vercel | 0 | 0 | 0 |
| ✅ nuxt-vercel | 0 | 0 | 0 |
| ✅ sveltekit-vercel | 0 | 0 | 0 |
| ✅ tanstack-start-vercel | 0 | 0 | 0 |
| ✅ vite-vercel | 0 | 0 | 0 |
✅ vercel-http-transport
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ example | 142 | 0 | 35 |
| ✅ express | 142 | 0 | 35 |
| ✅ hono | 142 | 0 | 35 |
| ✅ nextjs-turbopack | 169 | 0 | 8 |
| ✅ nitro | 142 | 0 | 35 |
| ✅ vite | 142 | 0 | 35 |
✅ vercel-multi-region
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ nextjs-turbopack | 27 | 0 | 0 |
✅ vercel-ws-transport
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ example | 142 | 0 | 35 |
| ✅ express | 142 | 0 | 35 |
| ✅ nextjs-turbopack | 169 | 0 | 8 |
| ✅ vite | 142 | 0 | 35 |
Sim WorldSimulated world deterministic testing for races. Traces 🟠 world-sim scenario book — 1 fail of 42 total
Full trace: |
About these numbersSizes are gzip; parentheses show the change against
|
TooTallNate
left a comment
There was a problem hiding this comment.
AI review: approve. I found nothing blocking, so there are no inline threads.
What I checked:
- Probe correctness (
hook.ts).promises.length > 0tracks exactly the state that decides whether ahook_receivedregisters an armed or unarmed barrier (hasWaitingConsumeruses the same expression). So "observed" and "this payload would be ordered against step results" are the same predicate and can't drift. Buffered-claim,for awaitbetween iterations, conflict and force-claim drains, and thePromise.raceleftover over-report all behave as the module doc describes. - Ordering argument. A later
wait_completedor registration delivery does gate on an unarmed hook barrier (DEFER_BEHIND). A step that follows it still gates on that armed entry, because the unarmed skip is direct, never transitive. Log order is preserved, so the "unobserved payload is inert" claim holds for the wait/registration interleavings I traced as well, not only the four swept shapes. - Snapshot timing.
observedHookIdsis taken inside the samescheduleWhenIdlecallback that builds the suspension, so the probe and the queue describe the same instant. The completion-drain suspension inworkflow.tsdoesn't reach the batch gate. QuickJS degrades tounknownHookCount, which is conservative. runtime.tswiring.inlineCorrelationIds,ownedRecoveryStepsandwaitDueThisInvocationare all populated before the call. Backstopped and queued steps correctly count as external. Nothing branches on the result.- I ran it locally:
out-of-band-observation.test.tspasses (11/11) andtsc --noEmitis clean for@workflow/core. CI is green.
Non-blocking suggestions, all fine as follow-ups:
observed_hook_boundariesmergesobservedHookCountandunknownHookCount. If QuickJS runs reach this gate, every boundary with an open hook shows up as "observed hook" and skews the metric this PR is meant to collect. A separateunknown_hook_boundariescounter (or aworkflow.out_of_band.hook_tracking: falseattribute) would keep the measurement honest.observeOutOfBandWriters(...)and itsnew Set([...])run on every batch suspension, but the result is only used whenlazyInlineSteps.length > 0. Moving the call inside that branch avoids the allocation on the queue-only path.hookPayloadAwaitersentries are only removed onhook_conflictandhook_disposedevents, not on a localdispose(). This is harmless today: the item is skipped viadisposed, and the map is per-session. Dropping the entry indisposeHooktoo would match the "dropped once the hook can receive nothing more" doc on the field.- The sweep only compares step decisions (name and args). Adding one shape with a
sleepraced against a step after an unawaitedcreateHookwould pin the wait-barrier interleaving above with a test, which is the case most likely to regress once run-ahead starts relying oninert.
- Count boundaries without awaiter tracking separately (unknown_hook_boundaries) - Classify only batches that run a step inline - Drop a hook's awaiter probe on local dispose - Sweep a sleep winning a race after an unawaited createHook Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
(AI) Addressed all four suggestions from the review in 3e3a561:
|
|
No backport to This commit adds new detection machinery ( To override, re-run the Backport to stable workflow manually via |
#4683's out-of-band boundary classifier is kept as a standalone module; its hooks into the replaced inline-step loop are not carried over (wiring it into the single-orchestrator inline wait is the run-ahead follow-up). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The node:vm orchestrator now consults observeOutOfBandWriters (#4683) at every suspension. At an inert boundary on a World that records in-band writes at their occurredAt (new capability inBandEventTime; world-local and the append-only test World declare it), the inline steps it schedules run ahead: their creation batch and their outcome go out as speculative writes whose events join the log at the slot they will take, and the workflow continues without waiting for them. At most WORKFLOW_RUN_AHEAD_DEPTH (default 2, 0 disables) outcomes are unconfirmed at once. - A non-inert boundary (observed or unknown hook, abort signal, external step, wait due within the window plus WORKFLOW_OPEN_WAIT_CLOCK_SKEW_MS) drains first: speculative writes settle and their reports are folded in before the boundary writes. The next inert boundary runs ahead again. - Speculative writes are required writes in the in-band writer: a refusal or failure stops the writer before anything else is sent. Each commit is checked before the next write leaves: an event below it that would have made its boundary path-changing, or a stored time other than occurredAt, stops the delivery (loud warning) and it is redelivered. A write that landed at another slot only because an inert event moved it is repaired by reloading the log once the speculative writes settle. - Turbo's first boundary is the trivially inert case of the same path. - Span attributes record inert vs. drained boundaries, the de-opt reasons and depth-cap and stop counts. QuickJS does not run ahead (it carries no observedHookIds). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Catches the docs up with SDK changes that shipped in the last day without (complete) docs updates. | Change | Source PR | Docs edit | | --- | --- | --- | | `Serializable` type exported from `workflow` (released in 5.1.0) | #4578, #4580 | New "Typing serializable values" subsection in Serialization, with what the type doesn't cover | | QuickJS snapshots VM memory by default at 1,000 events | #4690 | The PR updated Runtime tuning. Added one sentence to the QuickJS bullet in What's New | | world-postgres reconnects a dropped `LISTEN` connection and re-reads chunks for open stream readers | #4658 | Added to the Streaming bullet under "How it works" on the Postgres World page | I reviewed the other PRs from the last day and they need no further docs: lifecycle hooks (#4438) and nested step IDs (#4615) shipped their own docs, the out-of-band observation (#4683) is detection-only, the turbo step fixes (#4685, #4667) make already-documented behavior work, and the CLI manifest (#4376), `simple-git` bump (#4675), and benchmark trim (#4649) aren't user-facing. The Workflows pages in `vercel/front` were also checked and need no changes for these. ## Docs Preview | Page | Preview | | --- | --- | | Serialization | [Typing serializable values](https://workflow-docs-git-workflow-docs-cleanup-wed-7-oct.vercel.sh/docs/foundations/serialization#typing-serializable-values) | | What's New | [QuickJS VM engine](https://workflow-docs-git-workflow-docs-cleanup-wed-7-oct.vercel.sh/docs/whats-new) | | Postgres World | [How it works](https://workflow-docs-git-workflow-docs-cleanup-wed-7-oct.vercel.sh/worlds/postgres#how-it-works) | 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com> Co-authored-by: Pranay Prakash <1797812+pranaygp@users.noreply.github.com>
This PR was opened by the [Changesets release](https://github.com/changesets/action) GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated. # Releases ## @workflow/world-local@5.1.0 ### Minor Changes - [#4718](#4718) [`0139561`](0139561) Thanks [@ruiconti](https://github.com/ruiconti)! - Store event and step files in one directory per run for faster reads. Local run data written by earlier releases is deleted on upgrade. ### Patch Changes - [#4742](#4742) [`b38360d`](b38360d) Thanks [@pranaygp](https://github.com/pranaygp)! - Make a tagged `clear()` delete only its own tag's lock files, so it no longer reopens another tag's disposed hooks mid-run. - Updated dependencies [[`46b3bfb`](46b3bfb)]: - @workflow/world@5.0.3 - @workflow/errors@5.0.3 ## @workflow/world-vercel@5.2.0 ### Minor Changes - [#4722](#4722) [`d0f6b91`](d0f6b91) Thanks [@alangenfeld](https://github.com/alangenfeld)! - Stream writes now upgrade to WebSocket by default; set `WORKFLOW_STREAMS_TRANSPORT=http` to opt out. ### Patch Changes - [#4712](#4712) [`46b3bfb`](46b3bfb) Thanks [@karthikscale3](https://github.com/karthikscale3)! - Reduce redundant schema compilation by compiling final event schemas only and reusing compiled v4 event-response and paginated response schemas. - Updated dependencies [[`46b3bfb`](46b3bfb)]: - @workflow/world@5.0.3 - @workflow/errors@5.0.3 ## @workflow/astro@5.0.3 ### Patch Changes - Updated dependencies [[`1acc0ba`](1acc0ba)]: - @workflow/swc-plugin@5.0.1 - @workflow/builders@5.0.3 - @workflow/rollup@5.0.3 - @workflow/vite@5.0.3 ## @workflow/builders@5.0.3 ### Patch Changes - Updated dependencies [[`de8d985`](de8d985), [`306a40f`](306a40f), [`515dc9f`](515dc9f), [`1acc0ba`](1acc0ba), [`878979b`](878979b), [`3949c07`](3949c07), [`5589df7`](5589df7)]: - @workflow/core@5.2.0 - @workflow/swc-plugin@5.0.1 - @workflow/errors@5.0.3 ## @workflow/cli@5.0.3 ### Patch Changes - [#4376](#4376) [`2036247`](2036247) Thanks [@withkarann](https://github.com/withkarann)! - Ship an oclif command manifest so the CLI loads only the command being run, and skip loading the runtime on exit when no World was created. `workflow --version` starts in about 50 ms instead of 650 ms. - Updated dependencies [[`de8d985`](de8d985), [`306a40f`](306a40f), [`46b3bfb`](46b3bfb), [`515dc9f`](515dc9f), [`1acc0ba`](1acc0ba), [`878979b`](878979b), [`3949c07`](3949c07), [`5589df7`](5589df7), [`28cb70d`](28cb70d), [`0139561`](0139561), [`b38360d`](b38360d), [`d0f6b91`](d0f6b91)]: - @workflow/core@5.2.0 - @workflow/world-vercel@5.2.0 - @workflow/world@5.0.3 - @workflow/swc-plugin@5.0.1 - @workflow/web@5.0.3 - @workflow/world-local@5.1.0 - @workflow/builders@5.0.3 - @workflow/errors@5.0.3 ## @workflow/core@5.2.0 ### Patch Changes - [#4727](#4727) [`de8d985`](de8d985) Thanks [@pranaygp](https://github.com/pranaygp)! - Start the queued branches of a wide `Promise.all` fan-out sooner. The batched fan-out now commits its writes in `createBatch` calls of at most 16 events instead of 32. Each queued branch's message waits on its chunk's commit and then its chunk's batched publish, and both are faster for smaller chunks. In production sweeps, the last branch of a 64-branch fan-out started about 90 ms sooner at p50, and of a 128-branch fan-out about 200 ms sooner, with no change to the first branch at those widths. - [#4324](#4324) [`306a40f`](306a40f) Thanks [@VaguelySerious](https://github.com/VaguelySerious)! - Deliver a hook payload to the pending await when an earlier `Promise.race` over the same hook was lost Concurrent awaits of one hook, such as `Promise.all([hook, hook])`, may now receive the same payload. Runs that already took the timeout branch in this situation may fail to replay after upgrading in place. - [#4438](#4438) [`515dc9f`](515dc9f) Thanks [@TooTallNate](https://github.com/TooTallNate)! - Reject lifecycle registration inside steps, preserve reporting diagnostics for unreadable errors, and document executor registration and stream-lifetime constraints. - [#4690](#4690) [`878979b`](878979b) Thanks [@TooTallNate](https://github.com/TooTallNate)! - Enable VM-memory snapshotting by default for the QuickJS engine, with a threshold of 1000 events. Set `WORKFLOW_SNAPSHOT_THRESHOLD=0` to opt out. Runs without an encryption key (for example on world-local and world-postgres) are still not snapshotted unless `WORKFLOW_SNAPSHOT_ALLOW_UNENCRYPTED=1` is set. - [#4683](#4683) [`3949c07`](3949c07) Thanks [@VaguelySerious](https://github.com/VaguelySerious)! - Record on the invocation trace whether out-of-band events could affect each inline step boundary - [#4685](#4685) [`5589df7`](5589df7) Thanks [@pranaygp](https://github.com/pranaygp)! - Hold a turbo step's awaited `step_started` until the backgrounded `run_started` lands, so an explicit `WORKFLOW_OPTIMISTIC_INLINE_START=0` no longer gets its first step rejected for a run that has not started yet. - Updated dependencies [[`46b3bfb`](46b3bfb), [`0139561`](0139561), [`b38360d`](b38360d), [`d0f6b91`](d0f6b91)]: - @workflow/world-vercel@5.2.0 - @workflow/world@5.0.3 - @workflow/world-local@5.1.0 - @workflow/errors@5.0.3 ## @workflow/errors@5.0.3 ### Patch Changes - Updated dependencies [[`46b3bfb`](46b3bfb)]: - @workflow/world@5.0.3 ## @workflow/nest@5.1.1 ### Patch Changes - Updated dependencies [[`1acc0ba`](1acc0ba)]: - @workflow/swc-plugin@5.0.1 - @workflow/builders@5.0.3 ## @workflow/next@5.0.3 ### Patch Changes - Updated dependencies [[`de8d985`](de8d985), [`306a40f`](306a40f), [`515dc9f`](515dc9f), [`1acc0ba`](1acc0ba), [`878979b`](878979b), [`3949c07`](3949c07), [`5589df7`](5589df7)]: - @workflow/core@5.2.0 - @workflow/swc-plugin@5.0.1 - @workflow/builders@5.0.3 ## @workflow/nitro@5.0.3 ### Patch Changes - Updated dependencies [[`de8d985`](de8d985), [`306a40f`](306a40f), [`515dc9f`](515dc9f), [`1acc0ba`](1acc0ba), [`878979b`](878979b), [`3949c07`](3949c07), [`5589df7`](5589df7), [`28cb70d`](28cb70d)]: - @workflow/core@5.2.0 - @workflow/swc-plugin@5.0.1 - @workflow/web@5.0.3 - @workflow/builders@5.0.3 - @workflow/rollup@5.0.3 - @workflow/vite@5.0.3 ## @workflow/nuxt@5.0.3 ### Patch Changes - Updated dependencies []: - @workflow/nitro@5.0.3 ## @workflow/rollup@5.0.3 ### Patch Changes - Updated dependencies [[`1acc0ba`](1acc0ba)]: - @workflow/swc-plugin@5.0.1 - @workflow/builders@5.0.3 ## @workflow/sveltekit@5.0.3 ### Patch Changes - Updated dependencies [[`1acc0ba`](1acc0ba)]: - @workflow/swc-plugin@5.0.1 - @workflow/builders@5.0.3 - @workflow/rollup@5.0.3 - @workflow/vite@5.0.3 ## @workflow/swc-plugin@5.0.1 ### Patch Changes - [#4615](#4615) [`1acc0ba`](1acc0ba) Thanks [@karthikscale3](https://github.com/karthikscale3)! - Stabilize nested step IDs across scopes and SWC transform modes. Self-hosted runs that already recorded one of the previously colliding IDs may report a replay divergence if they continue on upgraded code; restart those in-flight runs after upgrading. ## @workflow/vite@5.0.3 ### Patch Changes - Updated dependencies []: - @workflow/builders@5.0.3 ## @workflow/vitest@5.0.3 ### Patch Changes - Updated dependencies [[`de8d985`](de8d985), [`306a40f`](306a40f), [`46b3bfb`](46b3bfb), [`515dc9f`](515dc9f), [`878979b`](878979b), [`3949c07`](3949c07), [`5589df7`](5589df7), [`0139561`](0139561), [`b38360d`](b38360d)]: - @workflow/core@5.2.0 - @workflow/world@5.0.3 - @workflow/world-local@5.1.0 - @workflow/builders@5.0.3 - @workflow/rollup@5.0.3 ## @workflow/web@5.0.3 ### Patch Changes - [#4736](#4736) [`28cb70d`](28cb70d) Thanks [@TooTallNate](https://github.com/TooTallNate)! - Upgrade `react-router` and `@react-router/*` to 7.18.4 to address CVE-2026-34077 and GHSA-qwww-vcr4-c8h2 - Updated dependencies [[`0139561`](0139561), [`b38360d`](b38360d)]: - @workflow/world-local@5.1.0 ## @workflow/web-shared@5.0.3 ### Patch Changes - Updated dependencies [[`de8d985`](de8d985), [`306a40f`](306a40f), [`46b3bfb`](46b3bfb), [`515dc9f`](515dc9f), [`878979b`](878979b), [`3949c07`](3949c07), [`5589df7`](5589df7)]: - @workflow/core@5.2.0 - @workflow/world@5.0.3 ## workflow@5.2.0 ### Patch Changes - [#4438](#4438) [`515dc9f`](515dc9f) Thanks [@TooTallNate](https://github.com/TooTallNate)! - Reject lifecycle registration inside steps, preserve reporting diagnostics for unreadable errors, and document executor registration and stream-lifetime constraints. - Updated dependencies [[`2036247`](2036247), [`de8d985`](de8d985), [`306a40f`](306a40f), [`515dc9f`](515dc9f), [`878979b`](878979b), [`3949c07`](3949c07), [`5589df7`](5589df7)]: - @workflow/cli@5.0.3 - @workflow/core@5.2.0 - @workflow/next@5.0.3 - @workflow/nitro@5.0.3 - @workflow/typescript-plugin@5.0.0 - @workflow/errors@5.0.3 - @workflow/astro@5.0.3 - @workflow/nest@5.1.1 - @workflow/rollup@5.0.3 - @workflow/sveltekit@5.0.3 - @workflow/nuxt@5.0.3 ## @workflow/world@5.0.3 ### Patch Changes - [#4712](#4712) [`46b3bfb`](46b3bfb) Thanks [@karthikscale3](https://github.com/karthikscale3)! - Reduce redundant schema compilation by compiling final event schemas only and reusing compiled v4 event-response and paginated response schemas. ## @workflow/world-postgres@5.0.3 ### Patch Changes - Updated dependencies [[`46b3bfb`](46b3bfb), [`0139561`](0139561), [`b38360d`](b38360d)]: - @workflow/world@5.0.3 - @workflow/world-local@5.1.0 - @workflow/errors@5.0.3 ## @workflow/world-testing@5.0.3 ### Patch Changes - Updated dependencies [[`2036247`](2036247), [`de8d985`](de8d985), [`306a40f`](306a40f), [`46b3bfb`](46b3bfb), [`515dc9f`](515dc9f), [`878979b`](878979b), [`3949c07`](3949c07), [`5589df7`](5589df7)]: - @workflow/cli@5.0.3 - @workflow/core@5.2.0 - @workflow/world@5.0.3 - workflow@5.2.0 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Detection only. Nothing in the runtime branches on it yet. This is groundwork for a future run-ahead mode, where an inline step's successor starts before the step's
step_completedwrite returns. Today's fast paths de-opt as soon as any hook or wait is open. This PR classifies, at each inline-step boundary, whether an event from an out-of-band writer could change what the boundary leads to.What it detects
await hook, a pendingfor awaititeration,.then).createHookregisters a probe on the orchestrator context. The suspension snapshots it intoWorkflowSuspension.observedHookIdswhen the VM goes idle. An unobserved hook'shook_receivedis buffered as an unarmed barrier and doesn't move the clock, so it can't change a decision until the first await.signal.abortedis read synchronously.resumeAtfalls inside the invocation's inline window plus clock skew (reuseswaitDueThisInvocation).run.wakeUp()ignoresresumeAtand is not covered; this is documented.observedHookIds, so every open hook counts as observed.runtime/out-of-band-observation.tsdocuments the writer table, why unobserved hooks are inert, how a run-ahead gate should de-opt on this signal, and why run-ahead needs a pipeline-depth cap.Output
observeOutOfBandWriters()returns anOutOfBandObservation(inertplus per-reason counts). It is computed in the inline batch gate inruntime.ts, only for batches that run a step inline. Per-invocation counters go on the span:workflow.out_of_band.inert_boundariesworkflow.out_of_band.observed_hook_boundariesworkflow.out_of_band.unknown_hook_boundaries(open hooks on a suspension without awaiter tracking, e.g. QuickJS)workflow.out_of_band.abort_signal_boundariesworkflow.out_of_band.external_step_boundariesworkflow.out_of_band.wait_due_boundariesThese measure how many boundaries run-ahead could pipeline.
Tests
out-of-band-observation.test.ts:for awaitloop, background.then, hook raced against a step, and asleepwinning a race after an unawaitedcreateHook). For every log position, it records whether the prefix reported the hook observed. It then inserts a payload there with a far-futurecreatedAtand checks whether any step decision changes.falsefails 4 of the tests.🤖 Generated with Claude Code