Pranay Prakash
90d · built 2026-09-30
Performance
What Pranay Prakash shipped in the selected window, measured in ETV, and how it compares with the 90 days before it.
Effective capacity
+2.4engineers
delivers like 3.4 (3.4x pre-AI)
Output (ETV)
31.2ETV
−3.4% vs 32.3 prior
Features share
19.4%
−11.3 pp vs prior window
Fixes share
18.0%
−0.9 pp vs prior window
Work mix
19.4% Features7.7% Maintenance40.7% Tests14.2% Docs18% Fixes
58 commits over 90 days, ending 2026-09-30.
Where this dev ranks
Percentile against the global top-100 leaderboard (all-time totals).
- By commits
- Top 85 %
- By Features share
- Top 64 %
Daily performance
Daily ETV, stacked by Features, Maintenance, Tests, Docs and Fixes.
Repository spread
Where this developer's commits land. Concentrated work (top1 > 80%) vs polymath spread (top1 < 30%).
Most impactful commits
Top 10 by ETV in the last 90 days.
- 5.1ETVAdd dynamic workflows, backed by encrypted ref storage (#2062) * feat: dynamic workflows, backed by encrypted ref storage Rebase of the dynamic-workflow-source prototype onto main, reworked to use the real server-side storage path instead of stashing generated code in plaintext run metadata. `start()` now accepts workflow source as a string, for orchestration whose shape is only known after you deploy — a workflow-builder UI, a customer-defined automation, an AI-generated plan over a fixed catalog of registered steps: await start(source, [{ userId }], { dynamic: { steps: { fetchUser } } }); The source is validated, compiled to workflow VM code, and stored with the run through the same pipeline as the run's input: compressed, then encrypted with the run's key. On worlds with a blob-ref layer it lands behind a ref — inline for a small definition, object storage for a large one (see the matching workflow-server PR). Replay hydrates it and evaluates *it* instead of the deployment's bundle, so a run always executes the exact code it started on. What changed from the prototype: - Generated code no longer rides `executionContext.dynamicWorkflow`. Only small plaintext metadata does — version, source hash, export name, and the alias-to-step-id map — which is what lets a run be identified as dynamic, and its step allowlist audited, without decrypting anything. It also has to fit the 2 KB execution-context budget, which real generated code does not. - The code rides `run_created` (and the queue message, for resilient start and the turbo path, which synthesizes the run snapshot rather than reading it) as a serialized payload, or as a ref for definitions too large to send inline. - `start()` verifies the created run came back carrying its code. A backend without dynamic-source storage drops the field rather than rejecting it, so without this the run would look fine and fail much later as an unregistered-workflow error on a queue delivery. - Compilation moved to its own module. The workflow id is derived from the source *and* its step bindings, so the same definition always gets the same durable id, and arbitrary source cannot claim a static workflow's. Docs: new "Dynamic Workflows" page. The v5 "How it works" section is renamed "Advanced" to fit it — the section explains the build-time execution model, and dynamic source is the escape hatch from it. Permanent redirects cover every `/v5/docs/how-it-works/*` slug; v4 keeps its own section at the unversioned path, so those are deliberately untouched until v5 becomes the default. Tests: 35 unit tests over compilation, id derivation and validation; 4 type-level tests pinning the overload resolution; 7 e2e tests over the real round trip (generated code executing against registered steps, stable ids, encrypted-at-rest storage, replay across a suspension, the deferred ref path, the step allowlist failing a run, and validation refusing to create one). The e2e file is added to `test:e2e`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com> * fix(web-shared): render a dynamic run's workflow code in the run detail view The run detail sidebar keys its display map off `WorkflowRun`'s fields via an exhaustive `Record<AttributeKey, ...>`, so adding `dynamicWorkflowCode` to the run schema broke the build until it had a renderer — which is the right outcome: a payload field with no renderer would otherwise be silently dropped from the one view that exists to show it. It gets the same locked-by-default treatment as input and output, which is what the RFC asks for: generated orchestration names internal step ids, prompts and business rules, so reading it goes through the existing decrypt flow rather than being displayed by default. Ordered after input/output — the code a dynamic run actually executed is the most useful thing on its detail view, and absent on every other run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com> * fix: point @workflow/core's packed docs at the renamed Advanced section `prepack` copies the v5 docs tree into the published package, and it named the section by its old path, so `pnpm pack` — and with it the tarballs preview deployment — failed on a missing directory rather than on anything about the docs themselves. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com> * docs: make the dynamic-workflow approval sample self-contained The docs type-checker checks each code block on its own, so a sample that leaned on the imports of the block above it did not compile — and neither would a reader who copied it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com> * docs: use Run.status in the dynamic-workflow example There is no readStatus(); status is a promise-returning getter. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com> * test(e2e): skip dynamic-workflow tests on a backend without source storage Two fixes the first Vercel-prod run surfaced. The unsupported-backend path threw instead of skipping, so the whole dynamic suite came back red against production — which does not have the server-side storage deployed yet. It now issues a real vitest skip, the same mechanism the conformance gate uses: the gap is the backend's and the suite cannot close it, and these tests go live unchanged once the server ships. The validation test also called `getWorld()` without awaiting it, then read `.runs.list` off a promise. That assertion is gone rather than fixed: the run listing is served from an eventually-consistent store in e2e, so comparing it before and after would be a flake, and the ordering it was checking — that validation happens before any write — is pinned deterministically by the unit tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com> * test(e2e): read run records through the World, and skip the suspension case Three problems the first full e2e matrix surfaced, all in the test file. The assertions on stored state went through `getRun()`, which returns a Run handle whose fields are promise-returning getters over a small public surface — so `workflowName` compared two promises, and `executionContext` / `dynamicWorkflowCode` read as undefined. They now read the persisted record through `world.runs.get(runId, { resolveData: 'all' })`, which is what the rest of the suite does for storage assertions. The suspension test is skipped and left in place with the observation written down: it timed out waiting on `returnValue` on `world-local` while every non-suspending dynamic test passed on the same world, so dynamic execution works and something about the resume delivery does not. That delivery takes the read-back path in `resolveWorkflowCodeForRun` that no passing test covers. Not root-caused, and it should be before this ships beyond experiment — deleting the test would hide the finding. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com> * fix(world-local): keep a dynamic run's code across status transitions world-local rebuilds the run record field by field on every status change, so anything not named in those four literals is dropped the first time the run moves — which for a dynamic run meant losing the workflow code the moment it went `running`, leaving a run nothing could replay. Same failure mode the neighbouring `encryptionPublicKey` lines were added to avoid, and the reason the suspension e2e test hung rather than failed: the resume delivery is the only path that reads the code back off the record, and it found nothing, so the delivery threw and was redelivered until the test timed out. That test is re-enabled here — it is what caught this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com> * test(e2e): skip the stored-code assertion when the backend persisted nothing Two of the twenty-seven Vercel apps got past `start()`'s fail-fast check and then failed on the stored bytes instead of skipping. That is accurate, not noise: the check reads the created run off `run_created`'s own response, and a resilient start has no response to read — so a short dynamic run can still finish, because turbo executes it from the queue message inside one invocation, while the backend stored nothing. This assertion is the thing that notices. It is the same "no dynamic-source storage" fact the other tests skip on, reached one step later, so it skips with that stated instead of failing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com> * test(e2e): apply the stored-code skip to both assertion sites The deferred-ref test asserts stored bytes too, and only the first site got the skip — so it failed on the two apps whose runs happened to take the resilient-start path. Both now go through one helper, so a third site cannot drift from it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com> * chore: re-run CI The previous Vercel e2e matrix hit a backend-wide slowdown — every Vercel-facing job failed on 60s timeouts in pre-existing tests (promiseRaceStressTestWorkflow, closureVariableWorkflow, the abort suite) while all 84 local-world jobs passed. Empty commit to get a clean read, since re-running the failed jobs directly is not permitted for this token. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Pranay Prakash <1797812+pranaygp@users.noreply.github.com> * test(e2e): emit the dynamic runs' IDs as a CI artifact The dynamic tests track every run they start, but never wrote the sidecar that carries the IDs out — each e2e file runs in its own vitest worker with its own copy of the collector, and only `e2e.test.ts` was writing one. So the run IDs were collected in memory and thrown away, which is precisely what you want when you ask what a dynamic run looks like in production. Written under `e2e-dynamic-runs-<app>-vercel.json` rather than the shared per-app metadata name, which the last writer would otherwise clobber, and added to the four artifact upload lists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com> * test(e2e): generate the dynamic workflows inside the deployment The dynamic e2e tests called `start(source, …)` from the vitest runner. That runs, but it is not the shape an app uses, and it skipped the most important part of the feature: `dynamic.steps` has to be given *imported* step functions, so the `.stepId` the build-time transform stamped on them is what binds generated source to a registered step. The runner holds no handle on those functions, so it had to look step ids up in the deployed manifest and pass `{ stepId }` — the escape hatch, not the documented path. The workflows are now generated inside the deployment, by fixtures in `workflows/99_e2e.ts` (one file, symlinked into every workbench app). Each fixture assembles source at runtime and starts it from a step with `{ steps: { add } }`, which is how a builder UI or an LLM-generated plan actually reaches `start()`. The tests start the static fixture, take the dynamic child's run id back, and assert on the child — so the same tests exercise Vercel, local dev/prod and Postgres with no per-world branching. Fixtures added: a plain generated workflow, one whose source suspends (the resume delivery is the only path that reads the stored code back), and one whose source calls a step it was not given. `getStepMetadata` goes with them — it existed only for the manifest lookup this replaces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com> * fix: accept imported step functions in dynamic.steps `DynamicWorkflowStepReference` was `{ readonly stepId: string }`, so the call the docs advertise — `steps: { fetchUser, sendEmail }` with real imports — did not typecheck. The build-time transform stamps `.stepId` on step functions at runtime and nothing adds it to their declared type, so the only thing the type actually accepted was the `{ stepId }` escape hatch. Moving the e2e fixtures into the deployment is what surfaced this: app code is typechecked by the app's build, and the runner had only ever passed the escape hatch. The function arm is typed as any function, with the existing runtime check in `resolveStepId` as the real gate — it already reads `.stepId` off functions and fails with a message naming the alias. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com> * fix: name the created run when its dynamic code was not stored The error said the code was not stored but not which run it was talking about — and `start()` writes `run_created` *before* reading back what the backend kept, so on this path a real dynamic run does exist and only its replayability is missing. Naming it is what makes it inspectable. The e2e skip path now pulls that id out and tracks it, so the run-ID sidecar reports the run even when the test skips. Without it the ids of dynamic runs created against a backend that cannot store them were simply lost. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com> * test(e2e): capture the child's run id, not the wrapping parent's The error reaches the runner wrapped in the parent fixture's failure, which names the parent, so a loose `wrun_` match took that instead — every run id in the sidecar was the parent's, recorded twice. Anchored to the exact wording of the message `start()` raises about the run it created. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com> * test(e2e): label the dynamic run in the run-id sidecar The sidecar lists two bare ids per test — the parent fixture's and the dynamic child's — with nothing saying which is which. The dynamic one is the whole reason the artifact exists, so it is now tagged `[dynamic run]`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com> * fix: stop the module-syntax check rejecting identifiers that start with import/export `import` was allowed to be followed directly by an identifier character, so a line starting with `importData = …` was rejected as module syntax. The keyword now has to be followed by whitespace or a module-syntax token. `export{` and `export *` without a space are caught as well. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(dynamic-workflows): purge stored code, refuse Replay Run, parse source up front, hydrate code for o11y - $retention: 0 purges clear dynamicWorkflowCode on world-local and world-postgres alongside the other run payloads. - Replay Run refuses a dynamic run instead of creating one with no code behind it. - compileDynamicWorkflow parses the generated code, so TypeScript syntax, a reserved-word function name, or an unbalanced brace fail at start() rather than on every delivery; inline "use step" is rejected as the docs already state. - The stored code payload is real devalue under its devl prefix, so the generic hydrators (CLI inspect, web UI) can read it; hydration field lists in core, web-shared, and the CLI include dynamicWorkflowCode. - hydrateDynamicWorkflowCode normalizes a corrupt payload to SerializationError. - Changeset covers @workflow/cli and @workflow/web-shared; bump is minor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(dynamic-workflows): preflight runtime and backend support Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * fix(dynamic-workflows): keep source out of local events Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * fix(web): disable replay for dynamic runs Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * docs(dynamic-workflows): qualify source storage guarantees Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * test(core): exercise dynamic source compression Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * docs(dynamic-workflows): qualify changeset storage wording Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * fix(web): guard Replay while run identity loads Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * docs(dynamic-workflows): clarify cross-deployment key preflight Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * feat(core): mark dynamic start options experimental Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * fix(world): reject ambiguous dynamic code storage Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * fix(core): isolate dynamic workflow compilation Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * docs(dynamic-workflows): explain runtime surface Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * fix(core): validate top-level dynamic workflows Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * fix(core): validate generated dynamic wrapper Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * fix(core): isolate dynamic source bindings Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * fix(core): pin dynamic syntax and preserve aliases Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * fix(dynamic-workflows): validate queue names before start writes Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * fix(core): gate dynamic starts on deployment opt-in and same deployment Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * fix(core): fail dynamic deliveries that cannot safely execute stored code Require the deployment opt-in, a marker that derives the run's workflow name, and encr-only stored code when the run has key material. Each refusal records run_failed instead of redelivering, and executing stored code is recorded on the delivery span and in one log line. Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * fix(core): validate execution context size for dynamic starts only Static starts no longer serialize or reject their execution context before creation. world-vercel throws a WorkflowRuntimeError that names the dynamic step bindings as the cause. Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * fix(world-vercel): read a missing capabilities route as no capabilities A 404 from /v2/capabilities maps to an empty set so dynamic starts fail closed with the backend-support error; other failures still propagate. The dynamic E2E suite skips when the deployment has not opted in or its backend does not advertise dynamic-source storage. Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * fix(world): omit dynamic workflow code for resolveData none WorkflowRunWithoutData excludes dynamicWorkflowCode, and the local, Postgres, and Vercel Worlds strip it from get, getMany, and list. world-local list now filters through filterRunData. Replay reads the code back with resolveData all when a snapshot lacks it. Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * docs(dynamic-workflows): document opt-in, same-deployment scope, and security model State that dynamic source runs with the deployment's full privileges and that steps is not a boundary, drop the customer-authored and AI-generated framing, and document the WORKFLOW_EXPERIMENTAL_DYNAMIC_WORKFLOWS opt-in, same-deployment-only starts, encr-only stored code, plaintext storage in Local and Postgres, the plaintext step map, and the 2,048-byte marker limit. Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * refactor(core): drop the redundant dynamic runtime-version check from start Dynamic starts are same-deployment only and require the opt-in up front, so the start-local target dynamic version could only echo this process's own constant. Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * ci: run dynamic workflow E2E on local and Postgres lanes The local dev, local prod, and Postgres servers opt in with WORKFLOW_EXPERIMENTAL_DYNAMIC_WORKFLOWS=1, and WORKFLOW_E2E_EXPECT_DYNAMIC_WORKFLOWS=1 makes the suite fail rather than skip on an opt-in refusal there. Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * fix(world-vercel): skip decompressing dynamic workflow code on resolveData none reads Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * fix(world-vercel): classify corrupt dynamic workflow code as a contract error Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> --------- Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Pranay Prakash <1797812+pranaygp@users.noreply.github.com> Co-authored-by: Peter Wielander <peter.wielander@vercel.com> Co-authored-by: Alex Langenfeld <alex.langenfeld@vercel.com>github.com-vercel-workflow · 20e8440f · 2026-09-29
- 3.8ETVfeat(hooks): `createHook({ experimental_force: true })` takes a held token over (#4193) * feat(hooks): createHook({ experimental_force: true }) takes a held token over A run can now register a hook token another active run holds. The World disposes the previous owner's hook in that run's own event log (`hook_disposed{forceClaimedBy}`), re-points the token, and returns the new hook with `claimedFrom`; the claimer's runtime then wakes the previous owner from that record. A run awaiting a hook whose token was taken over gets the new `HookForceClaimedError` naming the claimer, after draining the payloads that landed before the takeover. Co-authored-by: Peter Wielander <mittgfu@gmail.com>github.com-vercel-workflow · 4f524386 · 2026-09-23
- 2.6ETVfix(world-vercel,world-local): hold process-wide state on globalThis (#3728) * fix(world-vercel,world-local): hold process-wide state on globalThis Both packages are bundled into the host application's server build, and a bundler keys module identity on (resource, layer) — Next.js alone builds `instrument`, app-route, `ssr` and `edge` layers, so one process holds one copy of each of these modules per layer. Every module-scope `const`/`let` in them was therefore per-copy state wearing the costume of a process singleton. vercel/workflow#3493 made `@workflow/world-vercel` bundled rather than external and the events WebSocket transport regressed to HTTP for exactly this reason: the queue consumer registered its channel in the `instrument` copy's `Map` and the write path looked it up in the route copy's empty one. A deterministic miss, for the life of the process. `@workflow/world-local` had the same exposure all along — including `runFileLocks`, where a duplicated mutex simply stops mutually excluding. Add `globalSingleton()` to `@workflow/utils` (the primitive `@workflow/core` already hand-rolls for its World cache) and route every mutable module-scope binding in both worlds through it. Regression cover, in three layers: - `global-singleton.test.ts` pins the primitive's semantics. - `ws-transport-module-copies.test.ts` imports the module twice in one process and asserts a transport registered by one copy is found by the other — it fails on a plain module-scope `Map`, which is the shipped bug. - `scripts/lint/module-scope-state.mjs` fails the class: an AST rule banning mutable module-scope state in these packages, with `// per-copy-ok: <why>` as the deliberate escape. Wired into both packages' `vitest run src`, with fixture self-tests so it cannot rot into a no-op. * test(world-postgres): pin the module-scope-state rule for the postgres world It is deduped today only because `getRuntimeRequire()` loads it — a property of how it is loaded, not how it is written, and exactly what changed for world-vercel in #3493. The package is already clean; this keeps it that way. * docs(worlds): codify "a world must not hold mutable module state" A world package is loaded one of two ways, and only one of them gives it a single module instance: a runtime `require()` (deduped by Node) or the host's bundler (one copy per layer). Which one you get is a property of how the world is loaded, not of how it is written, and it changed under `world-vercel` in #3493 — so the rule has to be "never rely on module scope", not "rely on it until someone flips a config". Written down in the four places someone can meet it: - `docs/content/worlds/{v4,v5}/building-a-world.mdx` — a "Process-wide state" section for custom-world authors, with the loading modes spelled out and a nudge to prefer World-instance state over a global. - `packages/world/README.md` — the same constraint on the contract package. - `CLAUDE.md` — so the next contributor working in these packages sees it. - `packages/core/src/runtime/world.ts` — at the two static imports, which is where the difference between a bundled world and a required one originates. The rule's own error message now teaches it too, rather than naming a helper. Consolidates the guard while here: `@workflow/utils` owns the rule and its fixture self-tests, and sweeps every *published* `packages/world-*` discovered at runtime, so a world package added later is covered without anyone remembering. Each world keeps a one-assertion mirror for locality. * style: drop prose em dashes from this branch's new text #3704 landed a repo-wide writing pass hours after this branch was written and took `world-vercel/src` from 406 em dashes to 130 (`ws-transport.ts` alone went 35 to 1). This branch's docs section, README, comments and lint messages were written before that and would have put 36 of them straight back into the files that were just cleaned. Rewritten sentence by sentence rather than by substitution: an em dash becomes a colon, a comma, a full stop or a parenthetical depending on what it was doing. Also fixes a real defect the sweep surfaced: `world-postgres`'s guard test was generated through a shell heredoc and had literal backslash-backticks in its doc comment. * Update .changeset/world-module-scope-state.md Co-authored-by: Peter Wielander <mittgfu@gmail.com> Signed-off-by: Pranay Prakash <pranay.gp@gmail.com> * fix(core): build the entrypoint's queue handler from getWorld() Adopted from #3666 by @MintedKenny, which implements #3665 and could not run CI as a fork PR. One line of behavior: `workflowEntrypoint`'s lazy handler init calls `getWorld()` rather than `getWorldHandlers()`. `getWorldHandlers()` owns a second, build-time-safe cache, so calling it from the runtime route built a *second* World in the same process. That costs a stateful World duplicate resources on every instance — world-postgres eagerly constructs a `pg.Pool` (default `max: 10`) and a nested world-local World in `createWorld()`, so self-hosted users have been paying for two of each — and, for a bundled world package, the two Worlds are built by two different module copies, which is the mechanism behind the WS transport regression the rest of this branch contains. The public `getWorldHandlers()` and its separate build-time cache are unchanged; only the runtime route stops using it. Kept from the original: the regression test asserting the factory runs exactly once, and the api-reference wording (re-applied over #3704's list punctuation). Not taken: renaming the `workflow.route.get_world_handlers` span. It is a distinct span from the per-request `workflow.route.get_world` at the top of the flow route, and reusing that name would collide with it in traces and in `runtime-trace-mode.test.ts`; a comment records why the name outlived the call. Co-authored-by: Kenneth <kenneth@standardforensics.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: address AI review on the module-scope work Two blocking findings, both real: - **Cross-version state sharing** (`ws-transport.ts`). A process can hold two *published versions* of `@workflow/world-vercel` (a transitive dependency pinning an older `@workflow/core`, which depends on this package by exact version). Both wrote to the same unversioned `Symbol.for` key, so one version's write path could be handed a `WsEventsTransport` built by the other's class and frame against a protocol it may not share — with no version negotiation on the socket to catch it. `shapeVersion` cannot express this: the container is stable, the hazard is its contents. The registry and the events dispatcher recycler are now keyed by package version. The plain connection pools stay unversioned; sharing those across copies is the point. - **The documented pattern failed the rule this PR adds.** The custom-world docs teach `store[StateKey] ??= …`, which the rule flagged as a field write. It now recognizes state rooted at `globalThis`, following one alias hop, which is also what `core/private.ts:23` and `next/src/index.ts:58` are already doing correctly (core drops 26 findings to 22, next 7 to 6). The docs also now say outright that `globalSingleton()` is the same thing, since AGENTS.md prescribes it and the page did not mention it. Rule precision, from the review's probes: - `.mts`/`.cts` are scanned. `@workflow/world-testing` is authored in `.mts`, so its entry in the sweep was passing vacuously — with the walk fixed it reports a real finding, now annotated (it is a standalone `serve()` entry). - Mutations in top-level statements no longer count. A table filled at module evaluation is identical in every copy; divergence needs a later write. - `static` class fields are collected, attributed to the class name. - An *exported* binding initialized to an empty collection is a finding on its own, which approximates the cross-file case the walk cannot resolve. Six fixtures pin the new behavior. The rule's header now states what it does not see, and AGENTS.md states where the sweep stops and why core is not gated yet. Also tags `resetGlobalSingletonForTest` `@internal`. * fix(lint): attribute a static-field write to the field, not the class The static-field support added in the previous commit keyed `declared` on the class name, so a class carrying more than one mutable static reported one finding instead of one per field, and labelled the survivor with whichever mutation was seen first. On a two-static fixture it reported `static Registry.latch (`.set()`)`: the name of one field, the reason belonging to the other, pointing the reader at the wrong line. Key static fields `Class.field` and resolve a write to the same shape, via a new `memberPath()` that takes the first two segments of a member chain and tries that key before the bare root identifier. Two follow-ons fall out of having the path: - `this.field` inside a `static` member resolves to the class, which is the ordinary way to write the mutation. `staticClassOf()` returns nothing for an instance member, where `this` is an instance and the state is per-instance rather than per-copy, and nothing inside a nested `function`, which rebinds `this`. - `state.count++` is now a finding, like the `state.count += 1` that `assignment()` already reported. Fixtures pin all four, including the instance-field case that must stay clean. The four world packages still report zero, and the extracted `recordMutation()` keeps the file at its previous two Biome complexity warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: make module duplication inert across every bundled package `@workflow/core` is bundled into the host server build the same way the worlds are, and always has been — the original repro measured three live copies in every arm, including the pre-#3493 external one. One instance is not reachable: layers cannot share a module, and core cannot be external because it *is* workflow code (`runtime/start.ts:253` and nine methods in `runtime/run.ts` are `'use step'`), so it must go through the SWC loader. The Next integration already encodes that rule by removing workflow-bearing packages from `serverExternalPackages`. So the duplication stays and the hazard is removed instead, everywhere the duplication can happen. `@workflow/core` (22 findings to 0): warn-once latches in `constants.ts`, `start.ts` and `telemetry.ts`; the source-map tracer cache; the VM script cache; the QuickJS compiled-assets and baseline caches; the dev-server port cache (its own comment already said "per process"); the text codecs; the zstd browser decoder; and the `useStep` closure brand, where a function marked by one copy was invisible to another. The one with teeth was `step-single-flight.ts`: a per-copy map is not single-flight. Two invocations reaching it through different layers would each believe they were alone in the process and both run the step body, silently degrading in-process dedup to the cross-process residual its own doc scopes out to the ownership lease. Also `@workflow/world` (a warn-once set, hand-rolled onto `globalThis` to keep that package dependency-free), `@workflow/ai` (the lazy OTel API), and `@workflow/nest` (bootstrap config in a module-level `let` and two static class fields — configure one copy, read another, and the controller is unconfigured for the life of the process). Five sites are deliberately per-copy and now say why: state keyed on objects that never cross copies (the barrier safety-net `WeakSet`, the QuickJS pending byte `WeakMap`), the synchronously-scoped guest-code sink, and the OTel diagnostic that reports what *this* copy sees. The sweep now covers all of it. Packages with a single module graph stay out (build-time code, the CLI, the o11y UI, the test runner) and AGENTS.md records which and why. Found while doing this: two static fields on one class collapsed into a single entry in the rule, so `WorkflowModule.options` was invisible behind `WorkflowModule.outDir`. Statics are now keyed `Class.field`. * fix(world): suppress noAssignInExpressions on the globalThis idiom The hand-rolled form trips Biome, as it does in `packages/core/src/private.ts`, which carries the same suppression. Restructuring it into a helper function instead would hide the state behind a call the module-scope rule cannot follow, so the binding would stop being recognized as off-module and the package would report a finding for correct code. * fix: sweep every bundled package, and mark utils side-effect free @shalabhc asked on review whether `@workflow/utils` needs this too. It does, and so do three others: `utils`, `errors`, `serde` and `workflow` all end up in the host application's server build and none were in the sweep. All four report zero today, which is exactly the state `world-testing` appeared to be in before the `.mts` walk was fixed and it turned out to have a real finding. Being clean and being *checked* are different properties, and only the second one survives the next contributor. `sideEffects: false` on `@workflow/utils`: verified that every module in the package only declares (no import-time work), so a bundler can now drop the unused parts of the barrel instead of keeping all ~64 KB of it because three packages import one 476-byte function. --------- Signed-off-by: Pranay Prakash <pranay.gp@gmail.com> Co-authored-by: Peter Wielander <mittgfu@gmail.com> Co-authored-by: Kenneth <kenneth@standardforensics.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Peter Wielander <peter.wielander@vercel.com>github.com-vercel-workflow · f7715854 · 2026-08-21
- 1.8ETVfix(core): order step-result deliveries against wait/hook deliveries by event-log position (#3139) * fix(core): order step-result deliveries against wait/hook deliveries by event-log position Two production runs on `@workflow/core@5.0.0-beta.36` burned all three divergence-recovery replays at the same event and terminated with CORRUPTED_EVENT_LOG: wrun_41KYJENABV0GSF5YTE9EETV5DD (step vs wait) wrun_41KYJEE01S0GPC9RWT5MEKVCX8 (step vs hook) Replay divergence: step event step_created for step_X belongs to "A", but the current step consumer is "B" `useStep` proxies draw deterministic ULIDs in invocation order, so the ULID -> stepName allocation is a function of the order in which promise resolutions are delivered to workflow code. The delivery-barrier registry pinned that order to event-log position for hook payloads and wait completions, but step results were delivered straight off the serial `promiseQueue` — and their latency varies between replays of the SAME invocation, because the first replay pays full hydration while later replays memo-hit primitive results in the shared `ReplayPayloadCache`. A step completion adjacent in the log to a `wait_completed` was therefore delivered wait-first on a cold replay and step-first on a warm one; whichever order the invocation that wrote the follow-up `step_created` events happened to see became law, and every replay computing the other order diverged permanently. Step results and step failures now register a 'step' delivery barrier at their event-log index and resolve from a detached continuation after every relevant earlier-in-log delivery, mirroring the hook payload path: hydration stays inside the serial queue slot (which also releases `pendingDeliveries`), while the barrier wait and the resolve run off the queue so a queue slot never blocks on a resolution the queue itself drives. Waits and hook payloads likewise defer behind earlier step results. Two details are what actually make the ordering hold, and both were found by testing rather than by reading the code: The deferral set is captured while CONSUMING the event, not at the start of the hydration slot. Captured at slot start it is not merely less deterministic, it is usually empty: an earlier delivery whose own slot runs first on the serial queue has typically already resolved and deregistered its barrier before the later slot begins, so the later delivery does not defer at all. Every event in one drain window is consumed before any slot runs, so consumption time sees all of them. A delivery that had to wait then yields a macrotask before resolving. An earlier delivery being "delivered" only means its `resolve()` ran; the branch it woke may need arbitrarily many further microtask hops before it reaches its next `useStep` call (a `for await` over a hook resumes the generator, settles the promise from `next()`, and only then runs the loop body). Ordering the `resolve()` calls alone therefore buys a fixed hop or two of margin and leaves a hop-count race that holds only for the shortest consumers; yielding a macrotask lets the earlier branch drain completely, whatever its shape. One asymmetry is load-bearing: a step result skips any earlier delivery that will not resolve on its own, i.e. one blocked directly or transitively on a buffered hook payload no consumer has claimed. Such a payload is delivered only when the workflow next reads the hook, and reaching that read commonly requires the step result itself, so gating the step on it stalls the run until the barrier's idle safety net fires — which then releases every delivery queued behind that payload at once and loses the very race the ordering exists to protect. Waits and hooks keep gating on unclaimed payloads, where waiting for the claim IS the guarantee. Tests come in two files. `step-delivery-ordering.test.ts` is byte-identical to the file in the repro-only companion PR vercel/workflow#3137 apart from two `it.fails` markers there (which let a repro-only branch have green CI); `sed 's/it\.fails(/it(/g' | cmp` verifies it. Each of its five cases replays one committed log twice through a shared `ReplayPayloadCache`, and the two warm-replay cases fail on main with the production error text. `step-delivery-hop-count.test.ts` exists because those five cases cannot tell "delivered in log order" apart from "resolves a hop or two later than before". It replays logs a live run legitimately produced — the live invocation received the two events in separate deliveries, so the first branch finished long before the second event existed — while the replay receives both in one drain window, and pads the consumer with a varying number of extra awaits so hop count is the only variable. It covers step results against both wait completions and hook payloads, plus step FAILURES against wait completions, since a rejection decides whether a `catch` continuation runs and so which ULID the `useStep` there draws. All 18 cases fail on main; of the 12 that predate the macrotask, 9 still fail with the resolve-ordering-only version of this fix; all 18 pass here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Pranay Prakash <pranay.gp@gmail.com> * fix(core): close remaining delivery-barrier ordering gaps Follow-up on the step-delivery barrier work, addressing three cases the registry did not yet cover. Each has a regression test in the new `delivery-barrier-coverage.test.ts` that reproduces the production `ReplayDivergenceError` when its fix is reverted. - Step results now defer behind earlier STEP results. The old exclusion assumed the serial `promiseQueue` fixes step-vs-step order, which stopped holding once a step began resolving from a detached continuation instead of its queue slot: two steps consumed in different drain windows can disagree on their deferral set, and the earlier one — parked on the macrotask yield — gets overtaken. - `sleep.ts` and `hook.ts` (waiting-consumer path) now capture their deferral at event-consumption time, as `step.ts` already does. Reading the registry after their queue work misses an earlier step or hook that delivered and retired its barrier in the meantime, skipping both the gate and the macrotask yield. The buffered hook payload path deliberately keeps evaluating at claim time; a consumption-time snapshot there stalls the e2e `hookWithSleepWorkflow`. - Abort deliveries participate in the registry. `_setAborted` fires the signal's listeners, which may invoke a step and draw a ULID, so an abort is as branch-deciding as any other delivery. Also memoizes `resolvesOnItsOwn`. The walk is exponential in the number of live hook/wait barriers, and the registry is not bounded — a fan-out of `Promise.race([hook, sleep])` branches accumulates one barrier per branch per kind (49 measured for 24 branches). At 40 barriers a single scan took 92s before, and is instant after. --------- Signed-off-by: Pranay Prakash <pranay.gp@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Nathan Rajlich <n@n8.io> Co-authored-by: Peter Wielander <mittgfu@gmail.com>github.com-vercel-workflow · 2941b1c3 · 2026-07-28
- 1.4ETVfeat(core): resolve run.returnValue via a World long poll instead of a 1s poll (#3570) Co-authored-by: Peter Wielander <mittgfu@gmail.com> Co-authored-by: Peter Wielander <peter.wielander@vercel.com>github.com-vercel-workflow · 9454d51d · 2026-08-20
- 1.4ETVAdd support for 'noop' event type - spec version 7 (#3634) Co-authored-by: Peter Wielander <peter.wielander@vercel.com>github.com-vercel-workflow · 7b79ba37 · 2026-08-21
- 1.3ETV[core] Don't download recorded step inputs on replay (resolveData: 'skip-step-inputs') (#4348) * [core] Don't download recorded step inputs on replay Replay recomputes every step's arguments by re-running workflow code; a step's input is only ever read by step execution, from the step entity. Yet every replay read (the event list, the run_started / hook_received preloads, and inline sinceCursor deltas) transfers each step_created / step_started input. For a workflow passing growing state into its steps, those inputs are the quadratic part of the replay log. Add an advisory `omitStepInputs` to ListEventsParams and CreateEventParams. The runtime sets it on every replay read and on every write whose returned replay events feed the log; world-vercel sends it as a list query param and POST frame meta flag. A World (or server) that doesn't know it keeps returning the inputs, so this is compatible in both directions. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-Authored-By: Pranay Prakash <1797812+pranaygp@users.noreply.github.com> * [core] Express step-input omission as resolveData: 'skip-step-inputs' Review feedback: omitting step inputs is a data-resolution choice, so make it one instead of a separate flag. - @workflow/world: `EventsResolveData` = `ResolveData | 'skip-step-inputs'` for `ListEventsParams.resolveData` and `CreateEventParams.resolveData` (on a create it applies to the returned event-log page only), plus `entityResolveData()` for code that passes create params on to entity reads. `omitStepInputs` is gone. - @workflow/world-vercel: lists send `remoteRefBehavior=skip-step-inputs`; creates send `eventsRemoteRefBehavior: 'skip-step-inputs'` in the POST meta, since a POST's `remoteRefBehavior` already selects how its created event and entities come back (lazy for most event types). A backend that predates the value rejects the list with a 400; the first rejection moves the process to `resolve` for that backend (kept on globalThis). - @workflow/core: the replay reads and delta-requesting writes ask for `resolveData: 'skip-step-inputs'`. - world-local / world-postgres / world-sim: accept the wider type; they return inputs as before. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-Authored-By: Pranay Prakash <1797812+pranaygp@users.noreply.github.com> * [core] Share the replay-read resolution between the replay loop and the step executor REPLAY_RESOLVE_DATA names how replay reads the event log, and withReplayDelta() applies it to a write's inline delta. The step executor uses the helper instead of an inline ternary, which also keeps step-executor.ts's layout unchanged: dev-mode stack function names for step frames currently depend on that file's line offsets (#4352), and the inline version tripped the local-dev stack-trace e2e tests. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-Authored-By: Pranay Prakash <1797812+pranaygp@users.noreply.github.com> * Drop the ignored @workflow/world-sim from the changeset Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-Authored-By: Pranay Prakash <1797812+pranaygp@users.noreply.github.com> * Address review on skip-step-inputs - Document that a World MUST treat any resolveData other than 'none' as 'all' (a `=== 'all'` test would strip step results on every replay), and add a world-testing conformance case: `skip-step-inputs` returns the same events as `all`, every payload except step inputs unchanged. A World with that bug fails it (the run itself fails to replay). - Note on CreateEventParams.resolveData that forwarding it to an entity read needs entityResolveData(). - Remember a backend's rejection of `skip-step-inputs` for 10 minutes rather than for the life of the process, so a process that met an old instance mid-deploy goes back to the cheaper read. - Changeset wording: steps take their input from the step_started response or from memory, never from the replay log. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-Authored-By: Pranay Prakash <1797812+pranaygp@users.noreply.github.com> --------- Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com> Co-authored-by: Pranay Prakash <1797812+pranaygp@users.noreply.github.com>github.com-vercel-workflow · f2daf402 · 2026-09-24
- 1.0ETVfeat(core): stamp creator environment into runInput and reject cross-environment queue deliveries client-side (#3244) * feat(core): stamp creator environment into runInput and reject cross-environment queue deliveries client-side `start()` makes two writes that have to land in the same tenant: the `run_created` event, attributed to whatever environment the caller authenticates as, and the queue message, pinned to a deployment. A misconfigured caller can split them — writing the run to one environment while addressing the message to a deployment in another. The consumer finds no run under its own tenant, the backend's resilient start (`run_started` creates the run when `run_created` was never seen) mints a second copy of the same run id in the consumer's environment, and both copies are real: the creator's sits pending forever while the other executes. The deployment id is not the discriminator — it matched end to end in the incident that motivated this. The environment is. So carry it: add an optional `World.getEnvironment()`, implement it in world-vercel from the same resolution that produces the `x-vercel-environment` header, and stamp it into the queue message's `runInput`. The consuming deployment already knows its own environment, so it can refuse the delivery itself with no server coordination — and refuse before `run_started`, the write that would create the fork. The refusal acks the message instead of throwing: the mismatch is baked into the message, so every redelivery would reach the same verdict and throwing would hot-loop until MAX_QUEUE_DELIVERIES. Both sides must be known for the check to run, so worlds with a single tenant (local, Postgres) and runs started by an older SDK behave exactly as before. A companion diagnostic logs a deployment-id mismatch without refusing, since deployment ids differ for benign reasons too. Signed-off-by: Pranay Prakash <pranay.gp@gmail.com> * fix(world-vercel): resolve the runtime environment from VERCEL_TARGET_ENV For a deployment in a Vercel custom environment, the OIDC token's environment claim is the custom environment's slug (the platform mints `customEnvironment?.slug ?? envTarget`) while VERCEL_ENV reports 'preview' — so keying the cross-environment guard on VERCEL_ENV could false-refuse a legitimate delivery, e.g. a CLI client attributed to 'staging' starting a run on the staging deployment. VERCEL_TARGET_ENV is populated from exactly the same slug-or-target pair as the claim, so prefer it, keeping VERCEL_ENV as the fallback for contexts that don't inject it. Also sorts runtime.ts imports per the Biome rule that landed on main in #3241. Signed-off-by: Pranay Prakash <pranay.gp@gmail.com> --------- Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>github.com-vercel-workflow · ee944d24 · 2026-07-31
- 1.0ETVBatch: 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>github.com-vercel-workflow · 37e1d9e5 · 2026-08-19
- 1.0ETVfix(core): write hook events alongside a suspension's steps, not ahead of them (#4392) * fix(core): write hook events alongside a suspension's steps, not ahead of them The suspension handler committed every hook_created (and disposal/abort) before it wrote any step, wait, or attribute event, and the batched fan-out refused to engage for a suspension that carried hook writes at all. A step created beside a hook, such as eve's turn step next to the system hooks its AbortControllers register, could not start until those hook writes landed. Hook writes now run as one more op beside the rest of the suspension's writes. Ordering inside the hook writes is unchanged (code order per token, creations before aborts), and forced creations keep their barrier ahead of everything, since their victim-wake debt relies on being the log's tail. The batched fan-out engages alongside hook writes, and a lone inline step created beside a hook has its claim folded into that batch so it commits concurrently with the hook create rather than after it. Supporting changes: - decide lazy-inline deferral from the queue's getConflict() awaiters, since the creates' outcomes are still in flight while steps are written - merge the hook create's inline delta in slot order, as a concurrent write may have folded a skipped-slot report in while it was in flight - count batch commits toward eventLogCarriedForward - report hookCreationMs as only the time hook writes held the suspension beyond every other write - on a hook conflict, join the deferred batch work, and replay in-process when the suspension pre-claimed inline steps this delivery now owns A workflow that needs a hook registered before a step runs awaits hook.getConflict() first; the docs now say so. Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Pranay Prakash <1797812+pranaygp@users.noreply.github.com> * fix(core): don't hold a suspension's writes for a forced hook's victim wake Forced hook creations no longer run ahead of the rest of the suspension in either engine. The barrier existed only so a crash before the victim wake would leave the forced hook_created as the claimer's last own row, which is how the replay detects an owed wake. That detection was already leaky (a step or wait terminal from another invocation can land in the same window), and a missed wake ends the same way as a failed publish: the victim reads the takeover on its next invocation. Making the recovery independent of the log's tail is tracked in #4393. Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Pranay Prakash <1797812+pranaygp@users.noreply.github.com> * Apply suggestion from @VaguelySerious Signed-off-by: Peter Wielander <mittgfu@gmail.com> * fix(core): address review on hook writes alongside steps - Docs: say that a step started beside a hook can run even when the hook conflicts, so a duplicate run that relies on HookConflictError rather than `hook.getConflict()` may already have started it (create-hook, idempotency). - TTFS: snapshot the pre-attr hook time before adding the attr suspension's own `hookCreationMs`, which now falls after the attr commit that ends the measured window. Refresh the comments that still said the hook phase runs before the attr writes. - Tests: a run that ends over the conflict leaves the claimed step unrun; a lost pair beside a conflict hands the run off only after the batch's trailing chunk and step publishes land; a hook with a getConflict() awaiter pre-claims nothing. - Re-wrap a QuickJS entrypoint comment. Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Peter Wielander <29887157+VaguelySerious@users.noreply.github.com> --------- Signed-off-by: Peter Wielander <mittgfu@gmail.com> Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com> Co-authored-by: Pranay Prakash <1797812+pranaygp@users.noreply.github.com> Co-authored-by: Peter Wielander <mittgfu@gmail.com> Co-authored-by: Peter Wielander <29887157+VaguelySerious@users.noreply.github.com>github.com-vercel-workflow · 35ebeb4c · 2026-09-25