Skip to content

feat: batch step transitions on by default, implemented on every World#3025

Draft
pranaygp wants to merge 17 commits into
mainfrom
pgp/batch-transition-client
Draft

feat: batch step transitions on by default, implemented on every World#3025
pranaygp wants to merge 17 commits into
mainfrom
pgp/batch-transition-client

Conversation

@pranaygp

@pranaygp pranaygp commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

What

Batch step transitions, on by default. In the sequential inline runtime path, a step transition — completing step N, then creating + starting the lone next inline step N+1 — folds into one durable, atomic write instead of the two serialized event writes it did before:

  • step_completed(N) — ~65–92ms in production traces
  • a lazy step_started(N+1) — ~50–70ms

Warm STSO was ~190ms/step (two HTTPS round-trips, each doing a run read → step-claim transact → patch → event insert) vs Temporal's ~47ms. Folding the transition into one conditional batch removes a full round-trip and commits the next step's create-claim atomically with the previous completion (strictly fewer intermediate crash states).

This is not optimistic start. Step N+1's body runs only after the batch (its durable claim) returns; the latency win is purely from collapsing two RTTs into one.

Default-on + temporary kill switch

WORKFLOW_BATCH_TRANSITIONS is on by default. Setting it to 0 (or false) is an emergency kill switch that restores the exact prior two-write path (step_completed then a lazy step_started) byte-for-byte — mirroring the default-on + kill-switch shape of WORKFLOW_TURBO and WORKFLOW_RETURN_VALUE_STREAM.

The kill switch is temporary. It exists only as a burn-in rollback control and is planned for removal once the batch path has production mileage; it is not a long-term user-configurable option. A follow-up will delete the flag (and the single-write deferral branch it guards) once we're confident. Documented as such in docs/.../runtime-tuning.mdx.

How

  • @workflow/world (interfaces): events.createBatch(runId, events, params) returns one EventResult per event in order plus an optional inline delta.
  • @workflow/world-vercel: createBatch as 1..N back-to-back v4 frames, each byte-identical to a single-event POST body ([u32 meta_len][cbor meta][u32 body_len][body]), Content-Type: application/octet-stream, no envelope/sentinel (the server reads to EOF). sinceCursor/stateUpdatedAt ride on the step_completed frame's meta; vercelId per frame. Response is CBOR { results, eventsDelta? }. Transient transport failures retry in-process (the batch is idempotent-on-retry).
  • @workflow/core: in the sequential inline path, defer a lone inline step's step_completed (from the 2nd step onward — the first step stays on the single-write path so its TTFS telemetry is measured as today), then at the next clean single-lazy-inline suspension issue the batch, seed N+1 via executeStep's preStarted param, and consume the batch eventsDelta like pendingInlineDelta.

Implemented on every World (real e2e coverage)

