feat(core): resolve run.returnValue via a World long poll instead of a 1s poll - #3570
feat(core): resolve run.returnValue via a World long poll instead of a 1s poll#3570pranaygp wants to merge 3 commits into
Conversation
`await run.returnValue` re-reads the run every second, so a run that finishes
just after a read is reported up to a full `WORKFLOW_RETURN_VALUE_POLL_INTERVAL_MS`
late — quantization latency on a run that is already done.
Add an optional `Storage['runs'].waitForTerminalStatus(runId, { timeoutMs,
signal, resolveData })`: one read the World holds open until the run reaches a
terminal status, returning the same entity `runs.get()` returns. An expired
budget returns the latest snapshot rather than throwing, so `#pollReturnValue`
keeps its shape — the wait simply replaces `get` + sleep in the loop, and a
terminal status is observed the moment it lands.
Implemented three ways, because "wait" means something different per store:
world-vercel long polls workflow-server's new `GET /v2/runs/:runId/status`
route; world-postgres parks on a `LISTEN`/`NOTIFY` published by its
run-terminal writes; world-local uses an in-process emitter over its run
files. All three re-read the run before answering and back the wait with a
periodic re-read, so a lost notification costs latency, never correctness.
The fast path is strictly additive. A World that omits the method (world-sim,
third-party adapters) keeps interval-polling `get` exactly as before, and
world-vercel falls back to the plain read when the workflow-server it is
talking to has no such route — telling a missing route from a missing run by
whether that read succeeds. The loop also keeps consecutive non-terminal
observations one poll interval apart, so a World whose wait returns early
degrades to polling instead of spinning.
`WORKFLOW_RETURN_VALUE_LONG_POLL=0` restores the fixed-interval poll.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Pranay Prakash <1797812+pranaygp@users.noreply.github.com>
🦋 Changeset detectedLatest commit: b85dbfc The changes in this PR will be included in the next version bump. This PR includes changesets to release 20 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 |
🧪 E2E Test Results✅ All tests passed
|
| Passed | Failed | Skipped | Total | |
|---|---|---|---|---|
| ✅ ▲ Vercel Production | 3321 | 0 | 735 | 4056 |
| ✅ 💻 Local Development | 3673 | 0 | 539 | 4212 |
| ✅ 📦 Local Production | 3810 | 0 | 558 | 4368 |
| ✅ 🐘 Local Postgres | 3810 | 0 | 558 | 4368 |
| ✅ 🪟 Windows | 312 | 0 | 0 | 312 |
| ✅ 🌐 Cross-language Conformance | 9 | 0 | 128 | 137 |
| ✅ vercel-multi-region | 27 | 0 | 0 | 27 |
| Total | 14962 | 0 | 2518 | 17480 |
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-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 |
| ✅ python-node | 8 | 0 | 148 |
| ✅ 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-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 |
✅ 🌐 Cross-language Conformance
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ python | 9 | 0 | 128 |
✅ vercel-multi-region
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ nextjs-turbopack | 27 | 0 | 0 |
📊 Workflow Benchmarkscommit Backend:
Streams
📈 STSO distribution vs main (inline / queue-hop histograms)1020 steps (inline) Cumulative STSO time: main 369121ms → this run 276301ms (Δ -92820ms, -25%) 📈 CRTT drill-down vs main (RTT distributions & profiles)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)6cd6cd2Fri, 14 Aug 2026 23:57:45 GMT · run logs
Streams
ℹ️ Metric definitions & methodologyStreams: writer/reader sustained rates (steady window, 10% trimmed each side), 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, Best/P75/P90/P99 deltas compare against the most recent benchmark run on 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. |
The `world` stub the reference snippets typecheck against declares only the methods those snippets call, so the new `runs.waitForTerminalStatus()` sample had no property to resolve. Declared optional, mirroring the World interface — which is why the snippet reaches it through `?.`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Pranay Prakash <1797812+pranaygp@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR improves await run.returnValue latency by introducing an optional World-level long-poll (runs.waitForTerminalStatus) so callers can be notified as soon as a run becomes terminal instead of waiting for the next fixed ~1s polling tick.
Changes:
- Add
Storage['runs'].waitForTerminalStatus(optional) plus shared params type and documentation of its contract. - Update the core runtime polling loop to prefer
waitForTerminalStatus(with pacing/kill switch) and fall back to intervalruns.get. - Implement long-poll semantics across worlds (Vercel via
/v2/runs/:id/status, Postgres viaLISTEN/NOTIFY, Local via in-process signal + fs backstop) and add targeted tests.
Reviewed changes
Copilot reviewed 18 out of 19 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/world/src/runs.ts | Adds WaitForTerminalRunStatusParams for the optional long-poll API. |
| packages/world/src/interfaces.ts | Extends the Storage runs interface with optional waitForTerminalStatus and contract docs. |
| packages/world-vercel/src/storage.ts | Wires runs.waitForTerminalStatus into the world-vercel storage implementation. |
| packages/world-vercel/src/runs.ts | Implements /v2/runs/:id/status long-poll logic with budget clamping and degradation behavior. |
| packages/world-vercel/src/run-status-long-poll.test.ts | Adds unit tests for world-vercel long-poll behavior and degradation cases. |
| packages/world-postgres/test/run-status-wait.test.ts | Adds Postgres integration tests validating NOTIFY-driven wakeups and fallbacks. |
| packages/world-postgres/src/storage.ts | Implements Postgres runs.waitForTerminalStatus and emits NOTIFY on terminal transitions. |
| packages/world-postgres/src/run-status.ts | Adds shared LISTEN/NOTIFY listener + backstop interval configuration for terminal waits. |
| packages/world-postgres/src/index.ts | Initializes and closes the shared run-status listener in the Postgres world lifecycle. |
| packages/world-local/src/storage/runs-storage.ts | Implements local runs.waitForTerminalStatus with in-process signal and backstop poll. |
| packages/world-local/src/storage/run-status-wait.test.ts | Adds fs-backed tests for local long-poll semantics (complete/cancel/timeout/abort). |
| packages/world-local/src/storage/run-status-signal.ts | Adds in-process terminal-status signal emitter and backstop poll interval. |
| packages/world-local/src/storage/events-storage.ts | Emits terminal signal after committing terminal run writes. |
| packages/core/src/runtime/run.ts | Updates Run#pollReturnValue to prefer long-poll + pacing and adds env knobs/kill switch. |
| packages/core/src/runtime/run-return-value-long-poll.test.ts | Adds fake-timer tests covering pacing, kill switch, and budget forwarding. |
| docs/content/worlds/v5/building-a-world.mdx | Documents optional runs.waitForTerminalStatus and recommended implementation patterns. |
| docs/content/docs/v5/api-reference/workflow-runtime/world/storage.mdx | Adds API reference docs for runs.waitForTerminalStatus and behavior notes. |
| .changeset/run-status-long-poll.md | Changeset entry describing the new optional World long-poll and runtime behavior. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| const LONG_POLL_UNSUPPORTED_TTL_MS = 5 * 60 * 1000; | ||
|
|
||
| let longPollUnsupportedUntil = 0; | ||
|
|
||
| /** Test-only: forget that the long-poll route was unavailable. @internal */ | ||
| export function _resetRunStatusLongPollSupportForTests(): void { | ||
| longPollUnsupportedUntil = 0; |
There was a problem hiding this comment.
Good catch — fixed in b1006b7. The suppression is now a Map<baseUrl, deadline> keyed off getHttpUrl(config).baseUrl, so a proxy that predates the route and a direct backend that has it no longer share a verdict. Covered by a new case (suppresses the fast path per backend, not process-wide) that 404s the direct host and then asserts the proxy is still long-polled.
| const ensureSubscribed = () => { | ||
| subscription ??= listenChannel(pool, RUN_STATUS_TOPIC, async (payload) => { | ||
| if (payload) emitter.emit(`run:${payload}`); | ||
| }).catch(() => { | ||
| // No listener connection available (pool options that don't permit a | ||
| // second client, a database without LISTEN). Waits degrade to the | ||
| // backstop re-read, which is the behavior of a plain poll. | ||
| return undefined; | ||
| }); | ||
| return subscription; | ||
| }; |
There was a problem hiding this comment.
Agreed, fixed in b1006b7 — a failed attempt clears subscription so a later wait re-attempts.
One addition on top of the suggestion: a bare clear makes every waiting run retry the LISTEN once per poll interval while the database is unreachable, i.e. a connection attempt per second per awaited run against something already struggling. So the retry sits behind a 5s backoff (LISTEN_RETRY_BACKOFF_MS), which keeps a genuinely unavailable listener at one attempt every few seconds while still picking a restart back up well within a single 25s wait. close() pins the backoff at Infinity so a shut-down world can't re-open the connection.
Sim WorldSimulated world deterministic testing for races. Traces 🟠 Mint-ordered log — 3 fail of 41 total
Full trace: 🟢 Append-only log — 0 fail of 41 total
Full trace: |
CI stateGreen except two Both fail on the same test — Worth calling out what did pass: all 27 |
Two review findings on the degradation paths: world-vercel cached "this backend has no /status route" process-wide, but one process can hold worlds pointed at different backends — the api.vercel.com proxy and workflow-server directly resolve to different hosts, which can be on different versions. Key the suppression by resolved base URL so a miss against one never disables the fast path for the other. world-postgres cached a *failed* LISTEN attempt forever, so a database restart or a brief blip at process start would degrade every wait to backstop polling for the life of the process. Clear it on failure and re-attempt, behind a 5s backoff so an unavailable listener costs one connection attempt every few seconds rather than one per waiting run per poll interval. close() pins the backoff open so a shut-down world never re-opens it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Pranay Prakash <1797812+pranaygp@users.noreply.github.com>
Motivation
await run.returnValuepolls the run record on a fixed ~1s interval (Run#pollReturnValue,packages/core/src/runtime/run.ts). A run that finishes just after one of those reads is reported to the caller up to a full second late — quantization latency on a run that is already done, paid on every awaited run: a parent awaiting a child workflow,wf run --wait, a request handler awaiting a result.This replaces #3023, which closed that gap client-side by having the completing invocation write a marker to a run-scoped stream. Per the review:
So: no client-side signal, no per-run stream, no terminal-transition parity table. The World is asked to wait, and where it can't, nothing changes.
Server half: vercel/workflow-server#785 (the
GET /v2/runs/:runId/statusroute).The World API
The contract, in full:
getreturns.timeoutMswith the latest snapshot, whatever its status. A timeout is a normal return, never an error — a still-running run is a legitimate answer.timeoutMsis an upper bound, not a lower one. An implementation may resolve early with a non-terminal snapshot (world-vercel does when the backend has no long-poll route), so callers pace their own retries.get— a missing run throwsWorkflowRunNotFoundError.Nothing is declared beyond the method's presence: no capability flag, no env var to enable. A World that can't wait omits it and the runtime keeps interval-polling
get.The runtime loop
#pollReturnValuekeeps its exact shape — the wait just replacesget+ sleep:and the sleep in the not-yet-terminal branch becomes "sleep off whatever is left of one poll interval":
One expression, three jobs: it is the familiar fixed 1s sleep on the plain-poll path; a no-op after a wait that already blocked longer than the interval; and an anti-spin floor for a World whose wait returns early — that World degrades to interval polling rather than hot-looping. Resolved and rejected values are unchanged: same
hydrateWorkflowReturnValue, sameWorkflowRunCancelledError/WorkflowRunFailedError(witherrorCodeand hydrated cause), sameresilientStart404 retry ladder.WORKFLOW_RETURN_VALUE_LONG_POLL=0(orfalse) is the kill switch and restores the fixed-interval poll exactly.WORKFLOW_RETURN_VALUE_WAIT_MStunes the per-call budget (default 25s — comfortably inside world-vercel's 60s per-request HTTP timeout, so the budget always expires as a response, not as a client timeout).Per-world implementations
Each World waits with whatever its store already offers. All three re-read the run before answering — the notification is a signal only, never a source of truth — and back the wait with a periodic re-read, so a lost notification costs latency rather than a hang.
GET /v2/runs/:runId/status?waitMs=…(vercel/workflow-server#785)MockAgent, 8 cases)LISTEN/NOTIFYonworkflow_run_status, published after each run-terminalUPDATE; one lazily-opened shared listener connectionWORKFLOW_POSTGRES_RUN_STATUS_POLL_INTERVAL_MS, 1swriteRunUnderLifecycleLock(the one helper every run-lifecycle write funnels through)WORKFLOW_LOCAL_RUN_STATUS_POLL_INTERVAL_MS, 100ms — also covers multi-process dev over one data dirDegradation, in three layers
get, byte-identical to today.404there is ambiguous — the run may not exist, or the server may predate the route — so it is resolved by falling back to the plain read, which is the answer we want either way: it raisesWorkflowRunNotFoundErrorfor a missing run, and returns the run when the route was what was missing. Only the latter (proof that the run exists and the route does not) marks the fast path unsupported, so one bad run ID can never disable long polling for the process. The mark expires after 5 minutes so a client that outlives a server roll-forward picks the fast path back up on its own.405/501are treated the same way; a500propagates untouched rather than being masked by a second read.The budget is also clamped to leave 10s under the adapter's per-request HTTP timeout, so a long poll can never be the thing that times out.
Tests
packages/core/src/runtime/run-return-value-long-poll.test.ts(16 cases, fake timers) — uses the long poll and doesn't touchruns.get; forwards the configured budget; hydrates a completed return value; paces a World whose wait returns non-terminal early (asserts no second attempt before the interval, and one immediately after); no added sleep when the wait already outlasted the interval; interval-pollsgetwhen the method is absent; kill switch restores the fixed poll and never calls the wait; env parsing for both new vars.packages/world-vercel/src/run-status-long-poll.test.ts(8 cases) — path/params incl.lazyrefs and budget clamping; non-terminal snapshot returned as a value; no-budget → plain read; missing route degrades and stays degraded (one status attempt across two calls); missing run →WorkflowRunNotFoundErrorwith long polling still enabled;500propagates.packages/world-postgres/test/run-status-wait.test.ts(8 cases, testcontainers Postgres) — with the backstop dialed to 10s, so a wait that resolves in milliseconds proves theNOTIFYdid the waking; completed / failed / cancelled; budget expiry; abort; unknown run; and a storage built without the listener still resolving on its re-read.packages/world-local/src/storage/run-status-wait.test.ts(7 cases, real fs) — already-terminal, wake on completion and on cancellation, budget expiry, no budget, abort, unknown run.Test plan
packages/coreunit suite — 2131 passed, 3 expected-fail (quickjs-runtime.test.tsneeds generated assets; unrelated, fails onmainin this sandbox)@workflow/world-vercel— 509 passed@workflow/world-local— 549 passed (4 pre-existingchmod-based failures, also failing onmainhere)@workflow/world-postgres— 174 passed (test/spec.test.tsneeds a built@workflow/world-testing; unrelated)tsc --noEmitclean:world,world-vercel,world-local,world-postgres,corebiome checkclean on changed filesDocs
world.runs.waitForTerminalStatus()in the World storage reference, and an "Optional: Waiting for a Terminal Run Status" section in Building a World — the contract, the three reference mechanisms, and why omitting it is a supported choice.🤖 Generated with Claude Code