Commit Graph

1681 Commits

Author SHA1 Message Date
github-actions[bot] 16352a21c1 Version Packages (beta) (#3655) workflow@5.0.0-beta.43 2026-08-19 14:55:52 -07:00
Peter Wielander 0b2797bbac [next] Bundle the Vercel world into the Next.js server output (#3493) 2026-08-19 14:49:12 -07:00
Karthik Kalyan a074259166 Add run status polling changeset (#3674) 2026-08-19 14:06:26 -07:00
Peter Wielander 0ff689023d [ci] Polish changeset entry wording (#3673) 2026-08-19 13:59:58 -07:00
Karthik Kalyan 78a531a088 Reduce payload resolution during run polling (#3672) 2026-08-19 13:52:22 -07:00
Pranay Prakash 37e1d9e5a9 Batch: pre-claim inline steps in the same batch (#3568)
* Pre-claim inline steps inside the suspension batch (born-running pairs)

Restacked onto main after #3025's squash-merge; folds in the review-round
changes to the flush loop (per-write requestId attribution on createBatch,
and the seeded/advancing slot-bump expectation, now shared with the
pre-claim ceiling).

Fold each lazy-inline step's deferred writes into the batched fan-out as an
adjacent [step_created, step_started] pair: the created row carries the input,
the started row is a bare ownership-stamped claim the server folds into one
born-running create. The whole scheduling turn commits as ONE durable write,
inline bodies start straight off that commit (in parallel with the VQS
publishes for backgrounded steps), and executeStep gains a pre-claimed mode
that runs or skips the body off the batch's per-event verdict - a pair 409 is
the same skipped outcome as losing the lazy claim. The lone-inline case keeps
the optimistic lazy path (a pair-only batch buys nothing over the single
claim). Also threads per-event computeInstanceId through the World batch
request, and folds the batch's committed slot ceiling into the inline slot
snapshot so terminal writes stop being answered with reports echoing the
batch's own events.

* Parallel chunk commits, per-chunk continuation, batch span attributes

Production trace of a 67-event fan-out showed the three batch chunks
POSTing back-to-back (~230ms each) with no bodies or queue messages until
all three settled (~670ms). Three changes:

- Chunks now POST concurrently. Slot assignment is the server's, so
  parallel chunks race for slot ranges exactly like the pre-fold path's
  parallel single writes did; entity conditions, not commit order, carry
  correctness. The foreign-interleaving diagnostic is computed once over
  the whole fold (committed span vs seed) instead of per chunk.

- Per-chunk continuation: each chunk's step-execution queue messages
  publish the moment ITS creates are durable (in-flush, via stepDispatch,
  same message shape and idempotency key as the caller's dispatch pass -
  the affected steps are pre-reported in queuedStepCorrelationIds so the
  caller skips them). Only the chunk carrying the inline pairs gates
  handleSuspension's return (opt-in via allowDeferredBatchWork); trailing
  chunk commits + all publishes ride result.deferredBatchWork, which the
  runtime joins next to the dispatch join before it can ack - the
  every-create-durable-before-ack contract is unchanged, the bodies just
  start off the pair chunk instead of the slowest chunk.

- OTel: batch identity attributes (workflow.batch.size, per-type
  workflow.batch.shape) now live on the world.events.createBatch span
  (instrumentObject) instead of the http POST span, which keeps only
  wire-level facts (transport, bytes) and no longer sets
  workflow.event.type - that attribute names a single event write and
  tagging a batch with its first event's type misclassifies traffic.

* Address review: settle deferred fold on failure, drop pair-batch retry

Three fixes from review of the deferred/parallel-chunk fold.

1. A pair-chunk rejection escaped `handleSuspension` while the trailing
   chunks' commits and publishes were still in flight. `deferredBatchWork`
   never reaches the caller once the handler throws, so nothing joined that
   work — exactly the state `settlePhase` exists to prevent: a sibling create
   landing after the rejection commits an event from the abandoned replay's
   seeded sequence and races the caller's restart reload. The failure path now
   settles `trailing` before rethrowing.

2. Every pair-carrying chunk gates the return, not just the first. Pairs sort
   to the front and two rows per inline step fit inside one chunk, so this is
   one commit today, but `findIndex` silently degraded if either cap moved: a
   pair in an unawaited chunk yields no `inlineClaims` entry, the caller falls
   back to a lazy `step_started`, and that races this same fold's in-flight
   pair for the same step. constants.test.ts now pins the cap relationship.

3. A batch carrying a `step_started` is no longer retried in-process. The
   born-running pair does converge to a 409, but the pre-claim caller reads a
   pair 409 as "a concurrent writer owns this step" and skips the body — and
   on a retry that is indistinguishable from "my own first attempt committed
   the pair". Skipping there stranded a running step stamped with this
   invocation's own message id until the ownership lease expired (860s), where
   the single-POST path deliberately fails the delivery and recovers through
   owned-recovery in seconds. Same reasoning `EVENT_RETRY_ELIGIBILITY` already
   applies to `step_started`.

Also asserts `lazyStepInput` / `preclaimedStart` mutual exclusivity in
executeStep instead of only documenting it, and adds the changeset.

Tests: +1 suspension-handler (pair-chunk failure settles the trailing chunk
before escaping — fails without fix 1), +1 constants (cap relationship), +1
world-vercel (a born-running pair batch is single-attempt), and the existing
batch-retry test retargeted at an entity-conditioned batch. Full
@workflow/core unit suite 2178 green, @workflow/world-vercel 514 green,
typecheck green across core / world / world-vercel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Guard inline bodies against unhandledRejection; review follow-ups

The dispatch/deferred-batch joins now sit between the step promises'
creation and the `Promise.all` that reads them, so a body rejecting in that
window had no handler attached at the microtask checkpoint — an
unhandledRejection, fatal under Node's default --unhandled-rejections=throw.
A 412 fenced claim races exactly that window, and `deferredBatchWork` widens
it by a trailing-chunk round trip. Attach a no-op catch at creation, the same
way `dispatchesSettled` already does two lines up; the awaits below still
decide the outcome.

Review follow-ups:

- `workflow.batch.shape` is sorted by event type. Map iteration is first-seen
  order, so a pre-claimed fold and a pure eager fold rendered the same
  composition as different strings, which is not groupable as a dimension.

- A lost pre-claim reports StepSkipReason `running`, not `completed`. The
  pair's 409 says the step already exists and its claim winner is executing;
  the other skip site is a genuine terminal-state conflict, and tagging both
  `completed` left the attribute unable to separate the two.

- `batchCommittedSlotCeiling`'s docstring now says the echo is only fully
  suppressed for a single-chunk fold: on a multi-chunk fan-out an inline
  terminal write issued before the trailing chunks land still names a
  position below them and still draws a report.

- The defensive throw on a missing dehydrated input records where it lands —
  the pair is already durable, so it fails with the step claimed and its body
  unrun, recovered on redelivery via owned-recovery rather than failing
  cleanly.

No regression test for the unhandledRejection: the existing
inlineClaimRejectionScenario runs both steps inline, so `dispatches` is empty
and the join resolves in a microtask — the window never opens and a test
there passes with or without the fix. Reproducing it needs a scenario with a
backgrounded step and a slow queue publish alongside the fenced claim.

Full @workflow/core unit suite 2178 green, @workflow/world-vercel 514 green,
typecheck and biome clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Pin per-event computeInstanceId on the batch wire

Batch encoding is a separate path from the single-event POST, so the frame
meta had no coverage: the only assertion was at the World-call boundary.
Adds a wire-level test that a pre-claimed pair's step_started half carries
computeInstanceId in its frame meta and the step_created half does not.
Verified it fails when the threading in createWorkflowRunEventBatch is
removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Guard the pre-claim path as inert on Worlds without createBatch

world-local and world-postgres do not implement createBatch, so the fold
never engages there — but the runtime passes ownerMessageId and
allowDeferredBatchWork unconditionally. The existing "keeps the single path
when the World lacks createBatch" test passed neither, so it never covered
the pre-claim path at all.

Assert the inertness with the params the runtime actually sends: no claims,
no deferred work, no slot ceiling, the lazy-inline step still carrying its
input, and no step_started reaching the world.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Peter Wielander <peter.wielander@vercel.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 13:32:44 -07:00
Peter Wielander 771cdb22a8 fix(world-postgres): refuse a hook resume that races the disposal (#3645) 2026-08-19 11:38:04 -07:00
Alex Langenfeld 5fca3bb589 bench: consolidate stream throughput columns (#3585)
Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
2026-08-19 09:17:20 -05:00
Peter Wielander a036888765 [ci] Key the backport concurrency group on the target commit (#3642) 2026-08-18 16:03:55 -07:00
Mitul Shah 6ca2f1b3b7 fix(web-shared): optimize MiddleTruncate resizing (#3630)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-08-18 15:39:12 -07:00
Peter Wielander 7027301fbe Note why the check is inline rather than an extracted script (#3636) 2026-08-18 15:36:41 -07:00
Peter Wielander 26341192bd [ci] Validate the backport AI model slug up front (#3632) 2026-08-18 15:33:01 -07:00
Nathan Colosimo 52526e14aa perf(world-vercel): avoid repeated frame buffer copies (#3586) 2026-08-18 15:27:19 -07:00
Alex Langenfeld 3837ca2dad [core] Warm cold e2e targets before the suite starts runs (#3590)
A target answers HTTP well before its first run is picked up promptly,
for two reasons with one shape: a fresh Vercel deployment's queue
consumer takes a while to start delivering, and a local dev server pays
its first flow-route compile on the first queue delivery. The
run-pickup watchdog's telemetry shows the cost - stalls concentrated on
the suite's first test (addTenWorkflow), waitedMs pegged at the full
15s pickup budget, timestamps right at suite start; the sidecar
backends identify local-dev lanes as a dominant source alongside fresh
Vercel deployments. Each stall burns pickup budget inside a test,
drowns the infra telemetry in cold-start noise, and leaves the first
tests one stalled replacement away from failing.

warmDeployment() runs in the suite's beforeAll: it starts throwaway
probe runs, abandoning (best-effort cancelling) any still pending after
the pickup budget, until one is picked up or a total budget
(WORKFLOW_E2E_WARMUP_BUDGET_MS, default 120s) is spent. A warmup that
needed abandoned probes is recorded as a single cold-start-warmup infra
event - one per suite instead of per-test run-pickup-stall noise - and
an exhausted budget proceeds anyway: the per-test watchdog still guards
every start, and test failures carry run diagnostics a thrown warmup
would not.

Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
2026-08-18 17:00:23 -05:00
Alex Langenfeld 8789f4529b [e2e] Host the abort-fetch slow endpoint inside the step (#3618)
The abort-fetch tests cancelled an in-flight fetch against external slow
endpoints (postman-echo, httpbin /delay/10, tried in order). Those
upstreams 5xx and return early from GH Actions runners often enough to
be a recurring flake class - the tests were measuring the public
internet instead of abort propagation - and heavier suite load (e.g.
re-enabling e2e concurrency, #2083) makes both upstreams flake at once.

fetchWithSignal now hosts its own slow endpoint: an in-process node:http
server on a loopback ephemeral port that holds each response open for
~30s. The subject is unchanged - a real in-flight HTTP fetch cancelled
mid-flight - with no external dependency. The 30s hold keeps regression
detection honest: broken abort propagation surfaces as natural
completion (ok: true) within the tests' 60s budgets.

A per-workbench /api/delay route was rejected earlier because it would
only exist on whichever workbench it was added to; the in-step server
travels with the workflow fixture to every app.

Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
2026-08-18 16:59:49 -05:00
Peter Wielander 04e060a0ec [world] Add WORKFLOW_NODE_HTTP to run the HTTP Worlds on node:http (#3461) 2026-08-18 14:31:08 -07:00
Peter Wielander 707dfe6a06 [core] Pin draw order to event-log order: retire delivery barriers through one ordered dispenser (#3554) 2026-08-18 14:07:21 -07:00
Alex Langenfeld 09c51d8a16 [e2e] Shrink fibonacciWorkflow's run tree from fib(6) to fib(5) (#3619)
fib(6) spawns a 25-run tree whose ~24 concurrent parent polls saturate
the workflow scheduler past the test's 180s budget under a concurrent
suite (#2083 measured this as one of the three flake classes blocking
e2e concurrency re-enablement). fib(5)'s 15-run tree still exercises
the test's actual subject - recursive start() composition with parallel
children at every level - with 40% less peak load.

Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
2026-08-18 15:47:39 -05:00
Alex Langenfeld fb6f2654ac [e2e] Assert only the positive direction of step source maps (#3620)
Where hasStepSourceMaps() reports maps unsupported, the tests asserted
their absence - but on some lanes (nuxt, nextjs-webpack) source maps
apply nondeterministically, so the negative assertion pinned that
nondeterminism as a flake (#2083 measured this as one of the three
flake classes blocking e2e concurrency re-enablement). A stack that
resolves to source where none was promised is an improvement, not a
failure; hasStepSourceMaps() remains the record to update when a lane
starts mapping reliably, verified by the positive assertions on the
lanes it does promise.

Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
2026-08-18 15:47:27 -05:00
Alex Langenfeld 916fcf0833 [next] Let HMR log readers tell quiet from rebuild-in-flight (#3589)
The dev HMR log line for a rebuild is written at classification time,
before the (potentially multi-second) rebuild runs. A reader counting
lines in a window has no way to tell 'quiet' from 'rebuild in flight
with queued events behind it', and the e2e HMR tests count lines in
exact windows: on CI, where rebuilds take 5-12s, a write from a
previous test case (or a teardown restore) can still be rebuilding when
the next window opens, and its legitimate rebuild lines land inside
that window - observed on main as the fuzz test failing both retry
attempts with 'expected 2 to be 1'.

The dev server now also logs 'workflow dev hmr: rebuild complete' when
a rebuild finishes processing (in a finally, so an erroring rebuild
cannot wedge readers; behind WORKFLOW_DEV_HMR_LOGS like every hmr
line). The e2e suite uses it to drain to quiescence - every started
rebuild completed, plus a short quiet window covering watcher latency
and the flush debounce - before taking a log cursor. Every cursor call
site writes only after taking its cursor, so draining there cannot
swallow lines a test means to count.

Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
2026-08-18 15:07:40 -05:00
Alex Langenfeld 880801bd45 Stamp request provenance on step starts (#3567)
## Summary & Motivation

`step_started` now carries the invocation's request ID alongside the
compute instance ID, so observability can show both dimensions of where
an attempt ran. The two stay independent fields — world-vercel maps
`requestId` onto its analytics `vercelId`, which the compute instance ID
doesn't stand in for.

## Test Plan

Unit tests added for the stamping on all three step-start paths,
including when no request ID is available.

Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
2026-08-18 13:21:36 -05:00
Shalabh Chaturvedi ac51658d39 [core] Record step execution duration metric (#3526)
## Summary
- record `workflow.step.execute.duration` as an OpenTelemetry histogram
around the inner user-code step span
- rely on resource `service.name` for the service dimension and add only
a bounded status attribute
- cover the histogram name, unit, and attributes with a focused unit
test

## Validation
- `pnpm exec vitest run packages/core/src/telemetry-metrics.test.ts`
- `pnpm --filter @workflow/core typecheck`

---------

Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
2026-08-17 14:49:39 -07:00
Alex Langenfeld df0103bc3c Document stream reader request cancellation (#3581)
## Summary & Motivation

The earlier timeout-docs attempt in #534 was closed without merging.
Without `supportsCancellation`, a browser disconnect leaves the stream
reader reconnecting until the function hits
`FUNCTION_INVOCATION_TIMEOUT`, so the streaming guide now documents the
`vercel.json` opt-in, with a warning that it terminates everything
matching the configured path, and the resumable-streams guide points at
it.

## Test Plan

Docs only; no tests.


## Docs Preview

| Page | v4 | v5 |
| --- | --- | --- |
| Streaming |
[Preview](https://workflow-docs-git-alangenfeld-timeout-docs.vercel.sh/docs/foundations/streaming#avoiding-function-timeouts-after-client-disconnects)
|
[Preview](https://workflow-docs-git-alangenfeld-timeout-docs.vercel.sh/v5/docs/foundations/streaming#avoiding-function-timeouts-after-client-disconnects)
|
| Resumable Streams |
[Preview](https://workflow-docs-git-alangenfeld-timeout-docs.vercel.sh/docs/ai/resumable-streams)
|
[Preview](https://workflow-docs-git-alangenfeld-timeout-docs.vercel.sh/v5/docs/ai/resumable-streams)
|

Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
2026-08-17 13:04:11 -05:00
Alex Langenfeld c5f9177be3 Verify stream completion on clean EOF in the reconnecting framed reader (#3564)
## Summary & Motivation

Long live reads were ending silently at the server's 2-minute connection
cap: the max-duration abort reaches the client as a clean EOF on some
transport paths, and the reader read that as end-of-stream. On EOF it
now consults `streams.getInfo` and reconnects from the next chunk unless
the stream is done and every chunk up to the tail was delivered. A
failed metadata read trusts the EOF, so a transient blip can't fail a
healthy completion.

## Test Plan

Tests added for the reconnect, verified-completion, metadata-failure,
and reconnect-budget paths.

---------

Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
2026-08-17 12:58:27 -05:00
Pranay Prakash 64b8866140 [ci] Bump Next.js canary e2e pin to 16.3.1-canary.17 (#3559)
## Summary & Motivation

The canary e2e lanes (E2E Local Dev / Local Prod / Local Postgres for
`nextjs-turbopack` and `nextjs-webpack`, × node/quickjs) were pinned to
`16.3.0-canary.2`. This bumps the pin to the current canary,
`16.3.1-canary.17`, in all six spots in `tests.yml`.

Bumping the pin alone is not enough: fresh Next canaries are younger
than the repo's 48h `minimumReleaseAge` gate, so the "Setup canary"
`pnpm install --no-frozen-lockfile` fails with
`ERR_PNPM_NO_MATURE_MATCHING_VERSION` (reproduced locally with this
exact version). This adds `next` and its lockstep-published `@next/*`
companion packages (`@next/env`, `@next/swc-*`) to
`minimumReleaseAgeExclude` — both are Vercel-published, matching the
trust model of the existing exclusions (`@vercel/*`, `turbo`,
`esbuild`).

## Validation

The full e2e suite was run locally against `next@16.3.1-canary.17` on
all three worlds (staged tarball workbenches, same as CI's
`prepare-workbench-path`):

| World | App / mode | Result |
| --- | --- | --- |
| `world-local` | nextjs-webpack, dev server, node vm | `dev.test.ts` 5
passed / 4 skipped (incl. the HMR fuzz test) · `test:e2e` 137 passed /
19 skipped |
| `world-postgres` | nextjs-turbopack, prod build, node vm |
`local-build.test.ts` 13 passed · `test:e2e` 137 passed / 19 skipped¹ |
| `world-vercel` | nextjs-turbopack, preview deployment
(`dpl_3siALD2WyrsrdPFjmRY3ymwEHZiJ`), node vm | `test:e2e` 134 passed /
22 skipped |

¹ First pass failed the 3 `pages router` tests because the local harness
ran `local-build.test.ts` without `CI=true`, which deletes the
`workflow-sourcemap-warning-fixture` package the built output still
references (the test preserves it only when `CI=true`, exactly as its
comment warns). With the fixture preserved, all 3 pass. Not a canary
issue.

Note: the vercel world has no canary lane in CI (deployments build from
the committed stable pin), so the preview-deployment run above is the
only canary coverage it got.

## Context on current `main` CI

Recent `main` runs are red, but not because of the canary pin: the
dominant failure is the nextjs-webpack local-dev HMR fuzz race
(SDK-side, fix in flight in #3529), which hits the stable and canary
lanes alike; the rest are the known rotating vercel-prod flakes. The
latest completed `main` run (with #3530's e2e retry) passed every canary
lane and failed only the *stable* webpack dev lane.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 10:37:05 -07:00
Shalabh Chaturvedi ec709ba533 [world-sim] Remove mint-ordered log (#3544)
Since we're moving to server side serialized ids, we dont need to
simulate the prior model.

## Summary
- remove the mint-ordered simulation mode and reservation API
- make commit-time log positions and lagging-prefix reads the only
simulator behavior
- simplify CI, scenarios, tests, and documentation to the single log
model

## Testing
- node --check on changed TypeScript files
- pnpm --filter @workflow/world-sim test (blocked: node_modules is
absent; vitest unavailable)

---------

Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
2026-08-17 10:35:41 -07:00
Pranay Prakash b0adb50bce feat(world,world-vercel): createBatch — ordered batch event write with per-event results (#3025)
## createBatch: the client half of the v4 batch event write (per-event
results, no fence)

> **Note:** this PR was rebuilt from scratch. The previous revision
implemented the retired "v2 suspension fence" design
(`expectedRunVersion` / `batchId` / `logicalCreatedAt`, a
grammar-validated collect mode, world-postgres migration 0016,
world-local claim machinery). The server redesigned its endpoint in
place (vercel/workflow-server#646, merged and deployed) and this branch
now targets that contract on top of current `main` (specVersion 6 slot
identity). The old head is tagged `batch-client-v2-fence-design`; prior
review threads reference deleted code.

### The server contract this targets

`POST /api/v4/runs/:runId/events/batch` (workflow-server#646): an
**ordered** list of v4 frames — byte-identical to single-event POST
frames, **no batch-level meta** — committed in one DynamoDB transaction
per attempt, answered with HTTP 200 + `{ results }`: one entry per
frame, in request order. Each event reports what its own single POST
would have returned: `200` + the materialized entity, or the single-path
status/code (e.g. `409`/`conflict` for an event an earlier delivery
already applied). A transport retry of a committed batch converges to
all-409s with nothing written twice — idempotency comes from per-entity
conditions, not batch bookkeeping. Slot-identity runs only (specVersion
≥ 6 — what `world-vercel` stamps on every new run since #3389).

### What this revision ships

1. **`@workflow/world` — the spec addition.** `Storage['events']` gains
one optional method; **method presence is the capability declaration**
(no capability flag, no stub required):

```ts
createBatch?(
  runId: string,
  events: BatchEventRequest[],
  params?: CreateEventBatchParams
): Promise<EventBatchResult>;

interface BatchEventRequest {
  event: CreateEventRequest;   // same discriminated union as the single create
  occurredAt?: Date;           // under slot identity: the source of the durable createdAt
}

type BatchEventItemResult =    // one per submitted event, in request order
  | { status: 200; event: Event; run?: WorkflowRun; step?: Step; wait?: Wait }
  | { status: number; error: string; message: string };

interface EventBatchResult { results: BatchEventItemResult[] }
```

Contract: **ordered** (events land in the log in request order),
**per-event outcomes** (each event reports what its own single `create`
would have returned — success discriminated by `error === undefined`),
**idempotent on retry** (per-entity conditions make a retried committed
batch converge to per-event 409s). Worlds that don't implement it keep
the single-event path. `world-local` and `world-postgres` deliberately
do NOT implement it — batching a local/in-process write buys nothing
(this deletes the old revision's riskiest surface: the hand-written
postgres migration and the world-local claim machinery).
2. **`@workflow/world-vercel`** — the wire adapter: per-event frames
concatenated in order (reusing the single-frame encoder; each frame
carries its own `occurredAt`, which under slot identity is the source of
the durable `createdAt` — this natively closes the replay-clock question
the old `logicalCreatedAt` field existed for), CBOR `{ results }`
decoded against the **same per-type zod schemas as the single POST**,
loud `SCHEMA_VALIDATION` on any malformed response (wrong length,
invalid item), and the standard typed error mapping for request-level
failures.
3. **Retry policy** — a `batchIdempotent` override in the event-retry
eligibility machinery: the whole batch POST retries transient transport
failures/5xx (and waits out 429 `Retry-After` per #3504) regardless of
the contained event types, because per-event entity conditions make the
retry converge; the per-type non-retryability matrix guards *single*
posts (where e.g. a retried bare `step_started` would increment
`attempt`) and doesn't apply inside a batch.

Tests: 7 wire tests — frame encoding/ordering + **no fence fields on the
wire**, per-event result mapping (successes typed, failures passed
through), malformed-response failures (length mismatch, invalid item
body with index), typed request-level 400s, in-process 5xx retry,
empty-batch guard — plus 9 suspension-handler tests for the runtime
fold: ordering (steps then waits), per-event 409 tolerance, non-409
failure propagation, every gate exclusion (flag off / no `createBatch` /
pre-slot run / hook writes), 32-cap chunking, and lazy-inline exclusion.
Full `world-vercel` suite: 508 passed; full `@workflow/core` suite: 2126
passed.

### The runtime integration: batched suspension fan-out (ON by default)

The suspension handler folds a **clean fan-out** — the suspension's
eager `step_created` + `wait_created` writes — into `createBatch` calls
of at most **32 events**, and uses the batch endpoint **exactly when two
or more batchable eager events exist**: a lone eager event takes the
ordinary single write (same round trip, and it keeps the slot-snapshot +
bump-and-report the single path provides) (mirroring the server's
transaction budgets: 2 items/event against the 100-item cap, 768 KB
inline-byte budget; larger fan-outs commit in successive batches). The
gate requires: World implements `createBatch` ∧ run on slot identity
(specVersion ≥ 6) ∧ no attribute writes ∧ no hook writes ∧ no resilient
step dispatch. **Everything outside the gate keeps the single-event path
byte-for-byte**, and lazy-inline steps keep deferring their
`step_created` to the lazy start exactly as before.

Per-event semantics mirror the single path: a `409` is the same
already-exists tolerance as `EntityConflictError` (the conflicted step
is not marked owned); any other per-event failure fails the suspension
write the way a single-path rejection would. Slot bumps (the batch
endpoint has no bump-and-report) are tolerated and logged — the same
accepted exposure as a dropped truncated skipped-slot report on the
single path.

**On by default**, with the `WORKFLOW_TURBO`-shaped kill switch as the
operator escape hatch: **`WORKFLOW_BATCH_TRANSITIONS=0`** (or `false`)
disables batching and restores the exact prior one-write-per-event path.
Documented in the worlds configuration reference and the changelog
entry. Burn-in watch: the `event_batch`-tagged slot-conflict metrics and
DynamoDB throttle monitors on the server side.

### Docs

- New v5 changelog entry **`changelog/batched-event-writes`**
documenting the World spec addition (full `createBatch` signature +
contract — the signature block is compile-checked against
`@workflow/world` by the docs code-sample checker), the runtime fold,
and the follow-up.
- `configuration/worlds` gains the **`WORKFLOW_BATCH_TRANSITIONS`**
reference entry: default on, `=0`/`false` as the documented escape
hatch.

### Staged follow-up: the deferred sequential transition (the STSO win)

Hold `step_completed(N)` across the replay turn and commit
`[step_completed(N), step_created(N+1), step_started(N+1)]` as one batch
at the next lazy start (the server folds the pair born-running). This
needs the synthetic-completion replay machinery rebuilt against today's
runtime (parallel inline batches, turbo's run-ready barrier, optimistic
starts, slot bookkeeping) — it stays a separate PR so the SDK's most
sensitive replay path gets its own focused review. Its acceptance
criteria are already agreed: the runtime eligibility matrix as unit
tests, and an e2e that asserts ≥1 POST to `/events/batch` and **zero**
single-event POSTs for the batched transitions.

### Compatibility

- Old servers: no `/batch` route → 404/405 → callers fall back to
single-event posts (the runtime PRs will latch this per run).
- Pre-slot runs: request-level 400 (`batch-requires-slot-identity`) →
same fallback.
- No `WORKFLOW_SERVER_URL_OVERRIDE` pin this time — the server endpoint
is merged and deployed to production.

Refs: vercel/workflow-server#646 (endpoint), vercel/workflow-server#780
(unbatchable-types design space), #3389 (slot identity), #3504 (429
retry).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 17:42:33 -07:00
Peter Wielander 1321570464 [docs] Document duplicate-event handling, and describe webhook token generation accurately (#3497) 2026-08-14 16:00:21 -07:00
Alex Langenfeld ae1637502b [next] Fix dev HMR dropping edits that race a full rediscovery rebuild (#3529)
## Summary & Motivation

A full rebuild reads sources twice — once when the bundler consumes
them, once when the classifier baseline is refreshed from disk
afterwards — so an edit landing in between was absorbed into the
baseline without being built, and its queued watcher event classified as
a no-op. `pinBaselinesAcrossFullRebuild` pins baselines across the
rebuild instead: pre-build values for files present in both maps, and
the snapshots `classifyRebuild` read for the files that triggered the
rebuild, so routine duplicate watcher events still diff equal rather
than cascading into another full rebuild.

This is the largest ongoing CI flake — the nextjs-webpack local-dev e2e
HMR fuzz test writes a step definition right after triggering a
rediscovery and times out at 300s when the write lands mid-rebuild (~60
attempt-1 job failures in 10 days). For files the in-flight rebuild
discovered for the first time this narrows a create-then-edit race
rather than closing it; the initial-build startup window has the same
shape and is out of scope, documented at the call site.

## Test Plan

Unit tests cover the absorb race and the duplicate-event no-op; the
previously-flaky HMR fuzz e2e passed 3/3 locally against nextjs-webpack
where main reproduced the manifest drop, and a mid-rediscovery edit rig
went from 2/6 dropped on base to 0/6.

Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
2026-08-14 17:55:39 -05:00
Alex Langenfeld 234d3dd7b8 [core] Replace never-picked-up e2e runs instead of failing whole tests (#3560)
## Summary & Motivation

The e2e `start()` wrappers now poll the new run until it leaves
`pending`. A run still pending after `WORKFLOW_E2E_PICKUP_BUDGET_MS`
(default 15s) has executed no workflow code, so it is abandoned and
replaced in place and the test continues — one replacement, with the
CI-level retry still the backstop if that one stalls too.

Each replacement is recorded to an `e2e-infra-*.json` sidecar that every
e2e job uploads, and the aggregation script renders it as an "Infra
Events" section in the step summary and PR comment, so clustered
timestamps read as a backend blip rather than as unrelated flaky tests.

## Test Plan

Unit tests cover the pickup watchdog; a local nextjs-turbopack run
exercised both the clean path and, with a forced 1ms budget, the
replacement path end to end, and the aggregation script was smoke-tested
against synthetic sidecars in both modes.

Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
2026-08-14 16:48:08 -05:00
Fantix King 09b299a03e [e2e] Add Python e2e Test (#3369)
Turns `packages/core/e2e/e2e.test.ts` into a cross-language conformance
suite and adds `workbench/python` as its first non-JavaScript subject.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 16:56:05 -04:00
Alex Langenfeld af91cc2582 bench: per-chunk stream latency (CRTT/CDV) and replay-driven stream scenarios (#3393)
## Summary & Motivation

- **CRTT (chunk round-trip time)** — per-chunk write→read latency for a
paced stream, aggregated inside the reader step on the deployment (one
clock domain) into a fixed log-bin histogram plus index buckets and
mean-RTT profiles over stream progress and chunk size. Fills the gap
between SL (first chunk only) and SO (whole-stream throughput), where a
mid-stream delivery regression was invisible. It is deliberately a
*round*-trip name: the future production one-way write→read metric is
CTT, with its own skew caveats.
- **CDV (chunk delay variation)** — inter-arrival gap minus inter-write
gap per seq-adjacent pair, so each gap subtracts same-clock stamps and
the stat stays skew-free and measurable in production later. Reported as
each run's max positive value, since a 1-in-300 delivery stall dilutes
out of pooled percentiles.
- **Replay scenarios** — two real captured cadences (eve envelope
protocol via gpt-5.6-sol; raw gateway SSE via gpt-5.4-nano) replayed
through the same rig on an absolute open-loop schedule, so the workload
is measured rather than invented; the 2x speed multiplier is the only
chosen number, and matches how real fast-tier models behave (same chunk
sizes, compressed time). Each capture carries a semantic sha256 over
canonical `(offsetMs, bytes)` tuples so durabench's independent copy can
be checked for drift.
- **Streams table** — stream scenarios render in their own table with
writer/reader sustained rates, CRTT percentiles, and median worst stall.
No pass/fail targets yet: numbers and vs-main deltas only.
- **SL/SO report rows retired** — CRTT's seq-0 slice reproduces SL and
its aggregate reproduces SO's signal at ~100x the samples; write slip
stays as artifact-only data, the only guard for producer stalls that
neither CRTT nor CDV can see.

## Test Plan

- [x] Unit tests for the bucketing/merge/CDV helpers and the renderer;
the full benchmarks job ran green against real preview deployments, and
the first Streams numbers separated workload strain (eve 2x: read 173 <
write 181 c/s, CRTT p75 1278ms) from the transport floor (the paced
control and the 1x reality row both clean).

---------

Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
2026-08-14 15:53:11 -05:00
github-actions[bot] df1c7f1969 Version Packages (beta) (#3466) workflow@5.0.0-beta.42 2026-08-14 11:42:17 -07:00
Peter Wielander f5591aa278 [e2e] Fix event-log-race-repro for local/postgres (#3558) 2026-08-14 11:34:29 -07:00
Shalabh Chaturvedi 63cc09e24f feat(world-vercel): enrich STSO event spans (#3536)
Enrich the existing v4 event-write `http POST` **CLIENT** span. No new
span is created.

The modified span is emitted by HTTP event writes through `fetchV4` /
`instrumentedFetch` and WebSocket event writes through the existing
synthetic parity span in `postEventFrameOverWs` / `withHttpClientSpan`.

## Added attributes

- `workflow.client.version` on every v4 event write, formatted as
`@workflow/world-vercel/<version>`
- `step.stso_ms` when the event input carries step-to-step overhead
telemetry
- `step.latency_optimizations` when the event input carries the active
runtime optimization list, such as `lazyStepStart`, `turbo`, and
`optimisticStart`

The span already inherits the client application OTEL `service.name` and
carries the event type, transport, URL, and status. The added fields
allow sampled STSO analysis by client service, Workflow SDK version, and
runtime mode on one span.

## Validation

- Dependency-aware `@workflow/world-vercel` build
- `ws-transport-spans.test.ts`: 13 tests passed
- `@workflow/world-vercel` typecheck
- Biome and `git diff --check`

---------

Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
2026-08-14 11:33:51 -07:00
Alex Langenfeld 60dd2065f3 [core] Fix flaky timing-sensitive tests: events-consumer deferred-check budgets and TTL-expiration e2e timeout (#3531)
* [core] Give events-consumer deferred-check tests a stall-proof budget

The 'duplicate event classes' tests reach their outcome through the
deferred check's multi-stage timer chain (promise queue -> setTimeout(0)
-> idle poll -> delay timer), which a loaded CI runner with coarse
timers can starve for whole seconds. The afterDeferredCheck poll capped
that at 2s and two follow-up assertions used vi.waitFor's 1s default,
inside the 5s default test timeout - regularly starved through on
Windows runners ('does not track hook deliveries' and 'leaves a
duplicate run_cancelled' flaked ~1.5x/day over the last 10 days,
failing with strandedEvent/parkedSummary still undefined).

The polls return as soon as their assertions hold, so raising the poll
timeout to 15s and the suite budget to 30s costs healthy runs nothing
while bounding only genuinely stalled runners.

Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>

* [core] Match the TTL-expiration abort e2e test budget to its siblings

The other distributedAbortController tests run with a 60s budget; the
TTL-expiration one got 30s. Its 3s TTL is trivial, but on a fresh prod
deployment cold starts plus queue backlog routinely push run start +
first stream delivery past 30s - it timed out on two apps
simultaneously in a single Tests run over the last week.

Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>

* Add changeset

Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>

---------

Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
2026-08-14 13:24:26 -05:00
Peter Wielander 0c5a6495bc [ci] Report all three event-log-race-repro lanes in one small PR comment (#3556) 2026-08-14 10:59:00 -07:00
Andrew Barba 3560218937 fix(core): preserve order across adjacent hook deliveries (#3552)
Signed-off-by: Andrew Barba <barba@hey.com>
2026-08-14 10:51:22 -07:00
Peter Wielander de2a86c61c [world] Make spec version 6 the current version (#3542) 2026-08-14 10:22:21 -07:00
Alex Langenfeld c041d3d231 [ci] Retry e2e tests once in CI, keeping retried tests visible (#3530)
* [ci] Retry e2e tests once in CI, keeping retried tests visible

Over the last 10 days ~93 Tests runs were manually re-run until green,
some taking 6 attempts: the e2e suites drive real deployments, and a
single test losing a timing race fails a whole 20+ minute matrix job.
A CI-only vitest retry (retry: 1) absorbs those single-test races.
beforeEach/afterEach hooks run per attempt, so suites with file-restore
hooks (dev.test.ts) retry cleanly.

Retries must not hide real races, so a retried-then-passed test stays
visible everywhere a failure would have been: the github-reporter emits
::warning annotations and an e2e-flaky-*.json sidecar, every e2e job
uploads it, and aggregate-e2e-results.js renders a 'Flaky E2E Tests
(passed on retry)' section in both the per-job step summary and the PR
comment, with per-app occurrence counts.

Harnesses whose failures are themselves the signal pin retry: 0:
event-log-race-repro (a pass runs the full configured budget) and
benchmarks (a regression should not be papered over by a luckier
second sample). Local runs keep retry at 0 so races reproduce while
debugging.

Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>

* Add changeset

Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>

---------

Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
2026-08-14 12:08:41 -05:00
Peter Wielander dc85865718 [core] Drop pre-slot event ID support and preconditionGuard capability (#3519) 2026-08-13 15:57:28 -07:00
Peter Wielander 834d1f945f [web-shared] Mark ignored duplicate events in the observability UI (#3467) 2026-08-13 12:07:13 -07:00
Nathan Rajlich 8d8b7b3845 [world-vercel] Retry throttled (429) event writes in-process, honoring Retry-After (#3504) 2026-08-13 18:33:36 +00:00
Peter Wielander 0b7c9671ee [bench] Add a Promise.all fan-out scenario with Fan-out TTFS/TTLS rows (#3522) 2026-08-13 11:00:50 -07:00
Peter Wielander 3450c867f6 [core] Poll for the deferred check instead of sleeping past it (#3507) 2026-08-13 08:58:58 -07:00
Shalabh Chaturvedi 01991edeeb feat(world-vercel): synthesize per-event client spans on the WS transport (#3452)
* feat(world-vercel): synthesize per-event client spans on the WS transport

PR #3084 added the opt-in `WORKFLOW_EVENTS_TRANSPORT=ws` path and listed
"no client-side span on the WS path" as a known limitation. Because event
writes become multiplexed frames on one long-lived socket rather than
individual `fetch` calls, the per-event `http POST` CLIENT span that the
HTTP transport produced simply disappeared — traces went from one span
per event to nothing between the invocation and the server.

Restore it by synthesizing a request-shaped span around each frame, and
give the upgrade its own span:

- Extract `withHttpClientSpan` / `recordClientSpanStatus` from
  `instrumentedFetch` in `http-core.ts` so the synthetic span is emitted
  by the same envelope as the real one and cannot drift from it.
  `InstrumentedFetchOptions` now extends `HttpClientSpanOptions`.
- `postEventFrameOverWs` opens `http POST` with `url.full` pointing at the
  v4 REST endpoint the frame is forwarded into, so per-event traces and
  latency dashboards keep working across the flag. Extract `eventsV4Url`
  so that URL cannot drift from the one the HTTP path actually requests.
- Tag both transports with `workflow.events.transport` (`http` | `ws`) and
  `workflow.event.type`; the WS path additionally sets
  `network.protocol.name=websocket`, `workflow.events.ws.url` (the real
  wire destination) and `workflow.events.ws.req_id` (join key to the
  server's log line for the frame), so the span is never mistaken for a
  real HTTP request.
- Add a `workflow.events.ws.connect` span around the upgrade — the one
  genuinely-HTTP request here, previously the invisible half of every WS
  write's latency — carrying `workflow.events.ws.reconnect_attempt`. This
  also puts `resolveUpgradeHeaders`' trace-context injection inside a
  client span, as AGENTS.md requires.
- Fix `parseServer` to treat `wss:` as TLS (port 443, not 80).

Out of scope, deliberately: per-frame `traceparent` (needs a frame-meta
field plus a server change) and Vercel's outgoing-requests view (that
instruments global `fetch`, so a frame structurally cannot appear there).

Covered by `ws-transport-spans.test.ts`, which drives the real selection +
transport + adapter stack over a fake socket and asserts span shape,
failure reporting, retry behaviour and HTTP/WS parity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Co-Authored-By: shalabhchaturvedi-7802 <shalabh.chaturvedi@vercel.com>

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* chore: trim WS spans changeset to the user-facing summary

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Co-Authored-By: shalabhchaturvedi-7802 <shalabh.chaturvedi@vercel.com>

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* fix(world-vercel): only tag event-write spans with transport

Signed-off-by: Shalabh Chaturvedi <shalabh.chaturvedi@vercel.com>

Co-Authored-By: shalabhchaturvedi-7802 <shalabh.chaturvedi@vercel.com>

* fix(world-vercel): format WS transport span regression test

Signed-off-by: Shalabh Chaturvedi <shalabh.chaturvedi@vercel.com>

Co-Authored-By: Shalabh Chaturvedi <shalabh.chaturvedi@vercel.com>

---------

Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
2026-08-13 00:06:59 -07:00
Karthik Kalyan f1ef0cbf03 Deprecate world.runs.list for observability (#3404)
* Deprecate storage run listing for observability

* Document analytics run listing guidance
2026-08-12 14:36:21 -07:00
Peter Wielander 0f4b35f629 [world-local] Fix CORRUPTED_EVENT_LOG from hook-resume staging slot holes (#3492) 2026-08-12 13:16:42 -07:00
Peter Wielander 8931bee41b [world-local] Give the complete-preload test a budget the Windows runner can meet (#3503) 2026-08-12 13:09:35 -07:00
Peter Wielander b589460ce8 [core] Report the replay position on every event write (#3479) 2026-08-12 13:08:18 -07:00