The batch path is no longer opt-in-by-World with a silent single-write fallback — every World implements createBatch, so the default-on path is genuinely exercised in each World's own test suite:

  • world-vercel — the /events/batch endpoint (above).
  • world-postgres — the whole transition in one drizzle.transaction (true all-or-nothing: any error rolls the batch back). Threads every read/write through the tx handle (the module-level prepared statements run on a separate pooled connection and wouldn't see uncommitted rows). A bornRunning set tracks the create-claim winner; stepCreated:true is stamped only on a fresh born-running commit, omitted on already-applied/lost-race. Tested against a testcontainers Postgres, including a dedicated all-or-nothing rollback test.
  • world-local — a per-run write sequence guarded by its on-disk exclusive claims. The transition is "complete N, then born-run N+1", and the single-create path already has a battle-tested born-running fold (the lazy step_started carrying the create input), so createBatch composes it: create(step_completed(N)) then a lazy born-running create(step_started(N+1)), making the durable state structurally identical to the kill-switch two-POST path (asserted directly against a real fs-backed storage). Atomicity is honest for a filesystem World: there's no cross-entity transaction, and the causal invariant completed(N) < created(N+1) requires completing N first, so strict all-or-nothing + causal ordering can't both hold on a plain FS; an already-applied peek makes redelivery a no-op, and the only residual window can't occur in the single-process dev model and would otherwise surface as the same EntityConflictError lost-claim the single path already handles.

Across all Worlds, stepCreated on the started result is the create-claim winner signal; an already-applied retry or lost race omits it so the non-committer re-derives from a fresh replay instead of double-running the body (matches the server, lib/data/events.ts:4808-4824).

Deployment sequencing (required) + review-time server pin

  • Server before SDK. The server endpoint (vercel/workflow-server#646, POST /api/v4/runs/:runId/events/batch) must be deployed to production before this change ships in an @workflow/* SDK release. With the flag now default-on, a client that reaches a server without the endpoint would otherwise attempt one batch POST per invocation before falling back.
  • 404-disable is per-invocation, by design (not per-process). On a 404/405 the runtime disables batching for the rest of that invocation, flushes the deferred completion, and falls back to single POSTs; a fresh delivery re-probes. This is deliberate: workflow-server is a stateless, heterogeneous fleet, so during the fix: include next config in web package #646 rollout a client's batch POSTs hit different server instances round-robin. Per-invocation re-probing opportunistically uses the endpoint on instances that already have it and degrades gracefully as the rollout completes; a per-process latch would stick pessimistically off for a warm instance's whole lifetime after a single early 404 from a not-yet-updated instance. The deployment-sequencing guarantee above avoids the window entirely; per-invocation re-probe is the graceful behavior during any overlap.
  • ⚠️ Intentional CI merge gate — do not "fix". This PR pins WORKFLOW_SERVER_URL_OVERRIDE (packages/world-vercel/src/utils.ts) to the fix: include next config in web package #646 preview (https://workflow-server-8sp9l2esp.vercel.sh, server head a588ed3d) so the e2e/integration lanes run against the real batched backend before fix: include next config in web package #646 is in production. The CI lint rule that requires this constant to be empty on main will fail while the pin is present — that failure is the deliberate merge gate. Removal is a single-line revert to '' once fix: include next config in web package #646 deploys to production. (Three events-v4 unit tests that hardcoded the old mock origin were parameterized on the same override constant so the unit suite stays honest under the pin — correct with or without it.)

Fallbacks (restore prior behavior exactly)

  • Kill switch (=0/false); parallel steps / hooks / waits / attribute writes / run-completion / non-inline steps / first step of the invocation / final step alone.
  • Server 404/405 (endpoint absent) → disable batching for the rest of the invocation + flush the deferred completion, then single POSTs (see above).
  • 409 (claim lost) / 410 (run not running) / 412 (stale snapshot) → abandon the deferred completion and re-derive from a fresh replay, never failing the run. The batch is idempotent end-to-end, so transport errors are safe to retry.

With the kill switch thrown, all new paths are inert, so the disabled path is byte-for-byte the prior behavior (asserted by a test).

Correctness analysis

Reviewed adversarially against the server implementation (workflow-server#646). Both blockers found in review are fixed:

  • Exactly-once body execution on an already-applied 200 — FIXED (was a blocker). The server returns a 200 with eventWasCreated:false and no stepCreated when a canceled transaction finds step N already completed and step N+1 already existing — regardless of who created N+1 (resolveStepTransitionBatchCancellation, lib/data/events.ts:4808-4824). The batch path previously seeded preStarted from results[last].step unconditionally and ran N+1's body even when a concurrent/redelivered owner had won the claim (double-execution in the zombie/owned-recovery window; the single-event path would have skipped). Fix: gate preStarted + body execution on startedResult.stepCreated === true; on a falsy signal, abandon and reconcile from a fresh replay. Regression: runtime-batch-already-applied.adversarial.test.ts + a positive control in runtime-batch-transitions.test.ts.
  • Attribute-ordering causal invariant — FIXED (was a round-2 blocker). A pending deferred completion is flushed before any suspension that writes attr_set, preserving completed(N) < attr_set. Mechanism: a suspension carrying an attribute write is excluded from batchTransitionCandidate (err.attributeCount === 0), so pendingBatchTransition && !batchTransitionCandidate fires the single-path flush of completed(N) before handleSuspension writes attr_set. Without this, the hasAttributeEvents replay continue restarted the loop before the batch commit, stranding completed(N) and re-deriving attr_set each iteration until a replay-budget run_failed (livelock + ordering inversion the single-write path can't produce). Regression: runtime-batch-attr-ordering.adversarial.test.ts asserts batch-on produces the identical durable event order to the disabled control for both the raced and un-raced shapes; verified to fail (livelock) with the one-line fix removed.
  • Mixed prior state / crash windows / attempt accounting / synthetic-completion-not-in-cache: unchanged from the prior analysis — never a wrong result, only at worst an extra round-trip; the deferred completion is only ever durable via the batch's atomic commit (or a single-path flush), so a replay can't observe an uncommitted completion.

Known follow-up (non-blocking)

  • 409 {failedIndex, reason} → in-place skipped (perf). The server serializes {failedIndex, reason} on a 409; a step-claim-lost on the started element could map to the in-place skipped handling instead of a full reinvoke. Deferred: the batch is all-or-nothing, so a step-claim-lost also leaves completed(N) un-written — not a clean in-place substitution, and the safe abandon+reinvoke already handles it.

Tests

  • world-vercel/events-batch.test.ts — wire contract (concatenated frames, no sentinel, octet-stream, sinceCursor/stateUpdatedAt on the step_completed frame, vercelId per frame), nested eventsDelta decode, error mapping (409/410/412/404 no-retry).
  • core/runtime-batch-transitions.test.tsdefault (no env) folds the middle transition into one [completed, created, started] batch; kill switch (=0) asserts the exact two-POST-per-step pattern (no createBatch); world-lacks-createBatch and 404 disable and fall back; 409/410 abandon + nack without failing the run; exactly-once body counters.
  • core/runtime-batch-already-applied.adversarial.test.ts — body does not run on an already-applied 200 (no stepCreated).
  • core/runtime-batch-attr-ordering.adversarial.test.ts — batch-on produces the identical durable event order to the disabled control (raced + un-raced shapes).
  • world-local/storage-batch.test.tsreal fs-backed: happy-path born-run + stepCreated; structural equality vs the two-POST kill-switch path; idempotent re-application (no new events, no stepCreated); malformed-batch rejection.
  • world-postgres/test/storage-batch.test.tstestcontainers Postgres: happy-path; structural equality vs two-POST (step ids normalized to roles, since stepId is a global PK); idempotent re-application; all-or-nothing rollback (a failure mid-batch leaves N running, M absent, zero new events); empty/missing-runId rejection.

Suites green under default-on: @workflow/core (1505 passed, 3 expected-fail), @workflow/world-vercel (305), @workflow/world-local (469), @workflow/world-postgres (153, testcontainers). Typecheck clean across all five packages. (The e2e/* build/deploy tests need a DEPLOYMENT_URL and build toolchain — not runnable locally; they exercise the batch path in CI against the pinned #646 preview.)

🤖 Generated with Claude Code

Collapse a durable step transition — complete step N, then create & start
the lone next inline step N+1 — into ONE atomic POST to the world's
`events/batch` endpoint, replacing the two serialized event writes
(step_completed(N) ~65-92ms + a lazy step_started(N+1) ~50-70ms in
production traces). Removes a full HTTPS round-trip per step; the create
claim now commits atomically with the previous completion.

- world (interfaces): optional `events.createBatch(runId, events, params)`
  returning one EventResult per event plus an optional inline delta. Optional
  so world-local / world-postgres stay valid and fall back to `create`.
- world-vercel: implement createBatch as 1..N back-to-back v4 frames, each
  byte-identical to a single-event POST body (Content-Type
  application/octet-stream, no envelope/sentinel — the server reads to EOF).
  sinceCursor/stateUpdatedAt ride on the step_completed frame's meta;
  vercelId per frame. Response is CBOR { results, eventsDelta? }. Retries
  transient transport failures in-process (the batch is idempotent-on-retry).
- core: `WORKFLOW_BATCH_TRANSITIONS` (default OFF). In the sequential inline
  path, defer a lone inline step's step_completed (from the 2nd step onward),
  then at the next clean single-lazy-inline suspension issue the batch, seed
  N+1 via a new executeStep `preStarted` param, and consume the batch
  eventsDelta like pendingInlineDelta. NOT optimistic start — N+1's body runs
  only after the batch returns.

Fallbacks restore today's behavior exactly: flag off, World without
createBatch, 404/405 (endpoint absent → disable batching for the invocation
+ flush the deferred write), parallel/hooks/waits/run-completion/first-step/
final-step. 409 (claim lost) / 410 (not running) / 412 (stale) abandon the
deferred completion and re-derive from a fresh replay without failing the run.

All new paths gate on `isBatchTransitionsEnabled()` / a pending deferral, so
the default-off path is byte-for-byte today's behavior.

Relates to vercel/workflow-server#646 (server endpoint). Independent of and
composable with the deferred-write mechanism in #2972.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jul 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e80a06d

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

This PR includes changesets to release 20 packages
Name Type
@workflow/world-vercel Minor
@workflow/world-postgres Minor
@workflow/world-local Minor
@workflow/world Minor
@workflow/core Minor
@workflow/builders Patch
@workflow/cli Patch
@workflow/web Patch
@workflow/vitest Patch
@workflow/world-testing Patch
@workflow/web-shared Patch
@workflow/next Patch
@workflow/nitro Patch
workflow Minor
@workflow/astro Patch
@workflow/nest Patch
@workflow/nuxt Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite Patch

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

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

@vercel

vercel Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated (UTC)
example-nextjs-workflow-turbopack Ready Ready Preview, Comment Jul 21, 2026 2:16pm
example-nextjs-workflow-webpack Ready Ready Preview, Comment Jul 21, 2026 2:16pm
example-workflow Ready Ready Preview, Comment Jul 21, 2026 2:16pm
workbench-astro-workflow Ready Ready Preview, Comment Jul 21, 2026 2:16pm
workbench-express-workflow Ready Ready Preview, Comment Jul 21, 2026 2:16pm
workbench-fastify-workflow Ready Ready Preview, Comment Jul 21, 2026 2:16pm
workbench-hono-workflow Ready Ready Preview, Comment Jul 21, 2026 2:16pm
workbench-nestjs-workflow Ready Ready Preview, Comment Jul 21, 2026 2:16pm
workbench-nitro-workflow Ready Ready Preview, Comment Jul 21, 2026 2:16pm
workbench-nuxt-workflow Ready Ready Preview, Comment Jul 21, 2026 2:16pm
workbench-sveltekit-workflow Ready Ready Preview, Comment Jul 21, 2026 2:16pm
workbench-tanstack-start-workflow Ready Ready Preview, Comment Jul 21, 2026 2:16pm
workbench-vite-workflow Ready Ready Preview, Comment Jul 21, 2026 2:16pm
workflow-docs Ready Ready Preview, Comment, Open in v0 Jul 21, 2026 2:16pm
workflow-swc-playground Ready Ready Preview, Comment Jul 21, 2026 2:16pm
workflow-tarballs Ready Ready Preview, Comment Jul 21, 2026 2:16pm
workflow-web Ready Ready Preview, Comment Jul 21, 2026 2:16pm

@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

Passed Failed Skipped Total
✅ ▲ Vercel Production 1455 0 239 1694
✅ 💻 Local Development 1486 0 208 1694
✅ 📦 Local Production 1621 0 227 1848
✅ 🐘 Local Postgres 1621 0 227 1848
✅ 🪟 Windows 154 0 0 154
✅ 📋 Other 1020 0 212 1232
✅ vercel-multi-region 27 0 0 27
Total 7384 0 1113 8497

Details by Category

✅ ▲ Vercel Production
App Passed Failed Skipped
✅ astro 126 0 28
✅ example 126 0 28
✅ express 126 0 28
✅ fastify 126 0 28
✅ hono 126 0 28
✅ nextjs-turbopack 151 0 3
✅ nextjs-webpack 151 0 3
✅ nitro 126 0 28
✅ nuxt 126 0 28
✅ sveltekit 145 0 9
✅ vite 126 0 28
✅ 💻 Local Development
App Passed Failed Skipped
✅ astro-stable 128 0 26
✅ express-stable 128 0 26
✅ fastify-stable 128 0 26
✅ hono-stable 128 0 26
✅ nextjs-turbopack-stable 154 0 0
✅ nextjs-webpack-canary 135 0 19
✅ nextjs-webpack-stable 154 0 0
✅ nitro-stable 128 0 26
✅ nuxt-stable 128 0 26
✅ sveltekit-stable 147 0 7
✅ vite-stable 128 0 26
✅ 📦 Local Production
App Passed Failed Skipped
✅ astro-stable 128 0 26
✅ express-stable 128 0 26
✅ fastify-stable 128 0 26
✅ hono-stable 128 0 26
✅ nextjs-turbopack-canary 135 0 19
✅ nextjs-turbopack-stable 154 0 0
✅ nextjs-webpack-canary 135 0 19
✅ nextjs-webpack-stable 154 0 0
✅ nitro-stable 128 0 26
✅ nuxt-stable 128 0 26
✅ sveltekit-stable 147 0 7
✅ vite-stable 128 0 26
✅ 🐘 Local Postgres
App Passed Failed Skipped
✅ astro-stable 128 0 26
✅ express-stable 128 0 26
✅ fastify-stable 128 0 26
✅ hono-stable 128 0 26
✅ nextjs-turbopack-canary 135 0 19
✅ nextjs-turbopack-stable 154 0 0
✅ nextjs-webpack-canary 135 0 19
✅ nextjs-webpack-stable 154 0 0
✅ nitro-stable 128 0 26
✅ nuxt-stable 128 0 26
✅ sveltekit-stable 147 0 7
✅ vite-stable 128 0 26
✅ 🪟 Windows
App Passed Failed Skipped
✅ nextjs-turbopack 154 0 0
✅ 📋 Other
App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 128 0 26
✅ e2e-local-dev-tanstack-start- 128 0 26
✅ e2e-local-postgres-nest-stable 128 0 26
✅ e2e-local-postgres-tanstack-start- 128 0 26
✅ e2e-local-prod-nest-stable 128 0 26
✅ e2e-local-prod-tanstack-start- 128 0 26
✅ e2e-vercel-prod-nest 126 0 28
✅ e2e-vercel-prod-tanstack-start 126 0 28
✅ vercel-multi-region
App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: success
  • Local Dev: failure
  • Local Prod: success
  • Local Postgres: success
  • Windows: success

Check the workflow run for details.

@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit e80a06d · Tue, 21 Jul 2026 14:27:46 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 251 (±0%) 1285 🔴 (-5.6%) 1319 🔴 (-5.9%) 1371 🔴 (-8.4%) 30
TTFS stream 238 (-8.5%) 1272 🔴 (-5.9%) 1287 🔴 (-6.3%) 1335 🔴 (-9.1%) 30
TTFS hook + stream 385 (+9.4%) 1477 🔴 (-4.1%) 1552 🔴 (-1.6%) 1650 🔴 (-6.3%) 30
STSO 1020 steps (1-20) 169 (-7.7%) 256 🔴 (-25%) 💚 312 🔴 (-20%) 💚 346 🔴 (-51%) 💚 19
STSO 1020 steps (101-120) 183 (-7.6%) 250 🔴 (-15%) 273 🔴 (-26%) 💚 480 🔴 (+0.6%) 19
STSO 1020 steps (1001-1020) 463 (-12%) 548 🔴 (-13%) 594 🔴 (-16%) 💚 1164 🔴 (+40%) 🔻 19
WO 1020 steps 394483 (-2.9%) 394483 (-2.9%) 394483 (-2.9%) 394483 (-2.9%) 1
SL stream latency 95 (+3.3%) 184 🔴 (+6.4%) 217 🔴 (+3.3%) 295 🔴 (+19%) 🔻 30
📜 Previous results (4)

ff16390

Tue, 21 Jul 2026 12:37:22 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 212 (-16%) 💚 1286 🔴 (-5.5%) 1335 🔴 (-4.8%) 1681 🔴 (+12%) 30
TTFS stream 246 (-5.4%) 1272 🔴 (-5.9%) 1307 🔴 (-4.9%) 1380 🔴 (-6.1%) 30
TTFS hook + stream 447 (+27%) 🔻 1567 🔴 (+1.8%) 1632 🔴 (+3.4%) 1707 🔴 (-3.0%) 30
STSO 1020 steps (1-20) 179 (-2.2%) 302 🔴 (-12%) 349 🔴 (-10%) 386 🔴 (-46%) 💚 19
STSO 1020 steps (101-120) 197 (-0.5%) 284 🔴 (-3.1%) 316 🔴 (-14%) 366 🔴 (-23%) 💚 19
STSO 1020 steps (1001-1020) 685 (+30%) 🔻 3037 🔴 (+381%) 🔻 3146 🔴 (+344%) 🔻 3169 🔴 (+282%) 🔻 19
WO 1020 steps 1224865 (+202%) 🔻 1224865 (+202%) 🔻 1224865 (+202%) 🔻 1224865 (+202%) 🔻 1
SL stream latency 105 (+14%) 173 🔴 (±0%) 198 🔴 (-5.7%) 471 🔴 (+91%) 🔻 30

e0eab39

Tue, 21 Jul 2026 11:48:45 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1137 (+353%) 🔻 1291 🔴 (-5.1%) 1321 🔴 (-5.8%) 1365 🔴 (-8.8%) 30
TTFS stream 1217 (+368%) 🔻 1283 🔴 (-5.1%) 1311 🔴 (-4.6%) 1349 🔴 (-8.2%) 30
TTFS hook + stream 1412 (+301%) 🔻 1511 🔴 (-1.9%) 1568 🔴 (-0.6%) 1618 🔴 (-8.1%) 30
STSO 1020 steps (1-20) 166 (-9.3%) 254 🔴 (-26%) 💚 296 🔴 (-24%) 💚 314 🔴 (-56%) 💚 19
STSO 1020 steps (101-120) 170 (-14%) 245 🔴 (-16%) 💚 264 🔴 (-28%) 💚 265 🔴 (-44%) 💚 19
STSO 1020 steps (1001-1020) 651 (+23%) 🔻 3131 🔴 (+396%) 🔻 3192 🔴 (+350%) 🔻 3258 🔴 (+293%) 🔻 19
WO 1020 steps 1151488 (+183%) 🔻 1151488 (+183%) 🔻 1151488 (+183%) 🔻 1151488 (+183%) 🔻 1
SL stream latency 84 (-8.7%) 150 🔴 (-13%) 178 🔴 (-15%) 💚 2734 🔴 (+1007%) 🔻 30

f3bc16c

Tue, 21 Jul 2026 08:29:23 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 251 (-65%) 💚 1283 🔴 (+22%) 🔻 1370 🔴 (+27%) 🔻 1606 🔴 (+15%) 🔻 30
TTFS stream 214 (-77%) 💚 1278 🔴 (+25%) 🔻 1308 🔴 (+27%) 🔻 1365 🔴 (+32%) 🔻 30
TTFS hook + stream 414 (-66%) 💚 1463 🔴 (+14%) 1508 🔴 (+16%) 🔻 1623 🔴 (+22%) 🔻 30
STSO 1020 steps (1-20) 172 (+8.2%) 357 🔴 (+47%) 🔻 481 🔴 (+57%) 🔻 617 🔴 (+48%) 🔻 19
STSO 1020 steps (101-120) 190 (+3.8%) 283 🔴 (+20%) 🔻 301 🔴 (-17%) 💚 322 🔴 (-55%) 💚 19
STSO 1020 steps (1001-1020) 461 (+2.0%) 569 🔴 (-4.0%) 647 🔴 (-5.7%) 732 🔴 (-75%) 💚 19
WO 1020 steps 404672 (+8.9%) 404672 (+8.9%) 404672 (+8.9%) 404672 (+8.9%) 1
SL stream latency 87 (+4.8%) 190 🔴 (+74%) 🔻 229 🔴 (+86%) 🔻 388 🔴 (+159%) 🔻 30

b4f5040

Tue, 21 Jul 2026 07:55:20 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1225 (+72%) 🔻 1304 🔴 (+24%) 🔻 1328 🔴 (+23%) 🔻 1399 🔴 (±0%) 30
TTFS stream 1262 (+34%) 🔻 1360 🔴 (+33%) 🔻 1379 🔴 (+34%) 🔻 1512 🔴 (+46%) 🔻 30
TTFS hook + stream 1434 (+20%) 🔻 1532 🔴 (+20%) 🔻 1562 🔴 (+20%) 🔻 1687 🔴 (+27%) 🔻 30
STSO 1020 steps (1-20) 166 (+4.4%) 263 🔴 (+8.2%) 326 🔴 (+6.5%) 371 🔴 (-11%) 19
STSO 1020 steps (101-120) 167 (-8.7%) 231 🔴 (-2.1%) 286 🔴 (-21%) 💚 292 🔴 (-59%) 💚 19
STSO 1020 steps (1001-1020) 458 (+1.3%) 595 🔴 (±0%) 636 🔴 (-7.3%) 732 🔴 (-75%) 💚 19
WO 1020 steps 382646 (+3.0%) 382646 (+3.0%) 382646 (+3.0%) 382646 (+3.0%) 1
SL stream latency 86 (+3.6%) 118 🔴 (+8.3%) 133 🔴 (+8.1%) 146 🔴 (-2.7%) 30

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt)

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

On an idempotent already-applied 200 from the batch endpoint (a concurrent
or redelivered writer committed this exact step transition first), the
server returns the current entities with no `stepCreated` on the
step_started result — deliberately, so a non-committer neither double-bills
nor re-runs the step body (workflow-server events.ts:4808-4824). The client
was seeding `preStarted` from `results[last].step` unconditionally and
running step N+1's body regardless, breaking the exactly-once guarantee the
single-event lazy-start path enforces (a lost create-claim there surfaces as
EntityConflictError -> `skipped` and never runs the body).

Gate the inline continuation on `startedResult.stepCreated === true`. When
absent, abandon the deferred completion and `reinvoke(0)` exactly like the
409/410 paths: the transition is already durable, so the fresh replay
observes it and runs the body only if this invocation truly owns the claim
(existing owned-recovery / inline-ownership logic).

Adds a regression to runtime-batch-transitions.test.ts (body runs once on a
fresh commit; zero times on already-applied, which nacks instead) plus body-
execution counters. Found by the correctness review's adversarial repro.

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

Copy link
Copy Markdown
Contributor Author

Follow-up fix: gate preStarted body execution on the batch create-claim signal (b4f5040)

The correctness review (adversarial already-applied repro) surfaced a real double-execution defect in the batch success path, now fixed.

Bug: On an idempotent already-applied 200 from POST /v4/runs/:runId/events/batch — a concurrent or redelivered writer committed this exact transition first — resolveStepTransitionBatchCancellation (workflow-server#646, lib/data/events.ts:4808-4824) returns the current entities with eventWasCreated:false and no stepCreated on the step_started result, deliberately "so a retrying client neither double-bills nor re-runs the step body." A fresh born-running commit instead stamps stepCreated = true (events.ts:4708).

The client was seeding preStarted from results[last].step unconditionally and running step N+1's body regardless of that signal. executeStep's preStarted branch runs the body with no create-claim (on the premise "only the batch's committer takes this path"), so a claim-loser ran the body anyway — breaking the exactly-once guarantee the single-event lazy-start path enforces (a lost claim there → EntityConflictErrorskipped, body never runs).

Fix: gate the inline continuation on startedResult.stepCreated === true. When absent (already-applied), abandon the deferred completion and reinvoke(0) exactly like the 409/410 paths — the transition is already durable, so the fresh replay observes it and runs the body only if this invocation truly owns the claim (existing owned-recovery / inline-ownership logic).

Tests: added a regression in runtime-batch-transitions.test.ts — body runs exactly once on a fresh commit (positive control), zero times on already-applied (which nacks for redelivery instead) — plus body-execution counters. Full @workflow/core suite: 1502 passed / 3 expected-fail; world-vercel batch adapter: 9 passed; typecheck clean.

...request,
runId,
eventId: `evnt_synthetic_${request.correlationId ?? 'step'}`,
createdAt: new Date(),

@vercel vercel Bot Jul 21, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The batch-transition replay pins the workflow VM clock to a wall-clock new Date() synthetic step_completed(N), so clock-dependent inter-step code bakes a non-deterministic timestamp into the durably-committed step_created(N+1), causing ReplayDivergenceError on later replays — and since WORKFLOW_BATCH_TRANSITIONS is now default-ON, this hits all deployments with a createBatch-capable World without opt-in.

Fix on Vercel

pranaygp and others added 7 commits July 21, 2026 17:54
…eview

Point the world-vercel HTTP client at the workflow-server#646 preview
deployment (head a588ed3d) so this PR's e2e/integration lanes exercise
the real batched POST /api/v4/runs/:runId/events/batch backend before
#646 is promoted to production.

Probed pre-pin: POST /api/v4/runs/<fake>/events/batch returns 401 (auth),
not a route-404 — the batch route is served by the preview.

This is an intentional review-time pin. The CI lint rule requiring
WORKFLOW_SERVER_URL_OVERRIDE to be empty on `main` will fail while this
is present; that failure is the deliberate merge gate, not something to
work around. Removal is a single-line revert to '' once #646 deploys to
production.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ill switch

Flip the batch step-transition flag to default-ON, mirroring the
default-on + kill-switch shape of WORKFLOW_TURBO and
WORKFLOW_RETURN_VALUE_STREAM. isBatchTransitionsEnabled() now returns
true when the var is unset/empty; WORKFLOW_BATCH_TRANSITIONS=0 (or
'false', case-insensitive) is the emergency kill switch that restores
the exact single-event two-POST path (step_completed then step_started),
byte-identical to pre-feature behavior.

The kill switch is TEMPORARY: it exists for emergency rollback during
burn-in and is planned for removal once the batch path has production
mileage. It is not a user-configurable feature.

Test rework:
- runtime-batch-transitions.test.ts: beforeEach now clears the var so
  every test runs on the true default-ON path; the old "flag OFF" test
  becomes a kill-switch test (=0 -> createBatch never called, two POSTs
  per step); the old "flag ON" test drops its explicit '1' and asserts
  the default directly; redundant '1' settings removed from the
  error-path tests.
- runtime-batch-already-applied.adversarial.test.ts: beforeEach clears
  the var (default-ON) instead of setting '1'.
- runtime-batch-attr-ordering.adversarial.test.ts unchanged: its
  in-test 0-vs-1 pairs are a deliberate off/on causal-ordering contrast.

Core unit suite: 72 files, 1505 passed + 3 expected-fail under default-ON
(matches the pre-flip baseline; zero regressions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ansition

world-local now implements the createBatch contract of
workflow-server#646 so the default-ON batch path is genuinely exercised
on the filesystem World (not silently falling back to single POSTs).

The transition is "complete step N, then born-run step N+1", and the
single-create path already has a battle-tested born-running fold (the
lazy step_started that carries the step-creation input: it writes the
step entity, a synthetic step_created event, transitions to running, and
stamps stepCreated on the create-claim winner). createBatch composes
that fold: it drives create() twice — step_completed(N) then a lazy
born-running step_started(N+1) — so the durable state is structurally
identical to the kill-switch (two-POST) path. A real fs-backed test
asserts that equality directly.

Atomicity is honest for a filesystem World: there is no cross-entity
transaction, and the causal invariant completed(N) < created(N+1)
requires completing N before claiming N+1, so strict all-or-nothing and
causal ordering can't both hold on a plain FS. An already-applied peek
makes re-delivery a no-op (writes nothing, omits stepCreated so the
client re-derives from a fresh replay rather than double-running the
body); the only residual non-atomic window cannot occur in the
single-process dev model and would otherwise surface as the same
EntityConflictError the single-event lost-claim path already handles.

Tests (real fs-backed storage, no mocks): happy-path born-run +
stepCreated; structural equality vs the two-POST kill-switch path;
idempotent re-application (no new events, no stepCreated); malformed-
batch rejection. Full world-local suite: 469 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ction

world-postgres now implements the createBatch contract of
workflow-server#646, so the default-ON batch path is genuinely exercised
on the Postgres World. The whole transition
[step_completed(N), step_created(N+1), step_started(N+1)] is applied in
ONE drizzle.transaction — the same all-or-nothing guarantee the server
provides over HTTP: any thrown error rolls the entire batch back.

Implementation mirrors the single-create per-type logic but threads every
read and write through the transaction handle `tx` (the module-level
prepared statements run on a separate pooled connection and would not see
the tx's uncommitted rows). A bornRunning set tracks which step_created
frame won its create-claim; the matching step_started stamps
stepCreated:true only for a fresh commit, and omits it on an
already-applied retry / lost race so the non-committer re-derives from a
fresh replay. Idempotent re-application takes the guarded-UPDATE-0-rows /
onConflictDoNothing paths and writes nothing new. world-postgres does not
enforce the optimistic stateUpdatedAt guard (neither does its
single-create path), so it never raises 412; the client tolerates that.

Tests (testcontainers Postgres, real transactions): happy-path born-run +
stepCreated; structural equality vs the two-POST kill-switch path (step
IDs normalized to roles since stepId is a global PK); idempotent
re-application (no new events, no stepCreated); ALL-OR-NOTHING rollback
(a failure mid-batch leaves N running, M absent, zero new events); empty/
missing-runId rejection. Full world-postgres suite: 153 passed.

Also updates the Storage.events.createBatch interface doc: both
world-local and world-postgres now implement it, so the batch path runs
on every World rather than falling back.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add the WORKFLOW_BATCH_TRANSITIONS runtime variable to the Inline
execution section of runtime-tuning.mdx, next to WORKFLOW_TURBO (its
closest analog — a default-on optimization with a kill switch).

Describes what the batch transition collapses, when it applies
(non-turbo inline path, lone next step, no open hook/wait), and that
setting 0/false restores the exact prior two-write path byte-for-byte. A
warning callout states plainly that the flag is temporary — a burn-in
rollback switch planned for removal, not a long-term user-configurable
option.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…override

Three getEventV4/createWorkflowRunEventV4 tests hardcoded the mock origin
'https://vercel-workflow.com' while their siblings in the same file
already derive it from WORKFLOW_SERVER_URL_OVERRIDE || 'https://vercel-workflow.com'.
With the review-time server override pinned, requests go to the preview
origin but the mock was registered for the old one, so the interceptor
missed and fetch failed.

Align them with the sibling pattern so they pass with the override
pinned AND after it is reverted to '' before merge. This is not a
workaround for the empty-on-main lint gate (that gate stays failing by
design); it just keeps the unit suite honest under the pin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…orlds)

Bump the batch-step-transitions changeset from patch to minor across
core, world, world-vercel, world-local, and world-postgres, and rewrite
the summary: default-ON with a temporary WORKFLOW_TURBO-style kill
switch, and events.createBatch now implemented on every World (Vercel
endpoint, world-postgres single transaction, world-local guarded write
sequence) rather than being an opt-in flag with single-write fallback.

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

Copy link
Copy Markdown
Contributor Author

Converting to draft — upgrading in place to "suspension batch v2" (see the matching note on vercel/workflow-server#646). The client will collect the entire suspension output into one atomic batch (deferred completion + attrs/hooks/waits + parallel step claims + terminals) instead of only sequential step transitions — eliminating the attr-ordering flush class structurally and cutting parallel fan-outs to one round trip. Default-on + kill switch, per-World implementations, and the preview-pin CI setup all carry forward. Will re-mark ready when v2 lands against the updated server preview.

🤖 Generated with Claude Code

pranaygp and others added 4 commits July 21, 2026 18:50
Additive fields on the createBatch World interface for suspension-batch v2
(a whole suspension's event set committed in one conditional transaction):

- CreateBatchParams.expectedRunVersion — per-run monotonic fence the runtime
  believes is current; a v2-capable World applies the whole batch conditionally
  on it (mismatch -> 412/409, writes nothing) and rejects pre-v2 runs that have
  no runVersion so the runtime falls back to the single-write path for them.
- CreateBatchParams.batchId — idempotency key stable per suspension attempt; a
  batch whose id matches the run's lastBatchId resolves without re-writing.
- BatchEventResult.runVersion — the run's version after the batch applied; the
  runtime stores it as the next expectedRunVersion.
- BatchEventResult.lastBatchId — the batch id the World last applied; equals the
  request batchId on both a fresh apply and an idempotent already-applied
  resolution.

All optional and absent from v1 responses, so v1 callers and Worlds that don't
implement the fence are unaffected. This is the World-interface (logical) layer;
the Vercel World's HTTP transport for the fence is finalized in the wire-encoding
step against the v2 server contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…alse' kill switch

Round-3 review low nits:
- Track the two concurrent lost-race adversarial suites (world-local +
  world-postgres) that prove exactly-one `stepCreated` under genuine
  concurrency, not merely sequential redelivery — the B1 invariant the
  tracked storage-batch.test.ts does not exercise (its postgres pool is
  max:1, so its transactions never overlap).
- Add an explicit WORKFLOW_BATCH_TRANSITIONS='false' kill-switch test and
  correct the comment that wrongly claimed the attr-ordering suite covered
  the 'false' word form (it only ever sets '0'). constants.ts disables on
  '0' || 'false'; both are now proven.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pure additive module: assembleSuspensionBatch orders one suspension's
batchable components into the server's published v2 grammar (leading
outcome first, terminal last, created+started adjacent) and budgets
against the 100-item TransactWriteItems cap using the server's item
arithmetic. attr_set and hook_created/hook_disposed are excluded by
construction (physics: per-key attr updates and iad1-pinned hook
entities cannot join a regional transaction). No live path calls this
yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wire the client's existing sequential batch transition to the committed v2
server contract (workflow-server 0d1b9848), the linchpin the worlds and
collect-mode build on:

- world: additive optional WorkflowRun.runVersion (the per-run monotonic
  fence; run_created seeds 0, absent on pre-v2 runs).
- runtime: seed expectedRunVersion once per invocation from the loaded run;
  a run with no runVersion latches batchDisabledForRun (permanent by
  immutability — every invocation re-derives it). Gate batchTransitionCandidate
  and eligibleToDefer on it.
- runtime: assemble the batch frames through assembleSuspensionBatch (identical
  [completed, created, started] for the sequential case) and carry the fence
  (expectedRunVersion + a fresh bat_<ulid> batchId) on createBatch; advance the
  local fence from result.runVersion after a successful batch (single-event
  writes never touch it).
- runtime: apply-half maps 409 run-not-versioned to the PERMANENT per-run latch
  (flush the deferred completion on the single path, then reinvoke) — distinct
  from the transient 409 (suspension-batch-conflict), which the World surfaces
  as EntityConflictError/RunExpiredError and which keeps its abandon+reinvoke.

B2 stays load-bearing: attr_set is still excluded from the batch (per the final
contract) and attributeCount===0 remains in the candidate gate, so the
flush-before-attr ordering is unchanged. Immediate (flag-off / pre-v2 / world
without createBatch) paths are byte-identical.

Tests: +3 v2 cases (fence carried & seeded 0; pre-v2 run latches batching off;
run-not-versioned backstop flushes not abandons). runVersion:0 added to the two
adversarial run mocks so they stay v2-eligible. Core unit suite 1516 pass + 3
expected-fail; typecheck + biome clean.

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

@vercel vercel Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Additional Suggestion:

Exhaustive Record<AttributeKey, ...> map attributeToDisplayFn is missing the newly-added runVersion key, causing TS2741 build failure across web-shared and its dependents.

Fix on Vercel

pranaygp and others added 2 commits July 21, 2026 21:01
Generalize world-local createBatch to the committed v2 contract:

- run_created initializes runVersion:0 (both the direct and resilient-start
  paths) so a world-local run is v2-eligible; its absence marks a pre-v2 run.
- world (schema): additive optional WorkflowRun.lastBatchId (a World's
  already-applied bookkeeping; the runtime never reads it).
- createBatch(runId, events, params): when the caller supplies a batchId (the
  runtime always does for a v2 run) enforce the fence — reject a run with no
  runVersion as 409 run-not-versioned; short-circuit an already-applied batchId
  (lastBatchId match) to an idempotent no-write result with no stepCreated;
  reject a runVersion mismatch as EntityConflictError (the client abandons +
  reinvokes); otherwise apply the transition, then advance runVersion to
  expectedRunVersion+1 and record lastBatchId under the run lock. Return
  runVersion + lastBatchId. A fence-less caller (direct tests, legacy) keeps the
  exact v1 behavior. The durable step create-claim remains the hard exactly-once
  gate (honest FS-atomicity note preserved).

Tests: +4 v2 fence cases (apply+advance+stamp; idempotent already-applied;
version-mismatch aborts writing nothing; pre-v2 rejected). Full world-local
suite 475 pass; typecheck + biome clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Generalize world-postgres createBatch to the committed v2 contract, threading
the fence through the existing single Postgres transaction:

- schema + migration 0016: additive nullable run_version + last_batch_id columns
  on workflow_runs (NULL marks a pre-v2 run). run_created (direct + resilient
  start) seeds runVersion:0.
- createBatch: when the caller supplies a batchId, SELECT ... FOR UPDATE the run
  row so the version check and advance are a genuine CAS inside the tx — reject
  a NULL run_version as 409 run-not-versioned; short-circuit an already-applied
  batchId (the per-frame loop already runs read-only, and the advance is
  skipped); reject a runVersion mismatch as EntityConflictError (rolls back the
  whole tx). On the fresh path advance run_version to expectedRunVersion+1 and
  record last_batch_id via a conditional UPDATE (0 rows -> conflict). Return
  runVersion + lastBatchId. Fence-less callers keep exact v1 behavior.
- web-shared: add runVersion/lastBatchId to the attribute-panel display map
  (internal fence bookkeeping, rendered as null) since AttributeKey = keyof
  WorkflowRun now includes them.

Tests: +4 v2 fence cases against testcontainers Postgres (apply+advance in-tx;
idempotent already-applied echoes the version without re-advancing;
version-mismatch rolls back writing nothing; pre-v2 rejected). Batch suites 11
pass; full-repo typecheck (41 tasks) + biome clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
// server reads them off the batch's "primary" (step_completed) frame, and a
// create+start-only batch has no completion to guard or diff against.
const isCompletion = data.eventType === 'step_completed';
return {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P0] The v2 runtime passes expectedRunVersion and batchId, but this adapter never copies either value from batchParams onto the primary frame. workflow-server#646 at 0d1b9848 requires both on frame 0, so every Vercel-world batch currently fails with 400 v4-missing-batch-fence. Please serialize both fields on the first event (not specifically step_completed, since valid v2 batches need not have a leading outcome) and add a wire test asserting their placement.

const bodyBytes = new Uint8Array(await response.arrayBuffer());
const decoded =
bodyBytes.byteLength > 0
? (decode(bodyBytes) as {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P0] The current server response includes runVersion, but the decoder type and returned object drop it. Even after the request fence is serialized, the runtime will retain the old expectedRunVersion, so the next batch in the same invocation conflicts. Please decode/return runVersion through CreateEventBatchV4Result and BatchEventResult, with a regression covering two consecutive batches advancing 0 → 1 → 2.

*/
export const WORKFLOW_SERVER_URL_OVERRIDE = '';
export const WORKFLOW_SERVER_URL_OVERRIDE =
'https://workflow-server-8sp9l2esp.vercel.sh';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P0] This preview is pinned to server commit a588ed3d, but workflow-server#646 is now at 0d1b9848 with the required v2 fence contract. Testing against the old v1 endpoint masks the missing expectedRunVersion/batchId request fields and dropped response runVersion. Please repin to the current server preview before treating cross-repo integration as green.

err.waitCount === 0 &&
err.attributeCount === 0 &&
!batchOpenState.openHook &&
!batchOpenState.openWait;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] This gate means the live client still uses only the original single-inline-step triple, despite the server and assembleSuspensionBatch supporting fan-out, waits, hook_received, and terminal events. Under the existing counting convention this yields about N+6 requests, not N+1, because the first transition and final completion remain unbatched. A follow-up should collect the complete batchable suspension and use the existing budget/splitting logic; attr_set and hook_created can continue to force a pre-flush.

err.waitCount === 0 &&
!openHookWaitState.openHook &&
!openHookWaitState.openWait &&
(batchTransitionActive || inlineStepsExecuted >= 1);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Requiring inlineStepsExecuted >= 1 leaves step 1's completion and step 2's start as separate writes, losing one transition-level RTT on every multi-step workflow. The stated reason is preserving first-step telemetry, but the completion telemetry can ride on the leading frame of the same batch. Please either batch the first transition or document/measure why telemetry cannot be preserved while doing so.

// `run_completed` stay on the existing path — flush the
// completion now, then re-replay so `run_completed` is
// written from the authoritative log exactly as today.
if (pendingBatchTransition) {

@karthikscale3 karthikscale3 Jul 21, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] The v2 server already accepts [step_completed, run_completed], but this path flushes the final completion, replays again, and writes run_completed separately. Wiring the terminal event through assembleSuspensionBatch removes one server round trip plus this extra replay and is the clearest next optimization toward a N+1 transition model.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants