Skip to content

[world-local] Fix CORRUPTED_EVENT_LOG from hook-resume staging slot holes - #3492

Merged
VaguelySerious merged 2 commits into
mainfrom
peter/world-local-event-race
Aug 12, 2026
Merged

[world-local] Fix CORRUPTED_EVENT_LOG from hook-resume staging slot holes#3492
VaguelySerious merged 2 commits into
mainfrom
peter/world-local-event-race

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented Aug 12, 2026

Copy link
Copy Markdown
Member

What

Three fixes in @workflow/world-local, plus the local event-log race repro script gains a --world local lane.

The corruption

hook_received is the one event that does not publish straight into events/. It stages under .locks first, so a terminal transition can reap it before it ever becomes reader-visible (#2987). That staging path was keyed by the event id alone, and a collision on it bumped the writer to the next slot.

Under slot event ids (#3389) that bump is wrong. The slot allocator probes events/ and nothing else, so a staged file is not evidence the position is taken. Bumping moves the writer past a position nothing will ever fill, notePublishedSlot advances the watermark past it, and scanRunEventIds is max-based so no later writer backfills. The runtime reads the missing position as a durable hole and fails the run with CORRUPTED_EVENT_LOG.

Two ways in:

  1. An attempt killed between staging and promoting leaves its staged file behind. Its cleanup lives in a finally the kill skips, and the only other reaper runs on a terminal transition the run has not reached. Every later writer that draws that slot bumps off it, permanently.
  2. Two live writers draw the same candidate and the stager is later rejected by the terminal marker or the reap. The loser bumped off a slot the stager never published.

Provenance: staging arrived in 850777a03b (#2987) when ids were ULIDs, where the old comment was correct that the path "can only be occupied by a previous crashed attempt of this very event". 6786db9953 (#3389) turned ids into positions and kept the id-keyed staging name.

Fix: the staging name carries a nonce, so it is private to one attempt. The slot is then arbitrated only where it is actually taken, at the promote. This restores the file's own stated invariant: a slot is claimed by the publish that occupies it, never reserved ahead of a write that might still be rejected.

The spurious conflict, and the duplicate resume

When a resume loses the publish, the event now at that position can be the same resume, written by the other taker of the claim. That is the convergence the claim exists to force, and converge earlier in the function already answers it with the committed event. It just could not see it yet, because the other taker had not published when this attempt read.

The first pass answered that only for a pinned loser, which left the symmetric case open (caught in review). Only one of the two takers of a claim is pinned: the taker that writes the claim keeps its own id, unpinned, because a slot is a position another instance also hands out for unrelated events and refusing to move would fail this resume's append outright. The taker that adopts an existing claim is pinned to the claimed position. So the loser of the promote can be the unpinned owner, and a loser that bumps publishes a second hook_received for one resumeId. Nothing looks wrong afterwards (the log stays dense, both callers report success) but the resume is delivered twice on replay.

Fix: run the occupant convergence inside the publish loop, ahead of the bump, for any hook_received carrying a resumeId, with no pinning condition. An occupant that is not this resume is the unrelated-event collision the bump is for and still bumps, or conflicts when pinned. Both takers return the one committed event, matching the dedup contract hook-resume-dedup.test.ts already asserts. Reporting EntityConflictError instead gave the caller an error it cannot act on for a resume that did land (HTTP 500, queue retry).

Separately, a failure to stage under the nonced path now raises WorkflowWorldError rather than EntityConflictError: a nonced path cannot collide, so it is a filesystem fault, and EntityConflictError is the shape the runtime absorbs as a benign duplicate publish.

Repro script

scripts/event-log-race-repro-local.sh was postgres-only. It now takes --world postgres|local (default postgres, unchanged). Under --world local it exports WORKFLOW_TARGET_WORLD=local plus an absolute WORKFLOW_LOCAL_DATA_DIR and WORKFLOW_LOCAL_QUEUE_CONCURRENCY, clears the data directory instead of the queue, and skips all container bring-up, migration, and teardown.

pnpm run test:e2e:event-log-race-repro:local --world local

Testing

New packages/world-local/src/storage/hook-staging-slots.test.ts, 4 tests. Three are regression guards verified to fail with the fix removed:

  • a crashed attempt's leftover staged file no longer holes the log. Before the fix this produced slots [1, 2, 4] instead of [1, 2, 3].
  • both writers of one raced resume get the committed event back rather than a conflict.
  • when the unpinned claim owner loses the position to an adopter, one hook_received is written and both takers return it. This one mocks promoteExclusive to park whichever caller reaches it first until the other has linked, since the outcome hinges on that interleaving. Restoring the eventIdPinned && guard makes it fail with to have a length of 1 but got 2.

The fourth, keeps the log dense under live contention on one position, is not a regression guard and its comment says so: with two live stagers and no terminal transition the pre-fix code also ended dense, because the writer it bumped off the position was the one that went on to publish it. It covers that arbitrating at the promote still resolves two instances drawing one position.

Full world-local suite: 546/546 across 16 files. tsc --noEmit clean.

On the storm harness, stated plainly: it has never produced a CORRUPTED_EVENT_LOG outcome or an on-disk hole against world-local, across one 14-run pass and two 16-run passes before the fix. The corruption is demonstrated by the unit test, not by the storm. What the storm does show is the conflict volume: ~12-27 EntityConflictError per pass before, 1 after (a hook_created benign duplicate, which is the documented path), with all 14 runs completing and every run dense on disk (count == max, 8931 event files checked).

This matches the note now in AGENTS.md: world-local's storms come out clean far more often than world-postgres's, so reach for a unit test in packages/world-local/src/storage/ when a suspected filesystem race can be staged directly.

`hook_received` is the only event that does not publish straight into
`events/`: it stages under `.locks` first so a terminal transition can
reap it before it becomes reader-visible. That staging path was keyed by
the event id alone, and a collision on it bumped the writer to the next
slot.
Under slot ids a staging collision is not evidence the position is
taken. The allocator probes `events/` only, so bumping moves the writer
past a position nothing will ever fill, and `scanRunEventIds` is
max-based so no later writer backfills it. The runtime reads the missing
position as a durable hole and fails the run with CORRUPTED_EVENT_LOG.
Two triggers: an attempt killed between staging and promoting leaves its
staged file behind (cleanup lives in a `finally` the kill skips, and the
only other reaper runs on a terminal transition), and two live writers
drawing the same candidate where the stager is later rejected.
Staging now carries a nonce, so it is private to one attempt and the
slot is arbitrated only where it is actually taken, at the promote.
Second fix: when a pinned resume loses the publish, the event at the
pinned position is that same resume written by the other taker, which is
the convergence the pin exists to force. Return that committed event
instead of an EntityConflictError the caller cannot act on.
Also re-purposes scripts/event-log-race-repro-local.sh to drive either
world with `--world postgres|local`.
@VaguelySerious
VaguelySerious requested a review from a team as a code ownerAugust 12, 2026 16:46
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

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

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewAug 12, 2026 7:53pm
example-nextjs-workflow-webpackReadyReadyPreviewAug 12, 2026 7:53pm
example-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-astro-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-express-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-fastify-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-hono-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-nestjs-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-nitro-workflowBuildingBuildingPreviewAug 12, 2026 7:53pm
workbench-nuxt-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-python-workflowErrorErrorAug 12, 2026 7:53pm
workbench-sveltekit-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-tanstack-start-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-vite-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workflow-docsReadyReadyPreview, v0Aug 12, 2026 7:53pm
workflow-swc-playgroundReadyReadyPreviewAug 12, 2026 7:53pm
workflow-tarballsReadyReadyPreviewAug 12, 2026 7:53pm
workflow-webReadyReadyPreviewAug 12, 2026 7:53pm

@changeset-bot

changeset-botBot commented Aug 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c6bf782

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

This PR includes changesets to release 18 packages
NameType
@workflow/world-localPatch
@workflow/cliPatch
@workflow/corePatch
@workflow/vitestPatch
@workflow/webPatch
@workflow/world-postgresPatch
workflowPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/web-sharedPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

@github-actions

github-actionsBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

❌ Failed E2E Tests

▲ Vercel Production (1 failed)

nextjs-turbopack-node (1 failed):

  • distributedAbortController - manual abort triggers signal | wrun_41KZVSGQDP0GJSR1W5GDTRB65H | 🔍 observability

E2E Test Summary

Summary
PassedFailedSkippedTotal
❌ ▲ Vercel Production331215873900
✅ 💻 Local Development381005584368
✅ 📦 Local Production381005584368
✅ 🐘 Local Postgres381005584368
✅ 🪟 Windows31200312
✅ vercel-multi-region270027
Total150811226117343
Details by Category

❌ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node128028
✅ astro-quickjs128028
✅ example-node128028
✅ example-quickjs128028
✅ express-node128028
✅ express-quickjs128028
✅ fastify-node128028
✅ fastify-quickjs128028
✅ hono-node128028
✅ hono-quickjs128028
✅ nest-node128028
✅ nest-quickjs128028
❌ nextjs-turbopack-node15213
✅ nextjs-webpack-node15303
✅ nextjs-webpack-quickjs15303
✅ nitro-node128028
✅ nitro-quickjs128028
✅ nuxt-node128028
✅ nuxt-quickjs128028
✅ sveltekit-node14709
✅ sveltekit-quickjs14709
✅ tanstack-start-node128028
✅ tanstack-start-quickjs128028
✅ vite-node128028
✅ vite-quickjs128028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack-node15600
✅ nextjs-turbopack-quickjs15600

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@github-actions

github-actionsBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit c6bf782 · Wed, 12 Aug 2026 20:08:57 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1361 (+513%) 🔻1553 🔴 (+39%) 🔻1591 🔴 (+36%) 🔻1914 🔴 (+9.6%)30
TTFSstream1359 (+427%) 🔻1519 🔴 (+37%) 🔻1595 🔴 (+42%) 🔻1621 🔴 (+5.7%)30
TTFShook + stream1593 (+331%) 🔻1805 🔴 (+32%) 🔻1866 🔴 (+30%) 🔻1973 🔴 (-57%) 💚30
STSO1020 steps (inline)133 (-2.9%)177 (-16%) 💚198 (-19%) 💚296 (-26%) 💚1019
WO1020 steps176010 (-14%)176010 (-14%)176010 (-14%)176010 (-14%)1
SLstream latency109 (+18%) 🔻154 🔴 (+4.8%)182 🔴 (+0.6%)3136 🔴 (+602%) 🔻30
SOstream overhead (text)128 (+4.9%)185 (-31%) 💚210 (-57%) 💚282 (-71%) 💚30
SOstream overhead (structured)121 (-2.4%)169 (-39%) 💚198 (-77%) 💚235 (-99%) 💚30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 204038ms → this run 174609ms (Δ -29429ms, -14%)

 100-150 ms █░░┃ main 11 this 137 +126
150-200 ms ████████████████████░░░┃ main 643 this 785 +142
200-250 ms █┃██████ main 277 this 69 -208
250-300 ms ┃█ main 55 this 18 -37
300-350 ms ┃ main 10 this 6 -4
350-400 ms ┃ main 12 this 3 -9
400-450 ms ┃ main 3 this 0 -3
450-500 ms ┃ main 1 this 1 +0
550-600 ms ┃ main 1 this 0 -1
600-650 ms ┃ main 1 this 0 -1
650-700 ms ┃ main 4 this 0 -4
1000-1050 ms ┃ main 1 this 0 -1
📜 Previous results (1)

0161a14

Wed, 12 Aug 2026 17:06:24 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1262 (+79%) 🔻1448 🔴 (+44%) 🔻1489 🔴 (+47%) 🔻1737 🔴 (+15%) 🔻30
TTFSstream317 (-67%) 💚1381 🔴 (+40%) 🔻1410 🔴 (+40%) 🔻1505 🔴 (+43%) 🔻30
TTFShook + stream1618 (+33%) 🔻1718 🔴 (+33%) 🔻1786 🔴 (+33%) 🔻1875 🔴 (+16%) 🔻30
STSO1020 steps (inline)1311782034061019
WO1020 steps180588 (-53%) 💚180588 (-53%) 💚180588 (-53%) 💚180588 (-53%) 💚1
SLstream latency109 (+35%) 🔻139 🔴 (+5.3%)151 🔴 (+7.9%)210 🔴 (+17%) 🔻30
SOstream overhead (text)130 (+29%) 🔻238 (+32%) 🔻309 (+53%) 🔻420 (+68%) 🔻30
SOstream overhead (structured)127 (+28%) 🔻206 (+27%) 🔻238 (+22%) 🔻303 (+40%) 🔻30
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: is main, marks where this run lands, bridges the gap when this run has more samples in a bucket.

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) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

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) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

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.

@github-actions

github-actionsBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 Mint-ordered log — 6 fail of 41 total

log=mint-ordered · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted171.0mMISMATCH1
stale-read-equal-step-countscompleted141.0mMISMATCH1
step-vs-step-forkcompleted120msMISMATCH1
step-vs-step-fork-fencedcompleted120msMISMATCH1
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mMISMATCH1
in-flight-before-decision-countedcompleted201.0mok0
in-flight-after-decisionfailed142.0mMISMATCH1
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-mint.txt

🟢 Append-only log — 0 fail of 41 total

log=append-only · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted201.0mok0
stale-read-equal-step-countscompleted141.0mok0
step-vs-step-forkcompleted120msok0
step-vs-step-fork-fencedcompleted120msok0
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mok0
in-flight-before-decision-countedcompleted171.0mok0
in-flight-after-decisioncompleted192.0mok0
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-append-only.txt

@VaguelySerious

Copy link
Copy Markdown
MemberAuthor

CI status

E2E Required Check is red for exactly one reason, and it is not this PR.

UNIT_STATUS: failure
BUILD_STATUS: success
VERCEL_STATUS: success
LOCAL_DEV_STATUS: success
LOCAL_PROD_STATUS: success
POSTGRES_STATUS: success
WINDOWS_STATUS: success
unit (failure)

Unit Tests (windows-latest) times out in packages/world-local/src/storage.test.ts:1284, returns the complete preload when run_started is retried. That test writes 1000 sequential events and already carries an explicit 120_000 timeout.

It is red on main with the identical signature on the last four runs (31530061366, 31533009514, 31604115603, 31616799260) and passed on 08-10 (31446555530, 31431114276).

A marginal budget rather than a code regression. Whole-file duration on the Windows runner, same 245 tests either side of the boundary:

Runsrc/storage.test.tsResult
93641986199 (08-11 00:40, last green)143491mspass
94189757184 (this PR)154816msthis one test times out

8% apart. An added fs op per write would show a much bigger jump than that. Locally create is linear at ~0.95ms regardless of n (measured at n = 250 / 500 / 1000 / 2000), and the whole test runs in 1.17s on macOS.

Not fixing it here: this PR does not touch storage.test.ts, and the fix (parallelize the 1000 writes, or raise the budget with a stated reason) belongs on main rather than folded into a corruption fix. Flagging it because it blocks the required aggregate on every PR, not only this one.

Vercel – workbench-python-workflow also fails on main and every PR, and is not required.

Repro script, postgres lane

Checked that the --world refactor did not break the default path. Full build, container bring-up, migrations, harness: 13/14 completed, 0 CORRUPTED_EVENT_LOG, 1 hook-stormstuck, which is the documented local-runner artifact (one Next.js process holding every replay).

@pranaygppranaygp 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.

Reviewed with a focus on the testing story and on whether this closes all the slot-hole paths. The staging-nonce fix is correct and the crash-window regression guard is real — I verified 545/545 on the branch, tsc --noEmit clean, and that tests 1 and 2 fail deterministically with the fix reverted.

Three findings, detailed inline:

  1. A duplicate-resume window survives the fix (events-storage.ts): when the pinned adopter of a resume claim wins the promote link(2), the unpinned claim owner bumps and publishes a second hook_received for the same resumeId. Reproduced deterministically; repro in the inline comment. Not a CORRUPTED_EVENT_LOG (the log stays dense) — it's a violation of the dedup contract hook-resume-dedup.test.ts asserts, and it pre-exists this PR in a sibling interleaving.
  2. Test 3 is not a regression guard: it passes with the fix reverted (5/5 runs). Tests 1 and 2 do fail without the fix, so the PR description's claim is accurate for those two only.
  3. Minor: a nextjs app-name glob in the repro script doesn't match setupWorld's substring check, which could split-brain the data dir for future app names.

Also audited world-postgres for the analogous hole since it arbitrates slots differently: it doesn't have one. nextSlotId computes the position inside the INSERT itself (storage.ts:167) — nothing reserves a slot ahead of the write, so a rejected or crashed hook_received rolls back its transaction and leaves the numbering untouched; the terminal-race guard is a FOR UPDATE on the run row in the same transaction (storage.ts:1944). It also has no lazy resume-dedup path at all (no resumeId column, per the deliberate omission in drizzle/schema.ts:145), so the duplicate-resume window doesn't apply there either.

// read. Answer it the same way rather than reporting a conflict
// the caller cannot act on: the resume IS committed, exactly once,
// and the dedup contract is that both writers return that event.
if (eventIdPinned && data.eventType === 'hook_received') {

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.

This convergence covers the pinned loser, but the symmetric case is still open and produces a duplicate hook_received for one resumeId.

On a slot run the claim owner is unpinned (eventIdPinned = !isSlotEventId(eventId) up at the claim write), while a racing taker that adopts the claim is pinned. Both stage nonced files and race the promote at the claimed position S. If the adopter wins the link, the owner's promote returns 'exists', bumpEventSlot is allowed to move it (it isn't pinned), and the owner publishes a second hook_received for the same resume at S+1. Both callers return success; the log stays dense, so no CORRUPTED_EVENT_LOG — but replay now delivers one resume twice, which is exactly what the "collapses the two writers of ONE resume onto a single event" contract in hook-resume-dedup.test.ts forbids. (The claim rewrite after the owner's publish points redeliveries at the second event, so later converges mask it — but both events stay in the log and both replay.)

I reproduced this deterministically on this branch by parking the first promoteExclusive caller (the owner — it has a head start, since the adopter does a full findCommittedResumeEvent scan first) until the second caller has linked:

repro test (drop into src/storage/, uses a partial vi.mock of ../fs.js)
constgate={armed: false,released: false,firstRelease: nullas(()=>void)|null};vi.mock('../fs.js',async(importOriginal)=>{constactual=(awaitimportOriginal())asRecord<string,any>;return{
...actual,promoteExclusive: async(stagedPath: string,filePath: string)=>{if(gate.armed&&!gate.released){if(gate.firstRelease===null){awaitnewPromise<void>((r)=>{gate.firstRelease=r;});returnactual.promoteExclusive(stagedPath,filePath);}constresult=awaitactual.promoteExclusive(stagedPath,filePath);gate.released=true;gate.firstRelease?.();returnresult;}returnactual.promoteExclusive(stagedPath,filePath);},};});// setup: createRun + createHook, then:gate.armed=true;constresults=awaitPromise.allSettled([storage,createStorage(testDir)].map((inst)=>inst.events.create(runId,{eventType: 'hook_received',specVersion: SPEC_VERSION_CURRENT,correlationId: hook.hookId,eventData: {token: hook.token,payload: newUint8Array([1])},},{resumeId: 'resume_1',resumePayloadDigest: 'resume_1'})));

Result: zero rejections, and the log holds evnt_…003:hook_receivedandevnt_…004:hook_received, both with resumeId: 'resume_1'.

To be clear about provenance: this window is not introduced here — pre-PR, the same interleaving existed at the staging write (adopter stages the id-keyed path first → owner collides → bumps → duplicates). The nonce moves the collision from staging to promote, but the owner-loses arm is still resolved by bumping.

Suggested fix, symmetric with the one you added: run this occupant/isResumeEvent convergence for hook_received with a resumeIdbeforebumpEventSlot, regardless of pinning — if the occupant at the lost position is this same resume, return it instead of bumping. The pinned case then falls out of the same check, and an unrelated occupant still bumps (unpinned) or conflicts (pinned) as today.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Confirmed, and fixed in c6bf782.

I verified the asymmetry before changing anything: on a slot run the claim owner keeps its own id and eventIdPinned = !isSlotEventId(eventId) leaves it unpinned, while the adopter is pinned to the claimed position. So the loser of the promote can be the unpinned owner, it bumps, and a second hook_received lands for one resumeId.

The convergence now runs inside the publish loop, ahead of bumpEventSlot, for any hook_received carrying a resumeId, with no pinning condition. An occupant that is not this resume is the unrelated-event collision the bump exists for and still bumps, or conflicts when pinned. The post-loop pinned block is gone, since a resume that lost to its own committed event now returns from inside the loop, and reaching the end means the occupant is unrelated.

Regression guard: writes one event when the claim owner loses the position to an adopter. It mocks promoteExclusive to park whichever caller arrives first until the other has linked, which is the interleaving that decides the winner. Falsifiability check: restoring the eventIdPinned && guard makes it fail with expected [ { …(8) }, { …(8) } ] to have a length of 1 but got 2.

expect(data.filter((e) => e.eventType === 'hook_received')).toHaveLength(1);
});

it('keeps the log dense when two instances resume the same hook at once', async () => {

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.

This test passes without the fix — I reverted the two events-storage.ts hunks (keeping the tests) and ran this file five times: tests 1 and 2 failed deterministically every run, but this one passed 5/5.

That matches the old code's behavior for this shape: with two live stagers and no terminal transition, the bumped-off slot always gets backfilled — the loser of the id-keyed staging write bumps to N+1, but the staging winner still promotes N, so the log ends dense either way. The hole needs the stager to be rejected (terminal marker / reap) or crashed, which is what test 1 stages.

So the PR description's "Each regression guard was verified to fail with the fix removed" doesn't hold for this one. Two options:

  • make it a real guard by racing a terminal transition into the window (stager drawn and staged, then reaped, then a later writer draws the same slot), or
  • keep it as-is but reword the description/comment to position it as a sanity check that the new promote-point arbitration keeps density under live-vs-live contention (which it does usefully exercise — the 'exists' → bump path now only exists at the promote).

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

You're right, and thanks for actually running it rather than reading it. With two live stagers and no terminal transition the pre-fix code also ends dense, because the writer bumped off the position is the one that goes on to publish it. The hole needs the stager to be rejected or killed, which is what the crashed-attempt test stages.

Rather than contrive a guard here, I reframed it honestly and let the new owner/adopter test carry the weight. It is now keeps the log dense under live contention on one position, and the comment says outright that density here is not a regression guard, plus what it does cover: arbitrating at the promote still resolves two instances drawing the same position, which is the CLI-plus-app configuration this backend supports (each instance's allocator watermark is its own, so both hand out the same candidate).

Also corrected the PR description, which claimed all three tests fail with the fix removed. True for tests 1, 2, and the new one; not for this one.

# an absolute path so the app's cwd does not enter into it. `setupWorld`
# recomputes the same path for the harness process.
case "$APP_NAME" in
nextjs*|next-*) DATA_DIR_NAME=".next/workflow-data" ;;

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.

This prefix glob doesn't mirror the harness's derivation. setupWorld (packages/core/e2e/utils.ts:507) uses substring matching:

constisNextJs=appName.includes('nextjs')||appName.includes('next-');

so an app named e.g. example-nextjs would get .next/workflow-data from the harness but .workflow-data from this case — the exact silent split-brain the comment above warns about, where the app and the harness talk to different directories. Today it can't bite (only nextjs-turbopack/nextjs-webpack carry the repro fixtures, and both match), but since the whole point of this block is pinning the two processes to one path:

*nextjs*|*next-*) DATA_DIR_NAME=".next/workflow-data";;

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Good catch. Fixed to *nextjs*|*next-*) so it mirrors setupWorld. I checked packages/core/e2e/utils.ts:507 and it is the substring form you quoted, so an app named example-nextjs would have sent the app to .workflow-data and the harness to .next/workflow-data, which is exactly the split-brain the block exists to prevent. Comment above the case now states why it is a substring match.

if (await bumpEventSlot(attempt)) {
continue;
}
// A nonced path cannot already exist. Surfacing rather than

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.

Nit: the comment is right that this can't be a real duplicate — which makes EntityConflictError ("Event already exists") a slightly misleading surface for it. EntityConflictError is the shape the runtime's concurrent-replay paths treat as a benign duplicate publish, so a genuine filesystem fault here would get absorbed as "someone else already wrote it" instead of surfacing as infra trouble. A WorkflowWorldError would keep the impossible case loud. Fine to leave if you'd rather not grow the error surface, since the branch is effectively unreachable.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Taken. It is now a WorkflowWorldError. EntityConflictError is the shape the runtime reads as a benign duplicate publish, so raising it for a nonced path that cannot collide would absorb a filesystem fault as "someone else already wrote it". The comment says that.

Only one of the two takers of a resume claim is pinned. The taker that
writes the claim keeps its own id, unpinned, because a slot is a position
another instance also hands out for unrelated events and refusing to move
would fail the append outright. The taker that adopts an existing claim is
pinned to the claimed position.
So the loser of the promote can be the unpinned owner, and a loser that
bumps publishes a second `hook_received` for one resumeId. The log stays
dense and both callers report success, but replay delivers the resume
twice, which is what the dedup contract forbids.
Run the occupant convergence inside the publish loop, before the bump, for
any `hook_received` carrying a resumeId. An occupant that is not this
resume is the unrelated-event collision the bump is for and still bumps,
or conflicts when pinned. The post-loop pinned check is now redundant.
Also:
- staging-path faults raise WorkflowWorldError rather than
EntityConflictError, which the runtime absorbs as a benign duplicate.
- the repro script's app-name case mirrors `setupWorld`'s substring match
so a future `example-nextjs` cannot split-brain the data dir.
- the live-vs-live density test says what it actually guards: it passes
without the fix, because a bumped-off position still gets published by
the stager that won it.

@pranaygppranaygp 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.

All four findings addressed in c6bf782, and I re-verified each empirically on the branch:

  • Duplicate-resume window: the convergence now runs inside the publish loop before bumpEventSlot, ungated on pinning — exactly the symmetric fix. I confirmed the new writes one event when the claim owner loses the position to an adopter guard is real: with that hunk reverted it fails deterministically (length of 1 but got 2, the duplicate), and with it the file and the full suite pass (546/546, tsc --noEmit clean).
  • Density test: honestly reframed as not-a-regression-guard, and the PR description now matches what the tests actually prove.
  • Script glob mirrors setupWorld's substring match, with the reasoning in a comment.
  • Staging fault now raises WorkflowWorldError so infra trouble can't be absorbed as a benign duplicate.

One non-blocking observation, pre-existing and inherited rather than introduced: isResumeEvent matches an occupant with resumeId === undefined by position, so the in-loop convergence could in principle adopt a plain-pathhook_received (no resumeId) for the same hook as this resume's committed event, dropping the resume's payload. That requires concurrently mixing the lazy and plain resume paths on one hook plus a slot collision — outside the dedup contract's supported shape, and the same predicate already governs converge/findCommittedResumeEvent. Fine to leave; noting it in case slot-run positional matching ever gets tightened.

@VaguelySerious

Copy link
Copy Markdown
MemberAuthor

CI update: everything is green except Unit Tests (windows-latest), which fails at packages/world-local/src/storage.test.ts:1284 with Test timed out in 120000ms. Same signature on main's last runs, so it is not from this PR.

I opened #3503 to fix it on main. It is a marginal budget rather than a step regression: same 245 tests, whole-file duration on the Windows runner went 143491ms (last green) to 154816ms (first red), 8% apart. Since Unit Tests feeds E2E Required Check, that PR is what unblocks the required aggregate here.

@VaguelySerious
VaguelySerious merged commit 0f4b35f into mainAug 12, 2026
160 of 165 checks passed
@VaguelySerious
VaguelySerious deleted the peter/world-local-event-race branch August 12, 2026 20:16
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 0f4b35f (AI decision).

This is a genuine correctness fix, but it targets code that only exists on main: origin/stable's packages/world-local/src/storage/events-storage.ts has no pendingHookEventPath staging, no slot-based event ids, and no bumpEventSlot/notePublishedSlot allocator, so the CORRUPTED_EVENT_LOG hole and the duplicate-resume path being fixed cannot occur there. The remaining changes are also main-only or unmaintained on stablescripts/event-log-race-repro-local.sh is absent from origin/stable, and the AGENTS.md/new-test changes describe main-only harness and storage behavior.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

0f4b35f62945327417013060f6e5de5111fe6ff1

Sign up for freeto 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.

2 participants

@VaguelySerious@pranaygp
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
[world-local] Fix CORRUPTED_EVENT_LOG from hook-resume staging slot holes by VaguelySerious · Pull Request #3492 · vercel/workflow · GitHub
Skip to content

[world-local] Fix CORRUPTED_EVENT_LOG from hook-resume staging slot holes - #3492

Merged
VaguelySerious merged 2 commits into
mainfrom
peter/world-local-event-race
Aug 12, 2026
Merged

[world-local] Fix CORRUPTED_EVENT_LOG from hook-resume staging slot holes#3492
VaguelySerious merged 2 commits into
mainfrom
peter/world-local-event-race

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented Aug 12, 2026

Copy link
Copy Markdown
Member

What

Three fixes in @workflow/world-local, plus the local event-log race repro script gains a --world local lane.

The corruption

hook_received is the one event that does not publish straight into events/. It stages under .locks first, so a terminal transition can reap it before it ever becomes reader-visible (#2987). That staging path was keyed by the event id alone, and a collision on it bumped the writer to the next slot.

Under slot event ids (#3389) that bump is wrong. The slot allocator probes events/ and nothing else, so a staged file is not evidence the position is taken. Bumping moves the writer past a position nothing will ever fill, notePublishedSlot advances the watermark past it, and scanRunEventIds is max-based so no later writer backfills. The runtime reads the missing position as a durable hole and fails the run with CORRUPTED_EVENT_LOG.

Two ways in:

  1. An attempt killed between staging and promoting leaves its staged file behind. Its cleanup lives in a finally the kill skips, and the only other reaper runs on a terminal transition the run has not reached. Every later writer that draws that slot bumps off it, permanently.
  2. Two live writers draw the same candidate and the stager is later rejected by the terminal marker or the reap. The loser bumped off a slot the stager never published.

Provenance: staging arrived in 850777a03b (#2987) when ids were ULIDs, where the old comment was correct that the path "can only be occupied by a previous crashed attempt of this very event". 6786db9953 (#3389) turned ids into positions and kept the id-keyed staging name.

Fix: the staging name carries a nonce, so it is private to one attempt. The slot is then arbitrated only where it is actually taken, at the promote. This restores the file's own stated invariant: a slot is claimed by the publish that occupies it, never reserved ahead of a write that might still be rejected.

The spurious conflict, and the duplicate resume

When a resume loses the publish, the event now at that position can be the same resume, written by the other taker of the claim. That is the convergence the claim exists to force, and converge earlier in the function already answers it with the committed event. It just could not see it yet, because the other taker had not published when this attempt read.

The first pass answered that only for a pinned loser, which left the symmetric case open (caught in review). Only one of the two takers of a claim is pinned: the taker that writes the claim keeps its own id, unpinned, because a slot is a position another instance also hands out for unrelated events and refusing to move would fail this resume's append outright. The taker that adopts an existing claim is pinned to the claimed position. So the loser of the promote can be the unpinned owner, and a loser that bumps publishes a second hook_received for one resumeId. Nothing looks wrong afterwards (the log stays dense, both callers report success) but the resume is delivered twice on replay.

Fix: run the occupant convergence inside the publish loop, ahead of the bump, for any hook_received carrying a resumeId, with no pinning condition. An occupant that is not this resume is the unrelated-event collision the bump is for and still bumps, or conflicts when pinned. Both takers return the one committed event, matching the dedup contract hook-resume-dedup.test.ts already asserts. Reporting EntityConflictError instead gave the caller an error it cannot act on for a resume that did land (HTTP 500, queue retry).

Separately, a failure to stage under the nonced path now raises WorkflowWorldError rather than EntityConflictError: a nonced path cannot collide, so it is a filesystem fault, and EntityConflictError is the shape the runtime absorbs as a benign duplicate publish.

Repro script

scripts/event-log-race-repro-local.sh was postgres-only. It now takes --world postgres|local (default postgres, unchanged). Under --world local it exports WORKFLOW_TARGET_WORLD=local plus an absolute WORKFLOW_LOCAL_DATA_DIR and WORKFLOW_LOCAL_QUEUE_CONCURRENCY, clears the data directory instead of the queue, and skips all container bring-up, migration, and teardown.

pnpm run test:e2e:event-log-race-repro:local --world local

Testing

New packages/world-local/src/storage/hook-staging-slots.test.ts, 4 tests. Three are regression guards verified to fail with the fix removed:

  • a crashed attempt's leftover staged file no longer holes the log. Before the fix this produced slots [1, 2, 4] instead of [1, 2, 3].
  • both writers of one raced resume get the committed event back rather than a conflict.
  • when the unpinned claim owner loses the position to an adopter, one hook_received is written and both takers return it. This one mocks promoteExclusive to park whichever caller reaches it first until the other has linked, since the outcome hinges on that interleaving. Restoring the eventIdPinned && guard makes it fail with to have a length of 1 but got 2.

The fourth, keeps the log dense under live contention on one position, is not a regression guard and its comment says so: with two live stagers and no terminal transition the pre-fix code also ended dense, because the writer it bumped off the position was the one that went on to publish it. It covers that arbitrating at the promote still resolves two instances drawing one position.

Full world-local suite: 546/546 across 16 files. tsc --noEmit clean.

On the storm harness, stated plainly: it has never produced a CORRUPTED_EVENT_LOG outcome or an on-disk hole against world-local, across one 14-run pass and two 16-run passes before the fix. The corruption is demonstrated by the unit test, not by the storm. What the storm does show is the conflict volume: ~12-27 EntityConflictError per pass before, 1 after (a hook_created benign duplicate, which is the documented path), with all 14 runs completing and every run dense on disk (count == max, 8931 event files checked).

This matches the note now in AGENTS.md: world-local's storms come out clean far more often than world-postgres's, so reach for a unit test in packages/world-local/src/storage/ when a suspected filesystem race can be staged directly.

`hook_received` is the only event that does not publish straight into
`events/`: it stages under `.locks` first so a terminal transition can
reap it before it becomes reader-visible. That staging path was keyed by
the event id alone, and a collision on it bumped the writer to the next
slot.
Under slot ids a staging collision is not evidence the position is
taken. The allocator probes `events/` only, so bumping moves the writer
past a position nothing will ever fill, and `scanRunEventIds` is
max-based so no later writer backfills it. The runtime reads the missing
position as a durable hole and fails the run with CORRUPTED_EVENT_LOG.
Two triggers: an attempt killed between staging and promoting leaves its
staged file behind (cleanup lives in a `finally` the kill skips, and the
only other reaper runs on a terminal transition), and two live writers
drawing the same candidate where the stager is later rejected.
Staging now carries a nonce, so it is private to one attempt and the
slot is arbitrated only where it is actually taken, at the promote.
Second fix: when a pinned resume loses the publish, the event at the
pinned position is that same resume written by the other taker, which is
the convergence the pin exists to force. Return that committed event
instead of an EntityConflictError the caller cannot act on.
Also re-purposes scripts/event-log-race-repro-local.sh to drive either
world with `--world postgres|local`.
@VaguelySerious
VaguelySerious requested a review from a team as a code ownerAugust 12, 2026 16:46
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

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

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewAug 12, 2026 7:53pm
example-nextjs-workflow-webpackReadyReadyPreviewAug 12, 2026 7:53pm
example-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-astro-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-express-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-fastify-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-hono-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-nestjs-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-nitro-workflowBuildingBuildingPreviewAug 12, 2026 7:53pm
workbench-nuxt-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-python-workflowErrorErrorAug 12, 2026 7:53pm
workbench-sveltekit-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-tanstack-start-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-vite-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workflow-docsReadyReadyPreview, v0Aug 12, 2026 7:53pm
workflow-swc-playgroundReadyReadyPreviewAug 12, 2026 7:53pm
workflow-tarballsReadyReadyPreviewAug 12, 2026 7:53pm
workflow-webReadyReadyPreviewAug 12, 2026 7:53pm

@changeset-bot

changeset-botBot commented Aug 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c6bf782

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

This PR includes changesets to release 18 packages
NameType
@workflow/world-localPatch
@workflow/cliPatch
@workflow/corePatch
@workflow/vitestPatch
@workflow/webPatch
@workflow/world-postgresPatch
workflowPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/web-sharedPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

@github-actions

github-actionsBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

❌ Failed E2E Tests

▲ Vercel Production (1 failed)

nextjs-turbopack-node (1 failed):

  • distributedAbortController - manual abort triggers signal | wrun_41KZVSGQDP0GJSR1W5GDTRB65H | 🔍 observability

E2E Test Summary

Summary
PassedFailedSkippedTotal
❌ ▲ Vercel Production331215873900
✅ 💻 Local Development381005584368
✅ 📦 Local Production381005584368
✅ 🐘 Local Postgres381005584368
✅ 🪟 Windows31200312
✅ vercel-multi-region270027
Total150811226117343
Details by Category

❌ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node128028
✅ astro-quickjs128028
✅ example-node128028
✅ example-quickjs128028
✅ express-node128028
✅ express-quickjs128028
✅ fastify-node128028
✅ fastify-quickjs128028
✅ hono-node128028
✅ hono-quickjs128028
✅ nest-node128028
✅ nest-quickjs128028
❌ nextjs-turbopack-node15213
✅ nextjs-webpack-node15303
✅ nextjs-webpack-quickjs15303
✅ nitro-node128028
✅ nitro-quickjs128028
✅ nuxt-node128028
✅ nuxt-quickjs128028
✅ sveltekit-node14709
✅ sveltekit-quickjs14709
✅ tanstack-start-node128028
✅ tanstack-start-quickjs128028
✅ vite-node128028
✅ vite-quickjs128028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack-node15600
✅ nextjs-turbopack-quickjs15600

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@github-actions

github-actionsBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit c6bf782 · Wed, 12 Aug 2026 20:08:57 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1361 (+513%) 🔻1553 🔴 (+39%) 🔻1591 🔴 (+36%) 🔻1914 🔴 (+9.6%)30
TTFSstream1359 (+427%) 🔻1519 🔴 (+37%) 🔻1595 🔴 (+42%) 🔻1621 🔴 (+5.7%)30
TTFShook + stream1593 (+331%) 🔻1805 🔴 (+32%) 🔻1866 🔴 (+30%) 🔻1973 🔴 (-57%) 💚30
STSO1020 steps (inline)133 (-2.9%)177 (-16%) 💚198 (-19%) 💚296 (-26%) 💚1019
WO1020 steps176010 (-14%)176010 (-14%)176010 (-14%)176010 (-14%)1
SLstream latency109 (+18%) 🔻154 🔴 (+4.8%)182 🔴 (+0.6%)3136 🔴 (+602%) 🔻30
SOstream overhead (text)128 (+4.9%)185 (-31%) 💚210 (-57%) 💚282 (-71%) 💚30
SOstream overhead (structured)121 (-2.4%)169 (-39%) 💚198 (-77%) 💚235 (-99%) 💚30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 204038ms → this run 174609ms (Δ -29429ms, -14%)

 100-150 ms █░░┃ main 11 this 137 +126
150-200 ms ████████████████████░░░┃ main 643 this 785 +142
200-250 ms █┃██████ main 277 this 69 -208
250-300 ms ┃█ main 55 this 18 -37
300-350 ms ┃ main 10 this 6 -4
350-400 ms ┃ main 12 this 3 -9
400-450 ms ┃ main 3 this 0 -3
450-500 ms ┃ main 1 this 1 +0
550-600 ms ┃ main 1 this 0 -1
600-650 ms ┃ main 1 this 0 -1
650-700 ms ┃ main 4 this 0 -4
1000-1050 ms ┃ main 1 this 0 -1
📜 Previous results (1)

0161a14

Wed, 12 Aug 2026 17:06:24 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1262 (+79%) 🔻1448 🔴 (+44%) 🔻1489 🔴 (+47%) 🔻1737 🔴 (+15%) 🔻30
TTFSstream317 (-67%) 💚1381 🔴 (+40%) 🔻1410 🔴 (+40%) 🔻1505 🔴 (+43%) 🔻30
TTFShook + stream1618 (+33%) 🔻1718 🔴 (+33%) 🔻1786 🔴 (+33%) 🔻1875 🔴 (+16%) 🔻30
STSO1020 steps (inline)1311782034061019
WO1020 steps180588 (-53%) 💚180588 (-53%) 💚180588 (-53%) 💚180588 (-53%) 💚1
SLstream latency109 (+35%) 🔻139 🔴 (+5.3%)151 🔴 (+7.9%)210 🔴 (+17%) 🔻30
SOstream overhead (text)130 (+29%) 🔻238 (+32%) 🔻309 (+53%) 🔻420 (+68%) 🔻30
SOstream overhead (structured)127 (+28%) 🔻206 (+27%) 🔻238 (+22%) 🔻303 (+40%) 🔻30
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: is main, marks where this run lands, bridges the gap when this run has more samples in a bucket.

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) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

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) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

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.

@github-actions

github-actionsBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 Mint-ordered log — 6 fail of 41 total

log=mint-ordered · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted171.0mMISMATCH1
stale-read-equal-step-countscompleted141.0mMISMATCH1
step-vs-step-forkcompleted120msMISMATCH1
step-vs-step-fork-fencedcompleted120msMISMATCH1
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mMISMATCH1
in-flight-before-decision-countedcompleted201.0mok0
in-flight-after-decisionfailed142.0mMISMATCH1
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-mint.txt

🟢 Append-only log — 0 fail of 41 total

log=append-only · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted201.0mok0
stale-read-equal-step-countscompleted141.0mok0
step-vs-step-forkcompleted120msok0
step-vs-step-fork-fencedcompleted120msok0
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mok0
in-flight-before-decision-countedcompleted171.0mok0
in-flight-after-decisioncompleted192.0mok0
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-append-only.txt

@VaguelySerious

Copy link
Copy Markdown
MemberAuthor

CI status

E2E Required Check is red for exactly one reason, and it is not this PR.

UNIT_STATUS: failure
BUILD_STATUS: success
VERCEL_STATUS: success
LOCAL_DEV_STATUS: success
LOCAL_PROD_STATUS: success
POSTGRES_STATUS: success
WINDOWS_STATUS: success
unit (failure)

Unit Tests (windows-latest) times out in packages/world-local/src/storage.test.ts:1284, returns the complete preload when run_started is retried. That test writes 1000 sequential events and already carries an explicit 120_000 timeout.

It is red on main with the identical signature on the last four runs (31530061366, 31533009514, 31604115603, 31616799260) and passed on 08-10 (31446555530, 31431114276).

A marginal budget rather than a code regression. Whole-file duration on the Windows runner, same 245 tests either side of the boundary:

Runsrc/storage.test.tsResult
93641986199 (08-11 00:40, last green)143491mspass
94189757184 (this PR)154816msthis one test times out

8% apart. An added fs op per write would show a much bigger jump than that. Locally create is linear at ~0.95ms regardless of n (measured at n = 250 / 500 / 1000 / 2000), and the whole test runs in 1.17s on macOS.

Not fixing it here: this PR does not touch storage.test.ts, and the fix (parallelize the 1000 writes, or raise the budget with a stated reason) belongs on main rather than folded into a corruption fix. Flagging it because it blocks the required aggregate on every PR, not only this one.

Vercel – workbench-python-workflow also fails on main and every PR, and is not required.

Repro script, postgres lane

Checked that the --world refactor did not break the default path. Full build, container bring-up, migrations, harness: 13/14 completed, 0 CORRUPTED_EVENT_LOG, 1 hook-stormstuck, which is the documented local-runner artifact (one Next.js process holding every replay).

@pranaygppranaygp 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.

Reviewed with a focus on the testing story and on whether this closes all the slot-hole paths. The staging-nonce fix is correct and the crash-window regression guard is real — I verified 545/545 on the branch, tsc --noEmit clean, and that tests 1 and 2 fail deterministically with the fix reverted.

Three findings, detailed inline:

  1. A duplicate-resume window survives the fix (events-storage.ts): when the pinned adopter of a resume claim wins the promote link(2), the unpinned claim owner bumps and publishes a second hook_received for the same resumeId. Reproduced deterministically; repro in the inline comment. Not a CORRUPTED_EVENT_LOG (the log stays dense) — it's a violation of the dedup contract hook-resume-dedup.test.ts asserts, and it pre-exists this PR in a sibling interleaving.
  2. Test 3 is not a regression guard: it passes with the fix reverted (5/5 runs). Tests 1 and 2 do fail without the fix, so the PR description's claim is accurate for those two only.
  3. Minor: a nextjs app-name glob in the repro script doesn't match setupWorld's substring check, which could split-brain the data dir for future app names.

Also audited world-postgres for the analogous hole since it arbitrates slots differently: it doesn't have one. nextSlotId computes the position inside the INSERT itself (storage.ts:167) — nothing reserves a slot ahead of the write, so a rejected or crashed hook_received rolls back its transaction and leaves the numbering untouched; the terminal-race guard is a FOR UPDATE on the run row in the same transaction (storage.ts:1944). It also has no lazy resume-dedup path at all (no resumeId column, per the deliberate omission in drizzle/schema.ts:145), so the duplicate-resume window doesn't apply there either.

// read. Answer it the same way rather than reporting a conflict
// the caller cannot act on: the resume IS committed, exactly once,
// and the dedup contract is that both writers return that event.
if (eventIdPinned && data.eventType === 'hook_received') {

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.

This convergence covers the pinned loser, but the symmetric case is still open and produces a duplicate hook_received for one resumeId.

On a slot run the claim owner is unpinned (eventIdPinned = !isSlotEventId(eventId) up at the claim write), while a racing taker that adopts the claim is pinned. Both stage nonced files and race the promote at the claimed position S. If the adopter wins the link, the owner's promote returns 'exists', bumpEventSlot is allowed to move it (it isn't pinned), and the owner publishes a second hook_received for the same resume at S+1. Both callers return success; the log stays dense, so no CORRUPTED_EVENT_LOG — but replay now delivers one resume twice, which is exactly what the "collapses the two writers of ONE resume onto a single event" contract in hook-resume-dedup.test.ts forbids. (The claim rewrite after the owner's publish points redeliveries at the second event, so later converges mask it — but both events stay in the log and both replay.)

I reproduced this deterministically on this branch by parking the first promoteExclusive caller (the owner — it has a head start, since the adopter does a full findCommittedResumeEvent scan first) until the second caller has linked:

repro test (drop into src/storage/, uses a partial vi.mock of ../fs.js)
constgate={armed: false,released: false,firstRelease: nullas(()=>void)|null};vi.mock('../fs.js',async(importOriginal)=>{constactual=(awaitimportOriginal())asRecord<string,any>;return{
...actual,promoteExclusive: async(stagedPath: string,filePath: string)=>{if(gate.armed&&!gate.released){if(gate.firstRelease===null){awaitnewPromise<void>((r)=>{gate.firstRelease=r;});returnactual.promoteExclusive(stagedPath,filePath);}constresult=awaitactual.promoteExclusive(stagedPath,filePath);gate.released=true;gate.firstRelease?.();returnresult;}returnactual.promoteExclusive(stagedPath,filePath);},};});// setup: createRun + createHook, then:gate.armed=true;constresults=awaitPromise.allSettled([storage,createStorage(testDir)].map((inst)=>inst.events.create(runId,{eventType: 'hook_received',specVersion: SPEC_VERSION_CURRENT,correlationId: hook.hookId,eventData: {token: hook.token,payload: newUint8Array([1])},},{resumeId: 'resume_1',resumePayloadDigest: 'resume_1'})));

Result: zero rejections, and the log holds evnt_…003:hook_receivedandevnt_…004:hook_received, both with resumeId: 'resume_1'.

To be clear about provenance: this window is not introduced here — pre-PR, the same interleaving existed at the staging write (adopter stages the id-keyed path first → owner collides → bumps → duplicates). The nonce moves the collision from staging to promote, but the owner-loses arm is still resolved by bumping.

Suggested fix, symmetric with the one you added: run this occupant/isResumeEvent convergence for hook_received with a resumeIdbeforebumpEventSlot, regardless of pinning — if the occupant at the lost position is this same resume, return it instead of bumping. The pinned case then falls out of the same check, and an unrelated occupant still bumps (unpinned) or conflicts (pinned) as today.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Confirmed, and fixed in c6bf782.

I verified the asymmetry before changing anything: on a slot run the claim owner keeps its own id and eventIdPinned = !isSlotEventId(eventId) leaves it unpinned, while the adopter is pinned to the claimed position. So the loser of the promote can be the unpinned owner, it bumps, and a second hook_received lands for one resumeId.

The convergence now runs inside the publish loop, ahead of bumpEventSlot, for any hook_received carrying a resumeId, with no pinning condition. An occupant that is not this resume is the unrelated-event collision the bump exists for and still bumps, or conflicts when pinned. The post-loop pinned block is gone, since a resume that lost to its own committed event now returns from inside the loop, and reaching the end means the occupant is unrelated.

Regression guard: writes one event when the claim owner loses the position to an adopter. It mocks promoteExclusive to park whichever caller arrives first until the other has linked, which is the interleaving that decides the winner. Falsifiability check: restoring the eventIdPinned && guard makes it fail with expected [ { …(8) }, { …(8) } ] to have a length of 1 but got 2.

expect(data.filter((e) => e.eventType === 'hook_received')).toHaveLength(1);
});

it('keeps the log dense when two instances resume the same hook at once', async () => {

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.

This test passes without the fix — I reverted the two events-storage.ts hunks (keeping the tests) and ran this file five times: tests 1 and 2 failed deterministically every run, but this one passed 5/5.

That matches the old code's behavior for this shape: with two live stagers and no terminal transition, the bumped-off slot always gets backfilled — the loser of the id-keyed staging write bumps to N+1, but the staging winner still promotes N, so the log ends dense either way. The hole needs the stager to be rejected (terminal marker / reap) or crashed, which is what test 1 stages.

So the PR description's "Each regression guard was verified to fail with the fix removed" doesn't hold for this one. Two options:

  • make it a real guard by racing a terminal transition into the window (stager drawn and staged, then reaped, then a later writer draws the same slot), or
  • keep it as-is but reword the description/comment to position it as a sanity check that the new promote-point arbitration keeps density under live-vs-live contention (which it does usefully exercise — the 'exists' → bump path now only exists at the promote).

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

You're right, and thanks for actually running it rather than reading it. With two live stagers and no terminal transition the pre-fix code also ends dense, because the writer bumped off the position is the one that goes on to publish it. The hole needs the stager to be rejected or killed, which is what the crashed-attempt test stages.

Rather than contrive a guard here, I reframed it honestly and let the new owner/adopter test carry the weight. It is now keeps the log dense under live contention on one position, and the comment says outright that density here is not a regression guard, plus what it does cover: arbitrating at the promote still resolves two instances drawing the same position, which is the CLI-plus-app configuration this backend supports (each instance's allocator watermark is its own, so both hand out the same candidate).

Also corrected the PR description, which claimed all three tests fail with the fix removed. True for tests 1, 2, and the new one; not for this one.

# an absolute path so the app's cwd does not enter into it. `setupWorld`
# recomputes the same path for the harness process.
case "$APP_NAME" in
nextjs*|next-*) DATA_DIR_NAME=".next/workflow-data" ;;

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.

This prefix glob doesn't mirror the harness's derivation. setupWorld (packages/core/e2e/utils.ts:507) uses substring matching:

constisNextJs=appName.includes('nextjs')||appName.includes('next-');

so an app named e.g. example-nextjs would get .next/workflow-data from the harness but .workflow-data from this case — the exact silent split-brain the comment above warns about, where the app and the harness talk to different directories. Today it can't bite (only nextjs-turbopack/nextjs-webpack carry the repro fixtures, and both match), but since the whole point of this block is pinning the two processes to one path:

*nextjs*|*next-*) DATA_DIR_NAME=".next/workflow-data";;

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Good catch. Fixed to *nextjs*|*next-*) so it mirrors setupWorld. I checked packages/core/e2e/utils.ts:507 and it is the substring form you quoted, so an app named example-nextjs would have sent the app to .workflow-data and the harness to .next/workflow-data, which is exactly the split-brain the block exists to prevent. Comment above the case now states why it is a substring match.

if (await bumpEventSlot(attempt)) {
continue;
}
// A nonced path cannot already exist. Surfacing rather than

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.

Nit: the comment is right that this can't be a real duplicate — which makes EntityConflictError ("Event already exists") a slightly misleading surface for it. EntityConflictError is the shape the runtime's concurrent-replay paths treat as a benign duplicate publish, so a genuine filesystem fault here would get absorbed as "someone else already wrote it" instead of surfacing as infra trouble. A WorkflowWorldError would keep the impossible case loud. Fine to leave if you'd rather not grow the error surface, since the branch is effectively unreachable.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Taken. It is now a WorkflowWorldError. EntityConflictError is the shape the runtime reads as a benign duplicate publish, so raising it for a nonced path that cannot collide would absorb a filesystem fault as "someone else already wrote it". The comment says that.

Only one of the two takers of a resume claim is pinned. The taker that
writes the claim keeps its own id, unpinned, because a slot is a position
another instance also hands out for unrelated events and refusing to move
would fail the append outright. The taker that adopts an existing claim is
pinned to the claimed position.
So the loser of the promote can be the unpinned owner, and a loser that
bumps publishes a second `hook_received` for one resumeId. The log stays
dense and both callers report success, but replay delivers the resume
twice, which is what the dedup contract forbids.
Run the occupant convergence inside the publish loop, before the bump, for
any `hook_received` carrying a resumeId. An occupant that is not this
resume is the unrelated-event collision the bump is for and still bumps,
or conflicts when pinned. The post-loop pinned check is now redundant.
Also:
- staging-path faults raise WorkflowWorldError rather than
EntityConflictError, which the runtime absorbs as a benign duplicate.
- the repro script's app-name case mirrors `setupWorld`'s substring match
so a future `example-nextjs` cannot split-brain the data dir.
- the live-vs-live density test says what it actually guards: it passes
without the fix, because a bumped-off position still gets published by
the stager that won it.

@pranaygppranaygp 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.

All four findings addressed in c6bf782, and I re-verified each empirically on the branch:

  • Duplicate-resume window: the convergence now runs inside the publish loop before bumpEventSlot, ungated on pinning — exactly the symmetric fix. I confirmed the new writes one event when the claim owner loses the position to an adopter guard is real: with that hunk reverted it fails deterministically (length of 1 but got 2, the duplicate), and with it the file and the full suite pass (546/546, tsc --noEmit clean).
  • Density test: honestly reframed as not-a-regression-guard, and the PR description now matches what the tests actually prove.
  • Script glob mirrors setupWorld's substring match, with the reasoning in a comment.
  • Staging fault now raises WorkflowWorldError so infra trouble can't be absorbed as a benign duplicate.

One non-blocking observation, pre-existing and inherited rather than introduced: isResumeEvent matches an occupant with resumeId === undefined by position, so the in-loop convergence could in principle adopt a plain-pathhook_received (no resumeId) for the same hook as this resume's committed event, dropping the resume's payload. That requires concurrently mixing the lazy and plain resume paths on one hook plus a slot collision — outside the dedup contract's supported shape, and the same predicate already governs converge/findCommittedResumeEvent. Fine to leave; noting it in case slot-run positional matching ever gets tightened.

@VaguelySerious

Copy link
Copy Markdown
MemberAuthor

CI update: everything is green except Unit Tests (windows-latest), which fails at packages/world-local/src/storage.test.ts:1284 with Test timed out in 120000ms. Same signature on main's last runs, so it is not from this PR.

I opened #3503 to fix it on main. It is a marginal budget rather than a step regression: same 245 tests, whole-file duration on the Windows runner went 143491ms (last green) to 154816ms (first red), 8% apart. Since Unit Tests feeds E2E Required Check, that PR is what unblocks the required aggregate here.

@VaguelySerious
VaguelySerious merged commit 0f4b35f into mainAug 12, 2026
160 of 165 checks passed
@VaguelySerious
VaguelySerious deleted the peter/world-local-event-race branch August 12, 2026 20:16
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 0f4b35f (AI decision).

This is a genuine correctness fix, but it targets code that only exists on main: origin/stable's packages/world-local/src/storage/events-storage.ts has no pendingHookEventPath staging, no slot-based event ids, and no bumpEventSlot/notePublishedSlot allocator, so the CORRUPTED_EVENT_LOG hole and the duplicate-resume path being fixed cannot occur there. The remaining changes are also main-only or unmaintained on stablescripts/event-log-race-repro-local.sh is absent from origin/stable, and the AGENTS.md/new-test changes describe main-only harness and storage behavior.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

0f4b35f62945327417013060f6e5de5111fe6ff1

Sign up for freeto 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.

2 participants

@VaguelySerious@pranaygp
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [world-local] Fix CORRUPTED_EVENT_LOG from hook-resume staging slot holes by VaguelySerious · Pull Request #3492 · vercel/workflow · GitHub
Skip to content

[world-local] Fix CORRUPTED_EVENT_LOG from hook-resume staging slot holes - #3492

Merged
VaguelySerious merged 2 commits into
mainfrom
peter/world-local-event-race
Aug 12, 2026
Merged

[world-local] Fix CORRUPTED_EVENT_LOG from hook-resume staging slot holes#3492
VaguelySerious merged 2 commits into
mainfrom
peter/world-local-event-race

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented Aug 12, 2026

Copy link
Copy Markdown
Member

What

Three fixes in @workflow/world-local, plus the local event-log race repro script gains a --world local lane.

The corruption

hook_received is the one event that does not publish straight into events/. It stages under .locks first, so a terminal transition can reap it before it ever becomes reader-visible (#2987). That staging path was keyed by the event id alone, and a collision on it bumped the writer to the next slot.

Under slot event ids (#3389) that bump is wrong. The slot allocator probes events/ and nothing else, so a staged file is not evidence the position is taken. Bumping moves the writer past a position nothing will ever fill, notePublishedSlot advances the watermark past it, and scanRunEventIds is max-based so no later writer backfills. The runtime reads the missing position as a durable hole and fails the run with CORRUPTED_EVENT_LOG.

Two ways in:

  1. An attempt killed between staging and promoting leaves its staged file behind. Its cleanup lives in a finally the kill skips, and the only other reaper runs on a terminal transition the run has not reached. Every later writer that draws that slot bumps off it, permanently.
  2. Two live writers draw the same candidate and the stager is later rejected by the terminal marker or the reap. The loser bumped off a slot the stager never published.

Provenance: staging arrived in 850777a03b (#2987) when ids were ULIDs, where the old comment was correct that the path "can only be occupied by a previous crashed attempt of this very event". 6786db9953 (#3389) turned ids into positions and kept the id-keyed staging name.

Fix: the staging name carries a nonce, so it is private to one attempt. The slot is then arbitrated only where it is actually taken, at the promote. This restores the file's own stated invariant: a slot is claimed by the publish that occupies it, never reserved ahead of a write that might still be rejected.

The spurious conflict, and the duplicate resume

When a resume loses the publish, the event now at that position can be the same resume, written by the other taker of the claim. That is the convergence the claim exists to force, and converge earlier in the function already answers it with the committed event. It just could not see it yet, because the other taker had not published when this attempt read.

The first pass answered that only for a pinned loser, which left the symmetric case open (caught in review). Only one of the two takers of a claim is pinned: the taker that writes the claim keeps its own id, unpinned, because a slot is a position another instance also hands out for unrelated events and refusing to move would fail this resume's append outright. The taker that adopts an existing claim is pinned to the claimed position. So the loser of the promote can be the unpinned owner, and a loser that bumps publishes a second hook_received for one resumeId. Nothing looks wrong afterwards (the log stays dense, both callers report success) but the resume is delivered twice on replay.

Fix: run the occupant convergence inside the publish loop, ahead of the bump, for any hook_received carrying a resumeId, with no pinning condition. An occupant that is not this resume is the unrelated-event collision the bump is for and still bumps, or conflicts when pinned. Both takers return the one committed event, matching the dedup contract hook-resume-dedup.test.ts already asserts. Reporting EntityConflictError instead gave the caller an error it cannot act on for a resume that did land (HTTP 500, queue retry).

Separately, a failure to stage under the nonced path now raises WorkflowWorldError rather than EntityConflictError: a nonced path cannot collide, so it is a filesystem fault, and EntityConflictError is the shape the runtime absorbs as a benign duplicate publish.

Repro script

scripts/event-log-race-repro-local.sh was postgres-only. It now takes --world postgres|local (default postgres, unchanged). Under --world local it exports WORKFLOW_TARGET_WORLD=local plus an absolute WORKFLOW_LOCAL_DATA_DIR and WORKFLOW_LOCAL_QUEUE_CONCURRENCY, clears the data directory instead of the queue, and skips all container bring-up, migration, and teardown.

pnpm run test:e2e:event-log-race-repro:local --world local

Testing

New packages/world-local/src/storage/hook-staging-slots.test.ts, 4 tests. Three are regression guards verified to fail with the fix removed:

  • a crashed attempt's leftover staged file no longer holes the log. Before the fix this produced slots [1, 2, 4] instead of [1, 2, 3].
  • both writers of one raced resume get the committed event back rather than a conflict.
  • when the unpinned claim owner loses the position to an adopter, one hook_received is written and both takers return it. This one mocks promoteExclusive to park whichever caller reaches it first until the other has linked, since the outcome hinges on that interleaving. Restoring the eventIdPinned && guard makes it fail with to have a length of 1 but got 2.

The fourth, keeps the log dense under live contention on one position, is not a regression guard and its comment says so: with two live stagers and no terminal transition the pre-fix code also ended dense, because the writer it bumped off the position was the one that went on to publish it. It covers that arbitrating at the promote still resolves two instances drawing one position.

Full world-local suite: 546/546 across 16 files. tsc --noEmit clean.

On the storm harness, stated plainly: it has never produced a CORRUPTED_EVENT_LOG outcome or an on-disk hole against world-local, across one 14-run pass and two 16-run passes before the fix. The corruption is demonstrated by the unit test, not by the storm. What the storm does show is the conflict volume: ~12-27 EntityConflictError per pass before, 1 after (a hook_created benign duplicate, which is the documented path), with all 14 runs completing and every run dense on disk (count == max, 8931 event files checked).

This matches the note now in AGENTS.md: world-local's storms come out clean far more often than world-postgres's, so reach for a unit test in packages/world-local/src/storage/ when a suspected filesystem race can be staged directly.

`hook_received` is the only event that does not publish straight into
`events/`: it stages under `.locks` first so a terminal transition can
reap it before it becomes reader-visible. That staging path was keyed by
the event id alone, and a collision on it bumped the writer to the next
slot.
Under slot ids a staging collision is not evidence the position is
taken. The allocator probes `events/` only, so bumping moves the writer
past a position nothing will ever fill, and `scanRunEventIds` is
max-based so no later writer backfills it. The runtime reads the missing
position as a durable hole and fails the run with CORRUPTED_EVENT_LOG.
Two triggers: an attempt killed between staging and promoting leaves its
staged file behind (cleanup lives in a `finally` the kill skips, and the
only other reaper runs on a terminal transition), and two live writers
drawing the same candidate where the stager is later rejected.
Staging now carries a nonce, so it is private to one attempt and the
slot is arbitrated only where it is actually taken, at the promote.
Second fix: when a pinned resume loses the publish, the event at the
pinned position is that same resume written by the other taker, which is
the convergence the pin exists to force. Return that committed event
instead of an EntityConflictError the caller cannot act on.
Also re-purposes scripts/event-log-race-repro-local.sh to drive either
world with `--world postgres|local`.
@VaguelySerious
VaguelySerious requested a review from a team as a code ownerAugust 12, 2026 16:46
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

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

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewAug 12, 2026 7:53pm
example-nextjs-workflow-webpackReadyReadyPreviewAug 12, 2026 7:53pm
example-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-astro-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-express-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-fastify-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-hono-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-nestjs-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-nitro-workflowBuildingBuildingPreviewAug 12, 2026 7:53pm
workbench-nuxt-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-python-workflowErrorErrorAug 12, 2026 7:53pm
workbench-sveltekit-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-tanstack-start-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-vite-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workflow-docsReadyReadyPreview, v0Aug 12, 2026 7:53pm
workflow-swc-playgroundReadyReadyPreviewAug 12, 2026 7:53pm
workflow-tarballsReadyReadyPreviewAug 12, 2026 7:53pm
workflow-webReadyReadyPreviewAug 12, 2026 7:53pm

@changeset-bot

changeset-botBot commented Aug 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c6bf782

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

This PR includes changesets to release 18 packages
NameType
@workflow/world-localPatch
@workflow/cliPatch
@workflow/corePatch
@workflow/vitestPatch
@workflow/webPatch
@workflow/world-postgresPatch
workflowPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/web-sharedPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

@github-actions

github-actionsBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

❌ Failed E2E Tests

▲ Vercel Production (1 failed)

nextjs-turbopack-node (1 failed):

  • distributedAbortController - manual abort triggers signal | wrun_41KZVSGQDP0GJSR1W5GDTRB65H | 🔍 observability

E2E Test Summary

Summary
PassedFailedSkippedTotal
❌ ▲ Vercel Production331215873900
✅ 💻 Local Development381005584368
✅ 📦 Local Production381005584368
✅ 🐘 Local Postgres381005584368
✅ 🪟 Windows31200312
✅ vercel-multi-region270027
Total150811226117343
Details by Category

❌ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node128028
✅ astro-quickjs128028
✅ example-node128028
✅ example-quickjs128028
✅ express-node128028
✅ express-quickjs128028
✅ fastify-node128028
✅ fastify-quickjs128028
✅ hono-node128028
✅ hono-quickjs128028
✅ nest-node128028
✅ nest-quickjs128028
❌ nextjs-turbopack-node15213
✅ nextjs-webpack-node15303
✅ nextjs-webpack-quickjs15303
✅ nitro-node128028
✅ nitro-quickjs128028
✅ nuxt-node128028
✅ nuxt-quickjs128028
✅ sveltekit-node14709
✅ sveltekit-quickjs14709
✅ tanstack-start-node128028
✅ tanstack-start-quickjs128028
✅ vite-node128028
✅ vite-quickjs128028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack-node15600
✅ nextjs-turbopack-quickjs15600

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@github-actions

github-actionsBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit c6bf782 · Wed, 12 Aug 2026 20:08:57 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1361 (+513%) 🔻1553 🔴 (+39%) 🔻1591 🔴 (+36%) 🔻1914 🔴 (+9.6%)30
TTFSstream1359 (+427%) 🔻1519 🔴 (+37%) 🔻1595 🔴 (+42%) 🔻1621 🔴 (+5.7%)30
TTFShook + stream1593 (+331%) 🔻1805 🔴 (+32%) 🔻1866 🔴 (+30%) 🔻1973 🔴 (-57%) 💚30
STSO1020 steps (inline)133 (-2.9%)177 (-16%) 💚198 (-19%) 💚296 (-26%) 💚1019
WO1020 steps176010 (-14%)176010 (-14%)176010 (-14%)176010 (-14%)1
SLstream latency109 (+18%) 🔻154 🔴 (+4.8%)182 🔴 (+0.6%)3136 🔴 (+602%) 🔻30
SOstream overhead (text)128 (+4.9%)185 (-31%) 💚210 (-57%) 💚282 (-71%) 💚30
SOstream overhead (structured)121 (-2.4%)169 (-39%) 💚198 (-77%) 💚235 (-99%) 💚30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 204038ms → this run 174609ms (Δ -29429ms, -14%)

 100-150 ms █░░┃ main 11 this 137 +126
150-200 ms ████████████████████░░░┃ main 643 this 785 +142
200-250 ms █┃██████ main 277 this 69 -208
250-300 ms ┃█ main 55 this 18 -37
300-350 ms ┃ main 10 this 6 -4
350-400 ms ┃ main 12 this 3 -9
400-450 ms ┃ main 3 this 0 -3
450-500 ms ┃ main 1 this 1 +0
550-600 ms ┃ main 1 this 0 -1
600-650 ms ┃ main 1 this 0 -1
650-700 ms ┃ main 4 this 0 -4
1000-1050 ms ┃ main 1 this 0 -1
📜 Previous results (1)

0161a14

Wed, 12 Aug 2026 17:06:24 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1262 (+79%) 🔻1448 🔴 (+44%) 🔻1489 🔴 (+47%) 🔻1737 🔴 (+15%) 🔻30
TTFSstream317 (-67%) 💚1381 🔴 (+40%) 🔻1410 🔴 (+40%) 🔻1505 🔴 (+43%) 🔻30
TTFShook + stream1618 (+33%) 🔻1718 🔴 (+33%) 🔻1786 🔴 (+33%) 🔻1875 🔴 (+16%) 🔻30
STSO1020 steps (inline)1311782034061019
WO1020 steps180588 (-53%) 💚180588 (-53%) 💚180588 (-53%) 💚180588 (-53%) 💚1
SLstream latency109 (+35%) 🔻139 🔴 (+5.3%)151 🔴 (+7.9%)210 🔴 (+17%) 🔻30
SOstream overhead (text)130 (+29%) 🔻238 (+32%) 🔻309 (+53%) 🔻420 (+68%) 🔻30
SOstream overhead (structured)127 (+28%) 🔻206 (+27%) 🔻238 (+22%) 🔻303 (+40%) 🔻30
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: is main, marks where this run lands, bridges the gap when this run has more samples in a bucket.

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) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

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) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

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.

@github-actions

github-actionsBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 Mint-ordered log — 6 fail of 41 total

log=mint-ordered · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted171.0mMISMATCH1
stale-read-equal-step-countscompleted141.0mMISMATCH1
step-vs-step-forkcompleted120msMISMATCH1
step-vs-step-fork-fencedcompleted120msMISMATCH1
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mMISMATCH1
in-flight-before-decision-countedcompleted201.0mok0
in-flight-after-decisionfailed142.0mMISMATCH1
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-mint.txt

🟢 Append-only log — 0 fail of 41 total

log=append-only · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted201.0mok0
stale-read-equal-step-countscompleted141.0mok0
step-vs-step-forkcompleted120msok0
step-vs-step-fork-fencedcompleted120msok0
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mok0
in-flight-before-decision-countedcompleted171.0mok0
in-flight-after-decisioncompleted192.0mok0
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-append-only.txt

@VaguelySerious

Copy link
Copy Markdown
MemberAuthor

CI status

E2E Required Check is red for exactly one reason, and it is not this PR.

UNIT_STATUS: failure
BUILD_STATUS: success
VERCEL_STATUS: success
LOCAL_DEV_STATUS: success
LOCAL_PROD_STATUS: success
POSTGRES_STATUS: success
WINDOWS_STATUS: success
unit (failure)

Unit Tests (windows-latest) times out in packages/world-local/src/storage.test.ts:1284, returns the complete preload when run_started is retried. That test writes 1000 sequential events and already carries an explicit 120_000 timeout.

It is red on main with the identical signature on the last four runs (31530061366, 31533009514, 31604115603, 31616799260) and passed on 08-10 (31446555530, 31431114276).

A marginal budget rather than a code regression. Whole-file duration on the Windows runner, same 245 tests either side of the boundary:

Runsrc/storage.test.tsResult
93641986199 (08-11 00:40, last green)143491mspass
94189757184 (this PR)154816msthis one test times out

8% apart. An added fs op per write would show a much bigger jump than that. Locally create is linear at ~0.95ms regardless of n (measured at n = 250 / 500 / 1000 / 2000), and the whole test runs in 1.17s on macOS.

Not fixing it here: this PR does not touch storage.test.ts, and the fix (parallelize the 1000 writes, or raise the budget with a stated reason) belongs on main rather than folded into a corruption fix. Flagging it because it blocks the required aggregate on every PR, not only this one.

Vercel – workbench-python-workflow also fails on main and every PR, and is not required.

Repro script, postgres lane

Checked that the --world refactor did not break the default path. Full build, container bring-up, migrations, harness: 13/14 completed, 0 CORRUPTED_EVENT_LOG, 1 hook-stormstuck, which is the documented local-runner artifact (one Next.js process holding every replay).

@pranaygppranaygp 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.

Reviewed with a focus on the testing story and on whether this closes all the slot-hole paths. The staging-nonce fix is correct and the crash-window regression guard is real — I verified 545/545 on the branch, tsc --noEmit clean, and that tests 1 and 2 fail deterministically with the fix reverted.

Three findings, detailed inline:

  1. A duplicate-resume window survives the fix (events-storage.ts): when the pinned adopter of a resume claim wins the promote link(2), the unpinned claim owner bumps and publishes a second hook_received for the same resumeId. Reproduced deterministically; repro in the inline comment. Not a CORRUPTED_EVENT_LOG (the log stays dense) — it's a violation of the dedup contract hook-resume-dedup.test.ts asserts, and it pre-exists this PR in a sibling interleaving.
  2. Test 3 is not a regression guard: it passes with the fix reverted (5/5 runs). Tests 1 and 2 do fail without the fix, so the PR description's claim is accurate for those two only.
  3. Minor: a nextjs app-name glob in the repro script doesn't match setupWorld's substring check, which could split-brain the data dir for future app names.

Also audited world-postgres for the analogous hole since it arbitrates slots differently: it doesn't have one. nextSlotId computes the position inside the INSERT itself (storage.ts:167) — nothing reserves a slot ahead of the write, so a rejected or crashed hook_received rolls back its transaction and leaves the numbering untouched; the terminal-race guard is a FOR UPDATE on the run row in the same transaction (storage.ts:1944). It also has no lazy resume-dedup path at all (no resumeId column, per the deliberate omission in drizzle/schema.ts:145), so the duplicate-resume window doesn't apply there either.

// read. Answer it the same way rather than reporting a conflict
// the caller cannot act on: the resume IS committed, exactly once,
// and the dedup contract is that both writers return that event.
if (eventIdPinned && data.eventType === 'hook_received') {

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.

This convergence covers the pinned loser, but the symmetric case is still open and produces a duplicate hook_received for one resumeId.

On a slot run the claim owner is unpinned (eventIdPinned = !isSlotEventId(eventId) up at the claim write), while a racing taker that adopts the claim is pinned. Both stage nonced files and race the promote at the claimed position S. If the adopter wins the link, the owner's promote returns 'exists', bumpEventSlot is allowed to move it (it isn't pinned), and the owner publishes a second hook_received for the same resume at S+1. Both callers return success; the log stays dense, so no CORRUPTED_EVENT_LOG — but replay now delivers one resume twice, which is exactly what the "collapses the two writers of ONE resume onto a single event" contract in hook-resume-dedup.test.ts forbids. (The claim rewrite after the owner's publish points redeliveries at the second event, so later converges mask it — but both events stay in the log and both replay.)

I reproduced this deterministically on this branch by parking the first promoteExclusive caller (the owner — it has a head start, since the adopter does a full findCommittedResumeEvent scan first) until the second caller has linked:

repro test (drop into src/storage/, uses a partial vi.mock of ../fs.js)
constgate={armed: false,released: false,firstRelease: nullas(()=>void)|null};vi.mock('../fs.js',async(importOriginal)=>{constactual=(awaitimportOriginal())asRecord<string,any>;return{
...actual,promoteExclusive: async(stagedPath: string,filePath: string)=>{if(gate.armed&&!gate.released){if(gate.firstRelease===null){awaitnewPromise<void>((r)=>{gate.firstRelease=r;});returnactual.promoteExclusive(stagedPath,filePath);}constresult=awaitactual.promoteExclusive(stagedPath,filePath);gate.released=true;gate.firstRelease?.();returnresult;}returnactual.promoteExclusive(stagedPath,filePath);},};});// setup: createRun + createHook, then:gate.armed=true;constresults=awaitPromise.allSettled([storage,createStorage(testDir)].map((inst)=>inst.events.create(runId,{eventType: 'hook_received',specVersion: SPEC_VERSION_CURRENT,correlationId: hook.hookId,eventData: {token: hook.token,payload: newUint8Array([1])},},{resumeId: 'resume_1',resumePayloadDigest: 'resume_1'})));

Result: zero rejections, and the log holds evnt_…003:hook_receivedandevnt_…004:hook_received, both with resumeId: 'resume_1'.

To be clear about provenance: this window is not introduced here — pre-PR, the same interleaving existed at the staging write (adopter stages the id-keyed path first → owner collides → bumps → duplicates). The nonce moves the collision from staging to promote, but the owner-loses arm is still resolved by bumping.

Suggested fix, symmetric with the one you added: run this occupant/isResumeEvent convergence for hook_received with a resumeIdbeforebumpEventSlot, regardless of pinning — if the occupant at the lost position is this same resume, return it instead of bumping. The pinned case then falls out of the same check, and an unrelated occupant still bumps (unpinned) or conflicts (pinned) as today.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Confirmed, and fixed in c6bf782.

I verified the asymmetry before changing anything: on a slot run the claim owner keeps its own id and eventIdPinned = !isSlotEventId(eventId) leaves it unpinned, while the adopter is pinned to the claimed position. So the loser of the promote can be the unpinned owner, it bumps, and a second hook_received lands for one resumeId.

The convergence now runs inside the publish loop, ahead of bumpEventSlot, for any hook_received carrying a resumeId, with no pinning condition. An occupant that is not this resume is the unrelated-event collision the bump exists for and still bumps, or conflicts when pinned. The post-loop pinned block is gone, since a resume that lost to its own committed event now returns from inside the loop, and reaching the end means the occupant is unrelated.

Regression guard: writes one event when the claim owner loses the position to an adopter. It mocks promoteExclusive to park whichever caller arrives first until the other has linked, which is the interleaving that decides the winner. Falsifiability check: restoring the eventIdPinned && guard makes it fail with expected [ { …(8) }, { …(8) } ] to have a length of 1 but got 2.

expect(data.filter((e) => e.eventType === 'hook_received')).toHaveLength(1);
});

it('keeps the log dense when two instances resume the same hook at once', async () => {

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.

This test passes without the fix — I reverted the two events-storage.ts hunks (keeping the tests) and ran this file five times: tests 1 and 2 failed deterministically every run, but this one passed 5/5.

That matches the old code's behavior for this shape: with two live stagers and no terminal transition, the bumped-off slot always gets backfilled — the loser of the id-keyed staging write bumps to N+1, but the staging winner still promotes N, so the log ends dense either way. The hole needs the stager to be rejected (terminal marker / reap) or crashed, which is what test 1 stages.

So the PR description's "Each regression guard was verified to fail with the fix removed" doesn't hold for this one. Two options:

  • make it a real guard by racing a terminal transition into the window (stager drawn and staged, then reaped, then a later writer draws the same slot), or
  • keep it as-is but reword the description/comment to position it as a sanity check that the new promote-point arbitration keeps density under live-vs-live contention (which it does usefully exercise — the 'exists' → bump path now only exists at the promote).

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

You're right, and thanks for actually running it rather than reading it. With two live stagers and no terminal transition the pre-fix code also ends dense, because the writer bumped off the position is the one that goes on to publish it. The hole needs the stager to be rejected or killed, which is what the crashed-attempt test stages.

Rather than contrive a guard here, I reframed it honestly and let the new owner/adopter test carry the weight. It is now keeps the log dense under live contention on one position, and the comment says outright that density here is not a regression guard, plus what it does cover: arbitrating at the promote still resolves two instances drawing the same position, which is the CLI-plus-app configuration this backend supports (each instance's allocator watermark is its own, so both hand out the same candidate).

Also corrected the PR description, which claimed all three tests fail with the fix removed. True for tests 1, 2, and the new one; not for this one.

# an absolute path so the app's cwd does not enter into it. `setupWorld`
# recomputes the same path for the harness process.
case "$APP_NAME" in
nextjs*|next-*) DATA_DIR_NAME=".next/workflow-data" ;;

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.

This prefix glob doesn't mirror the harness's derivation. setupWorld (packages/core/e2e/utils.ts:507) uses substring matching:

constisNextJs=appName.includes('nextjs')||appName.includes('next-');

so an app named e.g. example-nextjs would get .next/workflow-data from the harness but .workflow-data from this case — the exact silent split-brain the comment above warns about, where the app and the harness talk to different directories. Today it can't bite (only nextjs-turbopack/nextjs-webpack carry the repro fixtures, and both match), but since the whole point of this block is pinning the two processes to one path:

*nextjs*|*next-*) DATA_DIR_NAME=".next/workflow-data";;

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Good catch. Fixed to *nextjs*|*next-*) so it mirrors setupWorld. I checked packages/core/e2e/utils.ts:507 and it is the substring form you quoted, so an app named example-nextjs would have sent the app to .workflow-data and the harness to .next/workflow-data, which is exactly the split-brain the block exists to prevent. Comment above the case now states why it is a substring match.

if (await bumpEventSlot(attempt)) {
continue;
}
// A nonced path cannot already exist. Surfacing rather than

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.

Nit: the comment is right that this can't be a real duplicate — which makes EntityConflictError ("Event already exists") a slightly misleading surface for it. EntityConflictError is the shape the runtime's concurrent-replay paths treat as a benign duplicate publish, so a genuine filesystem fault here would get absorbed as "someone else already wrote it" instead of surfacing as infra trouble. A WorkflowWorldError would keep the impossible case loud. Fine to leave if you'd rather not grow the error surface, since the branch is effectively unreachable.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Taken. It is now a WorkflowWorldError. EntityConflictError is the shape the runtime reads as a benign duplicate publish, so raising it for a nonced path that cannot collide would absorb a filesystem fault as "someone else already wrote it". The comment says that.

Only one of the two takers of a resume claim is pinned. The taker that
writes the claim keeps its own id, unpinned, because a slot is a position
another instance also hands out for unrelated events and refusing to move
would fail the append outright. The taker that adopts an existing claim is
pinned to the claimed position.
So the loser of the promote can be the unpinned owner, and a loser that
bumps publishes a second `hook_received` for one resumeId. The log stays
dense and both callers report success, but replay delivers the resume
twice, which is what the dedup contract forbids.
Run the occupant convergence inside the publish loop, before the bump, for
any `hook_received` carrying a resumeId. An occupant that is not this
resume is the unrelated-event collision the bump is for and still bumps,
or conflicts when pinned. The post-loop pinned check is now redundant.
Also:
- staging-path faults raise WorkflowWorldError rather than
EntityConflictError, which the runtime absorbs as a benign duplicate.
- the repro script's app-name case mirrors `setupWorld`'s substring match
so a future `example-nextjs` cannot split-brain the data dir.
- the live-vs-live density test says what it actually guards: it passes
without the fix, because a bumped-off position still gets published by
the stager that won it.

@pranaygppranaygp 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.

All four findings addressed in c6bf782, and I re-verified each empirically on the branch:

  • Duplicate-resume window: the convergence now runs inside the publish loop before bumpEventSlot, ungated on pinning — exactly the symmetric fix. I confirmed the new writes one event when the claim owner loses the position to an adopter guard is real: with that hunk reverted it fails deterministically (length of 1 but got 2, the duplicate), and with it the file and the full suite pass (546/546, tsc --noEmit clean).
  • Density test: honestly reframed as not-a-regression-guard, and the PR description now matches what the tests actually prove.
  • Script glob mirrors setupWorld's substring match, with the reasoning in a comment.
  • Staging fault now raises WorkflowWorldError so infra trouble can't be absorbed as a benign duplicate.

One non-blocking observation, pre-existing and inherited rather than introduced: isResumeEvent matches an occupant with resumeId === undefined by position, so the in-loop convergence could in principle adopt a plain-pathhook_received (no resumeId) for the same hook as this resume's committed event, dropping the resume's payload. That requires concurrently mixing the lazy and plain resume paths on one hook plus a slot collision — outside the dedup contract's supported shape, and the same predicate already governs converge/findCommittedResumeEvent. Fine to leave; noting it in case slot-run positional matching ever gets tightened.

@VaguelySerious

Copy link
Copy Markdown
MemberAuthor

CI update: everything is green except Unit Tests (windows-latest), which fails at packages/world-local/src/storage.test.ts:1284 with Test timed out in 120000ms. Same signature on main's last runs, so it is not from this PR.

I opened #3503 to fix it on main. It is a marginal budget rather than a step regression: same 245 tests, whole-file duration on the Windows runner went 143491ms (last green) to 154816ms (first red), 8% apart. Since Unit Tests feeds E2E Required Check, that PR is what unblocks the required aggregate here.

@VaguelySerious
VaguelySerious merged commit 0f4b35f into mainAug 12, 2026
160 of 165 checks passed
@VaguelySerious
VaguelySerious deleted the peter/world-local-event-race branch August 12, 2026 20:16
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 0f4b35f (AI decision).

This is a genuine correctness fix, but it targets code that only exists on main: origin/stable's packages/world-local/src/storage/events-storage.ts has no pendingHookEventPath staging, no slot-based event ids, and no bumpEventSlot/notePublishedSlot allocator, so the CORRUPTED_EVENT_LOG hole and the duplicate-resume path being fixed cannot occur there. The remaining changes are also main-only or unmaintained on stablescripts/event-log-race-repro-local.sh is absent from origin/stable, and the AGENTS.md/new-test changes describe main-only harness and storage behavior.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

0f4b35f62945327417013060f6e5de5111fe6ff1

Sign up for freeto 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.

2 participants

@VaguelySerious@pranaygp
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [world-local] Fix CORRUPTED_EVENT_LOG from hook-resume staging slot holes by VaguelySerious · Pull Request #3492 · vercel/workflow · GitHub
Skip to content

[world-local] Fix CORRUPTED_EVENT_LOG from hook-resume staging slot holes - #3492

Merged
VaguelySerious merged 2 commits into
mainfrom
peter/world-local-event-race
Aug 12, 2026
Merged

[world-local] Fix CORRUPTED_EVENT_LOG from hook-resume staging slot holes#3492
VaguelySerious merged 2 commits into
mainfrom
peter/world-local-event-race

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented Aug 12, 2026

Copy link
Copy Markdown
Member

What

Three fixes in @workflow/world-local, plus the local event-log race repro script gains a --world local lane.

The corruption

hook_received is the one event that does not publish straight into events/. It stages under .locks first, so a terminal transition can reap it before it ever becomes reader-visible (#2987). That staging path was keyed by the event id alone, and a collision on it bumped the writer to the next slot.

Under slot event ids (#3389) that bump is wrong. The slot allocator probes events/ and nothing else, so a staged file is not evidence the position is taken. Bumping moves the writer past a position nothing will ever fill, notePublishedSlot advances the watermark past it, and scanRunEventIds is max-based so no later writer backfills. The runtime reads the missing position as a durable hole and fails the run with CORRUPTED_EVENT_LOG.

Two ways in:

  1. An attempt killed between staging and promoting leaves its staged file behind. Its cleanup lives in a finally the kill skips, and the only other reaper runs on a terminal transition the run has not reached. Every later writer that draws that slot bumps off it, permanently.
  2. Two live writers draw the same candidate and the stager is later rejected by the terminal marker or the reap. The loser bumped off a slot the stager never published.

Provenance: staging arrived in 850777a03b (#2987) when ids were ULIDs, where the old comment was correct that the path "can only be occupied by a previous crashed attempt of this very event". 6786db9953 (#3389) turned ids into positions and kept the id-keyed staging name.

Fix: the staging name carries a nonce, so it is private to one attempt. The slot is then arbitrated only where it is actually taken, at the promote. This restores the file's own stated invariant: a slot is claimed by the publish that occupies it, never reserved ahead of a write that might still be rejected.

The spurious conflict, and the duplicate resume

When a resume loses the publish, the event now at that position can be the same resume, written by the other taker of the claim. That is the convergence the claim exists to force, and converge earlier in the function already answers it with the committed event. It just could not see it yet, because the other taker had not published when this attempt read.

The first pass answered that only for a pinned loser, which left the symmetric case open (caught in review). Only one of the two takers of a claim is pinned: the taker that writes the claim keeps its own id, unpinned, because a slot is a position another instance also hands out for unrelated events and refusing to move would fail this resume's append outright. The taker that adopts an existing claim is pinned to the claimed position. So the loser of the promote can be the unpinned owner, and a loser that bumps publishes a second hook_received for one resumeId. Nothing looks wrong afterwards (the log stays dense, both callers report success) but the resume is delivered twice on replay.

Fix: run the occupant convergence inside the publish loop, ahead of the bump, for any hook_received carrying a resumeId, with no pinning condition. An occupant that is not this resume is the unrelated-event collision the bump is for and still bumps, or conflicts when pinned. Both takers return the one committed event, matching the dedup contract hook-resume-dedup.test.ts already asserts. Reporting EntityConflictError instead gave the caller an error it cannot act on for a resume that did land (HTTP 500, queue retry).

Separately, a failure to stage under the nonced path now raises WorkflowWorldError rather than EntityConflictError: a nonced path cannot collide, so it is a filesystem fault, and EntityConflictError is the shape the runtime absorbs as a benign duplicate publish.

Repro script

scripts/event-log-race-repro-local.sh was postgres-only. It now takes --world postgres|local (default postgres, unchanged). Under --world local it exports WORKFLOW_TARGET_WORLD=local plus an absolute WORKFLOW_LOCAL_DATA_DIR and WORKFLOW_LOCAL_QUEUE_CONCURRENCY, clears the data directory instead of the queue, and skips all container bring-up, migration, and teardown.

pnpm run test:e2e:event-log-race-repro:local --world local

Testing

New packages/world-local/src/storage/hook-staging-slots.test.ts, 4 tests. Three are regression guards verified to fail with the fix removed:

  • a crashed attempt's leftover staged file no longer holes the log. Before the fix this produced slots [1, 2, 4] instead of [1, 2, 3].
  • both writers of one raced resume get the committed event back rather than a conflict.
  • when the unpinned claim owner loses the position to an adopter, one hook_received is written and both takers return it. This one mocks promoteExclusive to park whichever caller reaches it first until the other has linked, since the outcome hinges on that interleaving. Restoring the eventIdPinned && guard makes it fail with to have a length of 1 but got 2.

The fourth, keeps the log dense under live contention on one position, is not a regression guard and its comment says so: with two live stagers and no terminal transition the pre-fix code also ended dense, because the writer it bumped off the position was the one that went on to publish it. It covers that arbitrating at the promote still resolves two instances drawing one position.

Full world-local suite: 546/546 across 16 files. tsc --noEmit clean.

On the storm harness, stated plainly: it has never produced a CORRUPTED_EVENT_LOG outcome or an on-disk hole against world-local, across one 14-run pass and two 16-run passes before the fix. The corruption is demonstrated by the unit test, not by the storm. What the storm does show is the conflict volume: ~12-27 EntityConflictError per pass before, 1 after (a hook_created benign duplicate, which is the documented path), with all 14 runs completing and every run dense on disk (count == max, 8931 event files checked).

This matches the note now in AGENTS.md: world-local's storms come out clean far more often than world-postgres's, so reach for a unit test in packages/world-local/src/storage/ when a suspected filesystem race can be staged directly.

`hook_received` is the only event that does not publish straight into
`events/`: it stages under `.locks` first so a terminal transition can
reap it before it becomes reader-visible. That staging path was keyed by
the event id alone, and a collision on it bumped the writer to the next
slot.
Under slot ids a staging collision is not evidence the position is
taken. The allocator probes `events/` only, so bumping moves the writer
past a position nothing will ever fill, and `scanRunEventIds` is
max-based so no later writer backfills it. The runtime reads the missing
position as a durable hole and fails the run with CORRUPTED_EVENT_LOG.
Two triggers: an attempt killed between staging and promoting leaves its
staged file behind (cleanup lives in a `finally` the kill skips, and the
only other reaper runs on a terminal transition), and two live writers
drawing the same candidate where the stager is later rejected.
Staging now carries a nonce, so it is private to one attempt and the
slot is arbitrated only where it is actually taken, at the promote.
Second fix: when a pinned resume loses the publish, the event at the
pinned position is that same resume written by the other taker, which is
the convergence the pin exists to force. Return that committed event
instead of an EntityConflictError the caller cannot act on.
Also re-purposes scripts/event-log-race-repro-local.sh to drive either
world with `--world postgres|local`.
@VaguelySerious
VaguelySerious requested a review from a team as a code ownerAugust 12, 2026 16:46
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

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

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewAug 12, 2026 7:53pm
example-nextjs-workflow-webpackReadyReadyPreviewAug 12, 2026 7:53pm
example-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-astro-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-express-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-fastify-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-hono-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-nestjs-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-nitro-workflowBuildingBuildingPreviewAug 12, 2026 7:53pm
workbench-nuxt-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-python-workflowErrorErrorAug 12, 2026 7:53pm
workbench-sveltekit-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-tanstack-start-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-vite-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workflow-docsReadyReadyPreview, v0Aug 12, 2026 7:53pm
workflow-swc-playgroundReadyReadyPreviewAug 12, 2026 7:53pm
workflow-tarballsReadyReadyPreviewAug 12, 2026 7:53pm
workflow-webReadyReadyPreviewAug 12, 2026 7:53pm

@changeset-bot

changeset-botBot commented Aug 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c6bf782

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

This PR includes changesets to release 18 packages
NameType
@workflow/world-localPatch
@workflow/cliPatch
@workflow/corePatch
@workflow/vitestPatch
@workflow/webPatch
@workflow/world-postgresPatch
workflowPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/web-sharedPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

@github-actions

github-actionsBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

❌ Failed E2E Tests

▲ Vercel Production (1 failed)

nextjs-turbopack-node (1 failed):

  • distributedAbortController - manual abort triggers signal | wrun_41KZVSGQDP0GJSR1W5GDTRB65H | 🔍 observability

E2E Test Summary

Summary
PassedFailedSkippedTotal
❌ ▲ Vercel Production331215873900
✅ 💻 Local Development381005584368
✅ 📦 Local Production381005584368
✅ 🐘 Local Postgres381005584368
✅ 🪟 Windows31200312
✅ vercel-multi-region270027
Total150811226117343
Details by Category

❌ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node128028
✅ astro-quickjs128028
✅ example-node128028
✅ example-quickjs128028
✅ express-node128028
✅ express-quickjs128028
✅ fastify-node128028
✅ fastify-quickjs128028
✅ hono-node128028
✅ hono-quickjs128028
✅ nest-node128028
✅ nest-quickjs128028
❌ nextjs-turbopack-node15213
✅ nextjs-webpack-node15303
✅ nextjs-webpack-quickjs15303
✅ nitro-node128028
✅ nitro-quickjs128028
✅ nuxt-node128028
✅ nuxt-quickjs128028
✅ sveltekit-node14709
✅ sveltekit-quickjs14709
✅ tanstack-start-node128028
✅ tanstack-start-quickjs128028
✅ vite-node128028
✅ vite-quickjs128028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack-node15600
✅ nextjs-turbopack-quickjs15600

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@github-actions

github-actionsBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit c6bf782 · Wed, 12 Aug 2026 20:08:57 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1361 (+513%) 🔻1553 🔴 (+39%) 🔻1591 🔴 (+36%) 🔻1914 🔴 (+9.6%)30
TTFSstream1359 (+427%) 🔻1519 🔴 (+37%) 🔻1595 🔴 (+42%) 🔻1621 🔴 (+5.7%)30
TTFShook + stream1593 (+331%) 🔻1805 🔴 (+32%) 🔻1866 🔴 (+30%) 🔻1973 🔴 (-57%) 💚30
STSO1020 steps (inline)133 (-2.9%)177 (-16%) 💚198 (-19%) 💚296 (-26%) 💚1019
WO1020 steps176010 (-14%)176010 (-14%)176010 (-14%)176010 (-14%)1
SLstream latency109 (+18%) 🔻154 🔴 (+4.8%)182 🔴 (+0.6%)3136 🔴 (+602%) 🔻30
SOstream overhead (text)128 (+4.9%)185 (-31%) 💚210 (-57%) 💚282 (-71%) 💚30
SOstream overhead (structured)121 (-2.4%)169 (-39%) 💚198 (-77%) 💚235 (-99%) 💚30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 204038ms → this run 174609ms (Δ -29429ms, -14%)

 100-150 ms █░░┃ main 11 this 137 +126
150-200 ms ████████████████████░░░┃ main 643 this 785 +142
200-250 ms █┃██████ main 277 this 69 -208
250-300 ms ┃█ main 55 this 18 -37
300-350 ms ┃ main 10 this 6 -4
350-400 ms ┃ main 12 this 3 -9
400-450 ms ┃ main 3 this 0 -3
450-500 ms ┃ main 1 this 1 +0
550-600 ms ┃ main 1 this 0 -1
600-650 ms ┃ main 1 this 0 -1
650-700 ms ┃ main 4 this 0 -4
1000-1050 ms ┃ main 1 this 0 -1
📜 Previous results (1)

0161a14

Wed, 12 Aug 2026 17:06:24 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1262 (+79%) 🔻1448 🔴 (+44%) 🔻1489 🔴 (+47%) 🔻1737 🔴 (+15%) 🔻30
TTFSstream317 (-67%) 💚1381 🔴 (+40%) 🔻1410 🔴 (+40%) 🔻1505 🔴 (+43%) 🔻30
TTFShook + stream1618 (+33%) 🔻1718 🔴 (+33%) 🔻1786 🔴 (+33%) 🔻1875 🔴 (+16%) 🔻30
STSO1020 steps (inline)1311782034061019
WO1020 steps180588 (-53%) 💚180588 (-53%) 💚180588 (-53%) 💚180588 (-53%) 💚1
SLstream latency109 (+35%) 🔻139 🔴 (+5.3%)151 🔴 (+7.9%)210 🔴 (+17%) 🔻30
SOstream overhead (text)130 (+29%) 🔻238 (+32%) 🔻309 (+53%) 🔻420 (+68%) 🔻30
SOstream overhead (structured)127 (+28%) 🔻206 (+27%) 🔻238 (+22%) 🔻303 (+40%) 🔻30
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: is main, marks where this run lands, bridges the gap when this run has more samples in a bucket.

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) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

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) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

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.

@github-actions

github-actionsBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 Mint-ordered log — 6 fail of 41 total

log=mint-ordered · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted171.0mMISMATCH1
stale-read-equal-step-countscompleted141.0mMISMATCH1
step-vs-step-forkcompleted120msMISMATCH1
step-vs-step-fork-fencedcompleted120msMISMATCH1
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mMISMATCH1
in-flight-before-decision-countedcompleted201.0mok0
in-flight-after-decisionfailed142.0mMISMATCH1
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-mint.txt

🟢 Append-only log — 0 fail of 41 total

log=append-only · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted201.0mok0
stale-read-equal-step-countscompleted141.0mok0
step-vs-step-forkcompleted120msok0
step-vs-step-fork-fencedcompleted120msok0
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mok0
in-flight-before-decision-countedcompleted171.0mok0
in-flight-after-decisioncompleted192.0mok0
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-append-only.txt

@VaguelySerious

Copy link
Copy Markdown
MemberAuthor

CI status

E2E Required Check is red for exactly one reason, and it is not this PR.

UNIT_STATUS: failure
BUILD_STATUS: success
VERCEL_STATUS: success
LOCAL_DEV_STATUS: success
LOCAL_PROD_STATUS: success
POSTGRES_STATUS: success
WINDOWS_STATUS: success
unit (failure)

Unit Tests (windows-latest) times out in packages/world-local/src/storage.test.ts:1284, returns the complete preload when run_started is retried. That test writes 1000 sequential events and already carries an explicit 120_000 timeout.

It is red on main with the identical signature on the last four runs (31530061366, 31533009514, 31604115603, 31616799260) and passed on 08-10 (31446555530, 31431114276).

A marginal budget rather than a code regression. Whole-file duration on the Windows runner, same 245 tests either side of the boundary:

Runsrc/storage.test.tsResult
93641986199 (08-11 00:40, last green)143491mspass
94189757184 (this PR)154816msthis one test times out

8% apart. An added fs op per write would show a much bigger jump than that. Locally create is linear at ~0.95ms regardless of n (measured at n = 250 / 500 / 1000 / 2000), and the whole test runs in 1.17s on macOS.

Not fixing it here: this PR does not touch storage.test.ts, and the fix (parallelize the 1000 writes, or raise the budget with a stated reason) belongs on main rather than folded into a corruption fix. Flagging it because it blocks the required aggregate on every PR, not only this one.

Vercel – workbench-python-workflow also fails on main and every PR, and is not required.

Repro script, postgres lane

Checked that the --world refactor did not break the default path. Full build, container bring-up, migrations, harness: 13/14 completed, 0 CORRUPTED_EVENT_LOG, 1 hook-stormstuck, which is the documented local-runner artifact (one Next.js process holding every replay).

@pranaygppranaygp 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.

Reviewed with a focus on the testing story and on whether this closes all the slot-hole paths. The staging-nonce fix is correct and the crash-window regression guard is real — I verified 545/545 on the branch, tsc --noEmit clean, and that tests 1 and 2 fail deterministically with the fix reverted.

Three findings, detailed inline:

  1. A duplicate-resume window survives the fix (events-storage.ts): when the pinned adopter of a resume claim wins the promote link(2), the unpinned claim owner bumps and publishes a second hook_received for the same resumeId. Reproduced deterministically; repro in the inline comment. Not a CORRUPTED_EVENT_LOG (the log stays dense) — it's a violation of the dedup contract hook-resume-dedup.test.ts asserts, and it pre-exists this PR in a sibling interleaving.
  2. Test 3 is not a regression guard: it passes with the fix reverted (5/5 runs). Tests 1 and 2 do fail without the fix, so the PR description's claim is accurate for those two only.
  3. Minor: a nextjs app-name glob in the repro script doesn't match setupWorld's substring check, which could split-brain the data dir for future app names.

Also audited world-postgres for the analogous hole since it arbitrates slots differently: it doesn't have one. nextSlotId computes the position inside the INSERT itself (storage.ts:167) — nothing reserves a slot ahead of the write, so a rejected or crashed hook_received rolls back its transaction and leaves the numbering untouched; the terminal-race guard is a FOR UPDATE on the run row in the same transaction (storage.ts:1944). It also has no lazy resume-dedup path at all (no resumeId column, per the deliberate omission in drizzle/schema.ts:145), so the duplicate-resume window doesn't apply there either.

// read. Answer it the same way rather than reporting a conflict
// the caller cannot act on: the resume IS committed, exactly once,
// and the dedup contract is that both writers return that event.
if (eventIdPinned && data.eventType === 'hook_received') {

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.

This convergence covers the pinned loser, but the symmetric case is still open and produces a duplicate hook_received for one resumeId.

On a slot run the claim owner is unpinned (eventIdPinned = !isSlotEventId(eventId) up at the claim write), while a racing taker that adopts the claim is pinned. Both stage nonced files and race the promote at the claimed position S. If the adopter wins the link, the owner's promote returns 'exists', bumpEventSlot is allowed to move it (it isn't pinned), and the owner publishes a second hook_received for the same resume at S+1. Both callers return success; the log stays dense, so no CORRUPTED_EVENT_LOG — but replay now delivers one resume twice, which is exactly what the "collapses the two writers of ONE resume onto a single event" contract in hook-resume-dedup.test.ts forbids. (The claim rewrite after the owner's publish points redeliveries at the second event, so later converges mask it — but both events stay in the log and both replay.)

I reproduced this deterministically on this branch by parking the first promoteExclusive caller (the owner — it has a head start, since the adopter does a full findCommittedResumeEvent scan first) until the second caller has linked:

repro test (drop into src/storage/, uses a partial vi.mock of ../fs.js)
constgate={armed: false,released: false,firstRelease: nullas(()=>void)|null};vi.mock('../fs.js',async(importOriginal)=>{constactual=(awaitimportOriginal())asRecord<string,any>;return{
...actual,promoteExclusive: async(stagedPath: string,filePath: string)=>{if(gate.armed&&!gate.released){if(gate.firstRelease===null){awaitnewPromise<void>((r)=>{gate.firstRelease=r;});returnactual.promoteExclusive(stagedPath,filePath);}constresult=awaitactual.promoteExclusive(stagedPath,filePath);gate.released=true;gate.firstRelease?.();returnresult;}returnactual.promoteExclusive(stagedPath,filePath);},};});// setup: createRun + createHook, then:gate.armed=true;constresults=awaitPromise.allSettled([storage,createStorage(testDir)].map((inst)=>inst.events.create(runId,{eventType: 'hook_received',specVersion: SPEC_VERSION_CURRENT,correlationId: hook.hookId,eventData: {token: hook.token,payload: newUint8Array([1])},},{resumeId: 'resume_1',resumePayloadDigest: 'resume_1'})));

Result: zero rejections, and the log holds evnt_…003:hook_receivedandevnt_…004:hook_received, both with resumeId: 'resume_1'.

To be clear about provenance: this window is not introduced here — pre-PR, the same interleaving existed at the staging write (adopter stages the id-keyed path first → owner collides → bumps → duplicates). The nonce moves the collision from staging to promote, but the owner-loses arm is still resolved by bumping.

Suggested fix, symmetric with the one you added: run this occupant/isResumeEvent convergence for hook_received with a resumeIdbeforebumpEventSlot, regardless of pinning — if the occupant at the lost position is this same resume, return it instead of bumping. The pinned case then falls out of the same check, and an unrelated occupant still bumps (unpinned) or conflicts (pinned) as today.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Confirmed, and fixed in c6bf782.

I verified the asymmetry before changing anything: on a slot run the claim owner keeps its own id and eventIdPinned = !isSlotEventId(eventId) leaves it unpinned, while the adopter is pinned to the claimed position. So the loser of the promote can be the unpinned owner, it bumps, and a second hook_received lands for one resumeId.

The convergence now runs inside the publish loop, ahead of bumpEventSlot, for any hook_received carrying a resumeId, with no pinning condition. An occupant that is not this resume is the unrelated-event collision the bump exists for and still bumps, or conflicts when pinned. The post-loop pinned block is gone, since a resume that lost to its own committed event now returns from inside the loop, and reaching the end means the occupant is unrelated.

Regression guard: writes one event when the claim owner loses the position to an adopter. It mocks promoteExclusive to park whichever caller arrives first until the other has linked, which is the interleaving that decides the winner. Falsifiability check: restoring the eventIdPinned && guard makes it fail with expected [ { …(8) }, { …(8) } ] to have a length of 1 but got 2.

expect(data.filter((e) => e.eventType === 'hook_received')).toHaveLength(1);
});

it('keeps the log dense when two instances resume the same hook at once', async () => {

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.

This test passes without the fix — I reverted the two events-storage.ts hunks (keeping the tests) and ran this file five times: tests 1 and 2 failed deterministically every run, but this one passed 5/5.

That matches the old code's behavior for this shape: with two live stagers and no terminal transition, the bumped-off slot always gets backfilled — the loser of the id-keyed staging write bumps to N+1, but the staging winner still promotes N, so the log ends dense either way. The hole needs the stager to be rejected (terminal marker / reap) or crashed, which is what test 1 stages.

So the PR description's "Each regression guard was verified to fail with the fix removed" doesn't hold for this one. Two options:

  • make it a real guard by racing a terminal transition into the window (stager drawn and staged, then reaped, then a later writer draws the same slot), or
  • keep it as-is but reword the description/comment to position it as a sanity check that the new promote-point arbitration keeps density under live-vs-live contention (which it does usefully exercise — the 'exists' → bump path now only exists at the promote).

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

You're right, and thanks for actually running it rather than reading it. With two live stagers and no terminal transition the pre-fix code also ends dense, because the writer bumped off the position is the one that goes on to publish it. The hole needs the stager to be rejected or killed, which is what the crashed-attempt test stages.

Rather than contrive a guard here, I reframed it honestly and let the new owner/adopter test carry the weight. It is now keeps the log dense under live contention on one position, and the comment says outright that density here is not a regression guard, plus what it does cover: arbitrating at the promote still resolves two instances drawing the same position, which is the CLI-plus-app configuration this backend supports (each instance's allocator watermark is its own, so both hand out the same candidate).

Also corrected the PR description, which claimed all three tests fail with the fix removed. True for tests 1, 2, and the new one; not for this one.

# an absolute path so the app's cwd does not enter into it. `setupWorld`
# recomputes the same path for the harness process.
case "$APP_NAME" in
nextjs*|next-*) DATA_DIR_NAME=".next/workflow-data" ;;

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.

This prefix glob doesn't mirror the harness's derivation. setupWorld (packages/core/e2e/utils.ts:507) uses substring matching:

constisNextJs=appName.includes('nextjs')||appName.includes('next-');

so an app named e.g. example-nextjs would get .next/workflow-data from the harness but .workflow-data from this case — the exact silent split-brain the comment above warns about, where the app and the harness talk to different directories. Today it can't bite (only nextjs-turbopack/nextjs-webpack carry the repro fixtures, and both match), but since the whole point of this block is pinning the two processes to one path:

*nextjs*|*next-*) DATA_DIR_NAME=".next/workflow-data";;

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Good catch. Fixed to *nextjs*|*next-*) so it mirrors setupWorld. I checked packages/core/e2e/utils.ts:507 and it is the substring form you quoted, so an app named example-nextjs would have sent the app to .workflow-data and the harness to .next/workflow-data, which is exactly the split-brain the block exists to prevent. Comment above the case now states why it is a substring match.

if (await bumpEventSlot(attempt)) {
continue;
}
// A nonced path cannot already exist. Surfacing rather than

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.

Nit: the comment is right that this can't be a real duplicate — which makes EntityConflictError ("Event already exists") a slightly misleading surface for it. EntityConflictError is the shape the runtime's concurrent-replay paths treat as a benign duplicate publish, so a genuine filesystem fault here would get absorbed as "someone else already wrote it" instead of surfacing as infra trouble. A WorkflowWorldError would keep the impossible case loud. Fine to leave if you'd rather not grow the error surface, since the branch is effectively unreachable.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Taken. It is now a WorkflowWorldError. EntityConflictError is the shape the runtime reads as a benign duplicate publish, so raising it for a nonced path that cannot collide would absorb a filesystem fault as "someone else already wrote it". The comment says that.

Only one of the two takers of a resume claim is pinned. The taker that
writes the claim keeps its own id, unpinned, because a slot is a position
another instance also hands out for unrelated events and refusing to move
would fail the append outright. The taker that adopts an existing claim is
pinned to the claimed position.
So the loser of the promote can be the unpinned owner, and a loser that
bumps publishes a second `hook_received` for one resumeId. The log stays
dense and both callers report success, but replay delivers the resume
twice, which is what the dedup contract forbids.
Run the occupant convergence inside the publish loop, before the bump, for
any `hook_received` carrying a resumeId. An occupant that is not this
resume is the unrelated-event collision the bump is for and still bumps,
or conflicts when pinned. The post-loop pinned check is now redundant.
Also:
- staging-path faults raise WorkflowWorldError rather than
EntityConflictError, which the runtime absorbs as a benign duplicate.
- the repro script's app-name case mirrors `setupWorld`'s substring match
so a future `example-nextjs` cannot split-brain the data dir.
- the live-vs-live density test says what it actually guards: it passes
without the fix, because a bumped-off position still gets published by
the stager that won it.

@pranaygppranaygp 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.

All four findings addressed in c6bf782, and I re-verified each empirically on the branch:

  • Duplicate-resume window: the convergence now runs inside the publish loop before bumpEventSlot, ungated on pinning — exactly the symmetric fix. I confirmed the new writes one event when the claim owner loses the position to an adopter guard is real: with that hunk reverted it fails deterministically (length of 1 but got 2, the duplicate), and with it the file and the full suite pass (546/546, tsc --noEmit clean).
  • Density test: honestly reframed as not-a-regression-guard, and the PR description now matches what the tests actually prove.
  • Script glob mirrors setupWorld's substring match, with the reasoning in a comment.
  • Staging fault now raises WorkflowWorldError so infra trouble can't be absorbed as a benign duplicate.

One non-blocking observation, pre-existing and inherited rather than introduced: isResumeEvent matches an occupant with resumeId === undefined by position, so the in-loop convergence could in principle adopt a plain-pathhook_received (no resumeId) for the same hook as this resume's committed event, dropping the resume's payload. That requires concurrently mixing the lazy and plain resume paths on one hook plus a slot collision — outside the dedup contract's supported shape, and the same predicate already governs converge/findCommittedResumeEvent. Fine to leave; noting it in case slot-run positional matching ever gets tightened.

@VaguelySerious

Copy link
Copy Markdown
MemberAuthor

CI update: everything is green except Unit Tests (windows-latest), which fails at packages/world-local/src/storage.test.ts:1284 with Test timed out in 120000ms. Same signature on main's last runs, so it is not from this PR.

I opened #3503 to fix it on main. It is a marginal budget rather than a step regression: same 245 tests, whole-file duration on the Windows runner went 143491ms (last green) to 154816ms (first red), 8% apart. Since Unit Tests feeds E2E Required Check, that PR is what unblocks the required aggregate here.

@VaguelySerious
VaguelySerious merged commit 0f4b35f into mainAug 12, 2026
160 of 165 checks passed
@VaguelySerious
VaguelySerious deleted the peter/world-local-event-race branch August 12, 2026 20:16
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 0f4b35f (AI decision).

This is a genuine correctness fix, but it targets code that only exists on main: origin/stable's packages/world-local/src/storage/events-storage.ts has no pendingHookEventPath staging, no slot-based event ids, and no bumpEventSlot/notePublishedSlot allocator, so the CORRUPTED_EVENT_LOG hole and the duplicate-resume path being fixed cannot occur there. The remaining changes are also main-only or unmaintained on stablescripts/event-log-race-repro-local.sh is absent from origin/stable, and the AGENTS.md/new-test changes describe main-only harness and storage behavior.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

0f4b35f62945327417013060f6e5de5111fe6ff1

Sign up for freeto 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.

2 participants

@VaguelySerious@pranaygp
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' [world-local] Fix CORRUPTED_EVENT_LOG from hook-resume staging slot holes by VaguelySerious · Pull Request #3492 · vercel/workflow · GitHub
Skip to content

[world-local] Fix CORRUPTED_EVENT_LOG from hook-resume staging slot holes - #3492

Merged
VaguelySerious merged 2 commits into
mainfrom
peter/world-local-event-race
Aug 12, 2026
Merged

[world-local] Fix CORRUPTED_EVENT_LOG from hook-resume staging slot holes#3492
VaguelySerious merged 2 commits into
mainfrom
peter/world-local-event-race

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented Aug 12, 2026

Copy link
Copy Markdown
Member

What

Three fixes in @workflow/world-local, plus the local event-log race repro script gains a --world local lane.

The corruption

hook_received is the one event that does not publish straight into events/. It stages under .locks first, so a terminal transition can reap it before it ever becomes reader-visible (#2987). That staging path was keyed by the event id alone, and a collision on it bumped the writer to the next slot.

Under slot event ids (#3389) that bump is wrong. The slot allocator probes events/ and nothing else, so a staged file is not evidence the position is taken. Bumping moves the writer past a position nothing will ever fill, notePublishedSlot advances the watermark past it, and scanRunEventIds is max-based so no later writer backfills. The runtime reads the missing position as a durable hole and fails the run with CORRUPTED_EVENT_LOG.

Two ways in:

  1. An attempt killed between staging and promoting leaves its staged file behind. Its cleanup lives in a finally the kill skips, and the only other reaper runs on a terminal transition the run has not reached. Every later writer that draws that slot bumps off it, permanently.
  2. Two live writers draw the same candidate and the stager is later rejected by the terminal marker or the reap. The loser bumped off a slot the stager never published.

Provenance: staging arrived in 850777a03b (#2987) when ids were ULIDs, where the old comment was correct that the path "can only be occupied by a previous crashed attempt of this very event". 6786db9953 (#3389) turned ids into positions and kept the id-keyed staging name.

Fix: the staging name carries a nonce, so it is private to one attempt. The slot is then arbitrated only where it is actually taken, at the promote. This restores the file's own stated invariant: a slot is claimed by the publish that occupies it, never reserved ahead of a write that might still be rejected.

The spurious conflict, and the duplicate resume

When a resume loses the publish, the event now at that position can be the same resume, written by the other taker of the claim. That is the convergence the claim exists to force, and converge earlier in the function already answers it with the committed event. It just could not see it yet, because the other taker had not published when this attempt read.

The first pass answered that only for a pinned loser, which left the symmetric case open (caught in review). Only one of the two takers of a claim is pinned: the taker that writes the claim keeps its own id, unpinned, because a slot is a position another instance also hands out for unrelated events and refusing to move would fail this resume's append outright. The taker that adopts an existing claim is pinned to the claimed position. So the loser of the promote can be the unpinned owner, and a loser that bumps publishes a second hook_received for one resumeId. Nothing looks wrong afterwards (the log stays dense, both callers report success) but the resume is delivered twice on replay.

Fix: run the occupant convergence inside the publish loop, ahead of the bump, for any hook_received carrying a resumeId, with no pinning condition. An occupant that is not this resume is the unrelated-event collision the bump is for and still bumps, or conflicts when pinned. Both takers return the one committed event, matching the dedup contract hook-resume-dedup.test.ts already asserts. Reporting EntityConflictError instead gave the caller an error it cannot act on for a resume that did land (HTTP 500, queue retry).

Separately, a failure to stage under the nonced path now raises WorkflowWorldError rather than EntityConflictError: a nonced path cannot collide, so it is a filesystem fault, and EntityConflictError is the shape the runtime absorbs as a benign duplicate publish.

Repro script

scripts/event-log-race-repro-local.sh was postgres-only. It now takes --world postgres|local (default postgres, unchanged). Under --world local it exports WORKFLOW_TARGET_WORLD=local plus an absolute WORKFLOW_LOCAL_DATA_DIR and WORKFLOW_LOCAL_QUEUE_CONCURRENCY, clears the data directory instead of the queue, and skips all container bring-up, migration, and teardown.

pnpm run test:e2e:event-log-race-repro:local --world local

Testing

New packages/world-local/src/storage/hook-staging-slots.test.ts, 4 tests. Three are regression guards verified to fail with the fix removed:

  • a crashed attempt's leftover staged file no longer holes the log. Before the fix this produced slots [1, 2, 4] instead of [1, 2, 3].
  • both writers of one raced resume get the committed event back rather than a conflict.
  • when the unpinned claim owner loses the position to an adopter, one hook_received is written and both takers return it. This one mocks promoteExclusive to park whichever caller reaches it first until the other has linked, since the outcome hinges on that interleaving. Restoring the eventIdPinned && guard makes it fail with to have a length of 1 but got 2.

The fourth, keeps the log dense under live contention on one position, is not a regression guard and its comment says so: with two live stagers and no terminal transition the pre-fix code also ended dense, because the writer it bumped off the position was the one that went on to publish it. It covers that arbitrating at the promote still resolves two instances drawing one position.

Full world-local suite: 546/546 across 16 files. tsc --noEmit clean.

On the storm harness, stated plainly: it has never produced a CORRUPTED_EVENT_LOG outcome or an on-disk hole against world-local, across one 14-run pass and two 16-run passes before the fix. The corruption is demonstrated by the unit test, not by the storm. What the storm does show is the conflict volume: ~12-27 EntityConflictError per pass before, 1 after (a hook_created benign duplicate, which is the documented path), with all 14 runs completing and every run dense on disk (count == max, 8931 event files checked).

This matches the note now in AGENTS.md: world-local's storms come out clean far more often than world-postgres's, so reach for a unit test in packages/world-local/src/storage/ when a suspected filesystem race can be staged directly.

`hook_received` is the only event that does not publish straight into
`events/`: it stages under `.locks` first so a terminal transition can
reap it before it becomes reader-visible. That staging path was keyed by
the event id alone, and a collision on it bumped the writer to the next
slot.
Under slot ids a staging collision is not evidence the position is
taken. The allocator probes `events/` only, so bumping moves the writer
past a position nothing will ever fill, and `scanRunEventIds` is
max-based so no later writer backfills it. The runtime reads the missing
position as a durable hole and fails the run with CORRUPTED_EVENT_LOG.
Two triggers: an attempt killed between staging and promoting leaves its
staged file behind (cleanup lives in a `finally` the kill skips, and the
only other reaper runs on a terminal transition), and two live writers
drawing the same candidate where the stager is later rejected.
Staging now carries a nonce, so it is private to one attempt and the
slot is arbitrated only where it is actually taken, at the promote.
Second fix: when a pinned resume loses the publish, the event at the
pinned position is that same resume written by the other taker, which is
the convergence the pin exists to force. Return that committed event
instead of an EntityConflictError the caller cannot act on.
Also re-purposes scripts/event-log-race-repro-local.sh to drive either
world with `--world postgres|local`.
@VaguelySerious
VaguelySerious requested a review from a team as a code ownerAugust 12, 2026 16:46
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

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

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewAug 12, 2026 7:53pm
example-nextjs-workflow-webpackReadyReadyPreviewAug 12, 2026 7:53pm
example-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-astro-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-express-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-fastify-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-hono-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-nestjs-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-nitro-workflowBuildingBuildingPreviewAug 12, 2026 7:53pm
workbench-nuxt-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-python-workflowErrorErrorAug 12, 2026 7:53pm
workbench-sveltekit-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-tanstack-start-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-vite-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workflow-docsReadyReadyPreview, v0Aug 12, 2026 7:53pm
workflow-swc-playgroundReadyReadyPreviewAug 12, 2026 7:53pm
workflow-tarballsReadyReadyPreviewAug 12, 2026 7:53pm
workflow-webReadyReadyPreviewAug 12, 2026 7:53pm

@changeset-bot

changeset-botBot commented Aug 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c6bf782

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

This PR includes changesets to release 18 packages
NameType
@workflow/world-localPatch
@workflow/cliPatch
@workflow/corePatch
@workflow/vitestPatch
@workflow/webPatch
@workflow/world-postgresPatch
workflowPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/web-sharedPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

@github-actions

github-actionsBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

❌ Failed E2E Tests

▲ Vercel Production (1 failed)

nextjs-turbopack-node (1 failed):

  • distributedAbortController - manual abort triggers signal | wrun_41KZVSGQDP0GJSR1W5GDTRB65H | 🔍 observability

E2E Test Summary

Summary
PassedFailedSkippedTotal
❌ ▲ Vercel Production331215873900
✅ 💻 Local Development381005584368
✅ 📦 Local Production381005584368
✅ 🐘 Local Postgres381005584368
✅ 🪟 Windows31200312
✅ vercel-multi-region270027
Total150811226117343
Details by Category

❌ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node128028
✅ astro-quickjs128028
✅ example-node128028
✅ example-quickjs128028
✅ express-node128028
✅ express-quickjs128028
✅ fastify-node128028
✅ fastify-quickjs128028
✅ hono-node128028
✅ hono-quickjs128028
✅ nest-node128028
✅ nest-quickjs128028
❌ nextjs-turbopack-node15213
✅ nextjs-webpack-node15303
✅ nextjs-webpack-quickjs15303
✅ nitro-node128028
✅ nitro-quickjs128028
✅ nuxt-node128028
✅ nuxt-quickjs128028
✅ sveltekit-node14709
✅ sveltekit-quickjs14709
✅ tanstack-start-node128028
✅ tanstack-start-quickjs128028
✅ vite-node128028
✅ vite-quickjs128028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack-node15600
✅ nextjs-turbopack-quickjs15600

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@github-actions

github-actionsBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit c6bf782 · Wed, 12 Aug 2026 20:08:57 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1361 (+513%) 🔻1553 🔴 (+39%) 🔻1591 🔴 (+36%) 🔻1914 🔴 (+9.6%)30
TTFSstream1359 (+427%) 🔻1519 🔴 (+37%) 🔻1595 🔴 (+42%) 🔻1621 🔴 (+5.7%)30
TTFShook + stream1593 (+331%) 🔻1805 🔴 (+32%) 🔻1866 🔴 (+30%) 🔻1973 🔴 (-57%) 💚30
STSO1020 steps (inline)133 (-2.9%)177 (-16%) 💚198 (-19%) 💚296 (-26%) 💚1019
WO1020 steps176010 (-14%)176010 (-14%)176010 (-14%)176010 (-14%)1
SLstream latency109 (+18%) 🔻154 🔴 (+4.8%)182 🔴 (+0.6%)3136 🔴 (+602%) 🔻30
SOstream overhead (text)128 (+4.9%)185 (-31%) 💚210 (-57%) 💚282 (-71%) 💚30
SOstream overhead (structured)121 (-2.4%)169 (-39%) 💚198 (-77%) 💚235 (-99%) 💚30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 204038ms → this run 174609ms (Δ -29429ms, -14%)

 100-150 ms █░░┃ main 11 this 137 +126
150-200 ms ████████████████████░░░┃ main 643 this 785 +142
200-250 ms █┃██████ main 277 this 69 -208
250-300 ms ┃█ main 55 this 18 -37
300-350 ms ┃ main 10 this 6 -4
350-400 ms ┃ main 12 this 3 -9
400-450 ms ┃ main 3 this 0 -3
450-500 ms ┃ main 1 this 1 +0
550-600 ms ┃ main 1 this 0 -1
600-650 ms ┃ main 1 this 0 -1
650-700 ms ┃ main 4 this 0 -4
1000-1050 ms ┃ main 1 this 0 -1
📜 Previous results (1)

0161a14

Wed, 12 Aug 2026 17:06:24 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1262 (+79%) 🔻1448 🔴 (+44%) 🔻1489 🔴 (+47%) 🔻1737 🔴 (+15%) 🔻30
TTFSstream317 (-67%) 💚1381 🔴 (+40%) 🔻1410 🔴 (+40%) 🔻1505 🔴 (+43%) 🔻30
TTFShook + stream1618 (+33%) 🔻1718 🔴 (+33%) 🔻1786 🔴 (+33%) 🔻1875 🔴 (+16%) 🔻30
STSO1020 steps (inline)1311782034061019
WO1020 steps180588 (-53%) 💚180588 (-53%) 💚180588 (-53%) 💚180588 (-53%) 💚1
SLstream latency109 (+35%) 🔻139 🔴 (+5.3%)151 🔴 (+7.9%)210 🔴 (+17%) 🔻30
SOstream overhead (text)130 (+29%) 🔻238 (+32%) 🔻309 (+53%) 🔻420 (+68%) 🔻30
SOstream overhead (structured)127 (+28%) 🔻206 (+27%) 🔻238 (+22%) 🔻303 (+40%) 🔻30
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: is main, marks where this run lands, bridges the gap when this run has more samples in a bucket.

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) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

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) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

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.

@github-actions

github-actionsBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 Mint-ordered log — 6 fail of 41 total

log=mint-ordered · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted171.0mMISMATCH1
stale-read-equal-step-countscompleted141.0mMISMATCH1
step-vs-step-forkcompleted120msMISMATCH1
step-vs-step-fork-fencedcompleted120msMISMATCH1
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mMISMATCH1
in-flight-before-decision-countedcompleted201.0mok0
in-flight-after-decisionfailed142.0mMISMATCH1
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-mint.txt

🟢 Append-only log — 0 fail of 41 total

log=append-only · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted201.0mok0
stale-read-equal-step-countscompleted141.0mok0
step-vs-step-forkcompleted120msok0
step-vs-step-fork-fencedcompleted120msok0
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mok0
in-flight-before-decision-countedcompleted171.0mok0
in-flight-after-decisioncompleted192.0mok0
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-append-only.txt

@VaguelySerious

Copy link
Copy Markdown
MemberAuthor

CI status

E2E Required Check is red for exactly one reason, and it is not this PR.

UNIT_STATUS: failure
BUILD_STATUS: success
VERCEL_STATUS: success
LOCAL_DEV_STATUS: success
LOCAL_PROD_STATUS: success
POSTGRES_STATUS: success
WINDOWS_STATUS: success
unit (failure)

Unit Tests (windows-latest) times out in packages/world-local/src/storage.test.ts:1284, returns the complete preload when run_started is retried. That test writes 1000 sequential events and already carries an explicit 120_000 timeout.

It is red on main with the identical signature on the last four runs (31530061366, 31533009514, 31604115603, 31616799260) and passed on 08-10 (31446555530, 31431114276).

A marginal budget rather than a code regression. Whole-file duration on the Windows runner, same 245 tests either side of the boundary:

Runsrc/storage.test.tsResult
93641986199 (08-11 00:40, last green)143491mspass
94189757184 (this PR)154816msthis one test times out

8% apart. An added fs op per write would show a much bigger jump than that. Locally create is linear at ~0.95ms regardless of n (measured at n = 250 / 500 / 1000 / 2000), and the whole test runs in 1.17s on macOS.

Not fixing it here: this PR does not touch storage.test.ts, and the fix (parallelize the 1000 writes, or raise the budget with a stated reason) belongs on main rather than folded into a corruption fix. Flagging it because it blocks the required aggregate on every PR, not only this one.

Vercel – workbench-python-workflow also fails on main and every PR, and is not required.

Repro script, postgres lane

Checked that the --world refactor did not break the default path. Full build, container bring-up, migrations, harness: 13/14 completed, 0 CORRUPTED_EVENT_LOG, 1 hook-stormstuck, which is the documented local-runner artifact (one Next.js process holding every replay).

@pranaygppranaygp 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.

Reviewed with a focus on the testing story and on whether this closes all the slot-hole paths. The staging-nonce fix is correct and the crash-window regression guard is real — I verified 545/545 on the branch, tsc --noEmit clean, and that tests 1 and 2 fail deterministically with the fix reverted.

Three findings, detailed inline:

  1. A duplicate-resume window survives the fix (events-storage.ts): when the pinned adopter of a resume claim wins the promote link(2), the unpinned claim owner bumps and publishes a second hook_received for the same resumeId. Reproduced deterministically; repro in the inline comment. Not a CORRUPTED_EVENT_LOG (the log stays dense) — it's a violation of the dedup contract hook-resume-dedup.test.ts asserts, and it pre-exists this PR in a sibling interleaving.
  2. Test 3 is not a regression guard: it passes with the fix reverted (5/5 runs). Tests 1 and 2 do fail without the fix, so the PR description's claim is accurate for those two only.
  3. Minor: a nextjs app-name glob in the repro script doesn't match setupWorld's substring check, which could split-brain the data dir for future app names.

Also audited world-postgres for the analogous hole since it arbitrates slots differently: it doesn't have one. nextSlotId computes the position inside the INSERT itself (storage.ts:167) — nothing reserves a slot ahead of the write, so a rejected or crashed hook_received rolls back its transaction and leaves the numbering untouched; the terminal-race guard is a FOR UPDATE on the run row in the same transaction (storage.ts:1944). It also has no lazy resume-dedup path at all (no resumeId column, per the deliberate omission in drizzle/schema.ts:145), so the duplicate-resume window doesn't apply there either.

// read. Answer it the same way rather than reporting a conflict
// the caller cannot act on: the resume IS committed, exactly once,
// and the dedup contract is that both writers return that event.
if (eventIdPinned && data.eventType === 'hook_received') {

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.

This convergence covers the pinned loser, but the symmetric case is still open and produces a duplicate hook_received for one resumeId.

On a slot run the claim owner is unpinned (eventIdPinned = !isSlotEventId(eventId) up at the claim write), while a racing taker that adopts the claim is pinned. Both stage nonced files and race the promote at the claimed position S. If the adopter wins the link, the owner's promote returns 'exists', bumpEventSlot is allowed to move it (it isn't pinned), and the owner publishes a second hook_received for the same resume at S+1. Both callers return success; the log stays dense, so no CORRUPTED_EVENT_LOG — but replay now delivers one resume twice, which is exactly what the "collapses the two writers of ONE resume onto a single event" contract in hook-resume-dedup.test.ts forbids. (The claim rewrite after the owner's publish points redeliveries at the second event, so later converges mask it — but both events stay in the log and both replay.)

I reproduced this deterministically on this branch by parking the first promoteExclusive caller (the owner — it has a head start, since the adopter does a full findCommittedResumeEvent scan first) until the second caller has linked:

repro test (drop into src/storage/, uses a partial vi.mock of ../fs.js)
constgate={armed: false,released: false,firstRelease: nullas(()=>void)|null};vi.mock('../fs.js',async(importOriginal)=>{constactual=(awaitimportOriginal())asRecord<string,any>;return{
...actual,promoteExclusive: async(stagedPath: string,filePath: string)=>{if(gate.armed&&!gate.released){if(gate.firstRelease===null){awaitnewPromise<void>((r)=>{gate.firstRelease=r;});returnactual.promoteExclusive(stagedPath,filePath);}constresult=awaitactual.promoteExclusive(stagedPath,filePath);gate.released=true;gate.firstRelease?.();returnresult;}returnactual.promoteExclusive(stagedPath,filePath);},};});// setup: createRun + createHook, then:gate.armed=true;constresults=awaitPromise.allSettled([storage,createStorage(testDir)].map((inst)=>inst.events.create(runId,{eventType: 'hook_received',specVersion: SPEC_VERSION_CURRENT,correlationId: hook.hookId,eventData: {token: hook.token,payload: newUint8Array([1])},},{resumeId: 'resume_1',resumePayloadDigest: 'resume_1'})));

Result: zero rejections, and the log holds evnt_…003:hook_receivedandevnt_…004:hook_received, both with resumeId: 'resume_1'.

To be clear about provenance: this window is not introduced here — pre-PR, the same interleaving existed at the staging write (adopter stages the id-keyed path first → owner collides → bumps → duplicates). The nonce moves the collision from staging to promote, but the owner-loses arm is still resolved by bumping.

Suggested fix, symmetric with the one you added: run this occupant/isResumeEvent convergence for hook_received with a resumeIdbeforebumpEventSlot, regardless of pinning — if the occupant at the lost position is this same resume, return it instead of bumping. The pinned case then falls out of the same check, and an unrelated occupant still bumps (unpinned) or conflicts (pinned) as today.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Confirmed, and fixed in c6bf782.

I verified the asymmetry before changing anything: on a slot run the claim owner keeps its own id and eventIdPinned = !isSlotEventId(eventId) leaves it unpinned, while the adopter is pinned to the claimed position. So the loser of the promote can be the unpinned owner, it bumps, and a second hook_received lands for one resumeId.

The convergence now runs inside the publish loop, ahead of bumpEventSlot, for any hook_received carrying a resumeId, with no pinning condition. An occupant that is not this resume is the unrelated-event collision the bump exists for and still bumps, or conflicts when pinned. The post-loop pinned block is gone, since a resume that lost to its own committed event now returns from inside the loop, and reaching the end means the occupant is unrelated.

Regression guard: writes one event when the claim owner loses the position to an adopter. It mocks promoteExclusive to park whichever caller arrives first until the other has linked, which is the interleaving that decides the winner. Falsifiability check: restoring the eventIdPinned && guard makes it fail with expected [ { …(8) }, { …(8) } ] to have a length of 1 but got 2.

expect(data.filter((e) => e.eventType === 'hook_received')).toHaveLength(1);
});

it('keeps the log dense when two instances resume the same hook at once', async () => {

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.

This test passes without the fix — I reverted the two events-storage.ts hunks (keeping the tests) and ran this file five times: tests 1 and 2 failed deterministically every run, but this one passed 5/5.

That matches the old code's behavior for this shape: with two live stagers and no terminal transition, the bumped-off slot always gets backfilled — the loser of the id-keyed staging write bumps to N+1, but the staging winner still promotes N, so the log ends dense either way. The hole needs the stager to be rejected (terminal marker / reap) or crashed, which is what test 1 stages.

So the PR description's "Each regression guard was verified to fail with the fix removed" doesn't hold for this one. Two options:

  • make it a real guard by racing a terminal transition into the window (stager drawn and staged, then reaped, then a later writer draws the same slot), or
  • keep it as-is but reword the description/comment to position it as a sanity check that the new promote-point arbitration keeps density under live-vs-live contention (which it does usefully exercise — the 'exists' → bump path now only exists at the promote).

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

You're right, and thanks for actually running it rather than reading it. With two live stagers and no terminal transition the pre-fix code also ends dense, because the writer bumped off the position is the one that goes on to publish it. The hole needs the stager to be rejected or killed, which is what the crashed-attempt test stages.

Rather than contrive a guard here, I reframed it honestly and let the new owner/adopter test carry the weight. It is now keeps the log dense under live contention on one position, and the comment says outright that density here is not a regression guard, plus what it does cover: arbitrating at the promote still resolves two instances drawing the same position, which is the CLI-plus-app configuration this backend supports (each instance's allocator watermark is its own, so both hand out the same candidate).

Also corrected the PR description, which claimed all three tests fail with the fix removed. True for tests 1, 2, and the new one; not for this one.

# an absolute path so the app's cwd does not enter into it. `setupWorld`
# recomputes the same path for the harness process.
case "$APP_NAME" in
nextjs*|next-*) DATA_DIR_NAME=".next/workflow-data" ;;

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.

This prefix glob doesn't mirror the harness's derivation. setupWorld (packages/core/e2e/utils.ts:507) uses substring matching:

constisNextJs=appName.includes('nextjs')||appName.includes('next-');

so an app named e.g. example-nextjs would get .next/workflow-data from the harness but .workflow-data from this case — the exact silent split-brain the comment above warns about, where the app and the harness talk to different directories. Today it can't bite (only nextjs-turbopack/nextjs-webpack carry the repro fixtures, and both match), but since the whole point of this block is pinning the two processes to one path:

*nextjs*|*next-*) DATA_DIR_NAME=".next/workflow-data";;

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Good catch. Fixed to *nextjs*|*next-*) so it mirrors setupWorld. I checked packages/core/e2e/utils.ts:507 and it is the substring form you quoted, so an app named example-nextjs would have sent the app to .workflow-data and the harness to .next/workflow-data, which is exactly the split-brain the block exists to prevent. Comment above the case now states why it is a substring match.

if (await bumpEventSlot(attempt)) {
continue;
}
// A nonced path cannot already exist. Surfacing rather than

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.

Nit: the comment is right that this can't be a real duplicate — which makes EntityConflictError ("Event already exists") a slightly misleading surface for it. EntityConflictError is the shape the runtime's concurrent-replay paths treat as a benign duplicate publish, so a genuine filesystem fault here would get absorbed as "someone else already wrote it" instead of surfacing as infra trouble. A WorkflowWorldError would keep the impossible case loud. Fine to leave if you'd rather not grow the error surface, since the branch is effectively unreachable.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Taken. It is now a WorkflowWorldError. EntityConflictError is the shape the runtime reads as a benign duplicate publish, so raising it for a nonced path that cannot collide would absorb a filesystem fault as "someone else already wrote it". The comment says that.

Only one of the two takers of a resume claim is pinned. The taker that
writes the claim keeps its own id, unpinned, because a slot is a position
another instance also hands out for unrelated events and refusing to move
would fail the append outright. The taker that adopts an existing claim is
pinned to the claimed position.
So the loser of the promote can be the unpinned owner, and a loser that
bumps publishes a second `hook_received` for one resumeId. The log stays
dense and both callers report success, but replay delivers the resume
twice, which is what the dedup contract forbids.
Run the occupant convergence inside the publish loop, before the bump, for
any `hook_received` carrying a resumeId. An occupant that is not this
resume is the unrelated-event collision the bump is for and still bumps,
or conflicts when pinned. The post-loop pinned check is now redundant.
Also:
- staging-path faults raise WorkflowWorldError rather than
EntityConflictError, which the runtime absorbs as a benign duplicate.
- the repro script's app-name case mirrors `setupWorld`'s substring match
so a future `example-nextjs` cannot split-brain the data dir.
- the live-vs-live density test says what it actually guards: it passes
without the fix, because a bumped-off position still gets published by
the stager that won it.

@pranaygppranaygp 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.

All four findings addressed in c6bf782, and I re-verified each empirically on the branch:

  • Duplicate-resume window: the convergence now runs inside the publish loop before bumpEventSlot, ungated on pinning — exactly the symmetric fix. I confirmed the new writes one event when the claim owner loses the position to an adopter guard is real: with that hunk reverted it fails deterministically (length of 1 but got 2, the duplicate), and with it the file and the full suite pass (546/546, tsc --noEmit clean).
  • Density test: honestly reframed as not-a-regression-guard, and the PR description now matches what the tests actually prove.
  • Script glob mirrors setupWorld's substring match, with the reasoning in a comment.
  • Staging fault now raises WorkflowWorldError so infra trouble can't be absorbed as a benign duplicate.

One non-blocking observation, pre-existing and inherited rather than introduced: isResumeEvent matches an occupant with resumeId === undefined by position, so the in-loop convergence could in principle adopt a plain-pathhook_received (no resumeId) for the same hook as this resume's committed event, dropping the resume's payload. That requires concurrently mixing the lazy and plain resume paths on one hook plus a slot collision — outside the dedup contract's supported shape, and the same predicate already governs converge/findCommittedResumeEvent. Fine to leave; noting it in case slot-run positional matching ever gets tightened.

@VaguelySerious

Copy link
Copy Markdown
MemberAuthor

CI update: everything is green except Unit Tests (windows-latest), which fails at packages/world-local/src/storage.test.ts:1284 with Test timed out in 120000ms. Same signature on main's last runs, so it is not from this PR.

I opened #3503 to fix it on main. It is a marginal budget rather than a step regression: same 245 tests, whole-file duration on the Windows runner went 143491ms (last green) to 154816ms (first red), 8% apart. Since Unit Tests feeds E2E Required Check, that PR is what unblocks the required aggregate here.

@VaguelySerious
VaguelySerious merged commit 0f4b35f into mainAug 12, 2026
160 of 165 checks passed
@VaguelySerious
VaguelySerious deleted the peter/world-local-event-race branch August 12, 2026 20:16
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 0f4b35f (AI decision).

This is a genuine correctness fix, but it targets code that only exists on main: origin/stable's packages/world-local/src/storage/events-storage.ts has no pendingHookEventPath staging, no slot-based event ids, and no bumpEventSlot/notePublishedSlot allocator, so the CORRUPTED_EVENT_LOG hole and the duplicate-resume path being fixed cannot occur there. The remaining changes are also main-only or unmaintained on stablescripts/event-log-race-repro-local.sh is absent from origin/stable, and the AGENTS.md/new-test changes describe main-only harness and storage behavior.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

0f4b35f62945327417013060f6e5de5111fe6ff1

Sign up for freeto 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.

2 participants

@VaguelySerious@pranaygp
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [world-local] Fix CORRUPTED_EVENT_LOG from hook-resume staging slot holes by VaguelySerious · Pull Request #3492 · vercel/workflow · GitHub
Skip to content

[world-local] Fix CORRUPTED_EVENT_LOG from hook-resume staging slot holes - #3492

Merged
VaguelySerious merged 2 commits into
mainfrom
peter/world-local-event-race
Aug 12, 2026
Merged

[world-local] Fix CORRUPTED_EVENT_LOG from hook-resume staging slot holes#3492
VaguelySerious merged 2 commits into
mainfrom
peter/world-local-event-race

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented Aug 12, 2026

Copy link
Copy Markdown
Member

What

Three fixes in @workflow/world-local, plus the local event-log race repro script gains a --world local lane.

The corruption

hook_received is the one event that does not publish straight into events/. It stages under .locks first, so a terminal transition can reap it before it ever becomes reader-visible (#2987). That staging path was keyed by the event id alone, and a collision on it bumped the writer to the next slot.

Under slot event ids (#3389) that bump is wrong. The slot allocator probes events/ and nothing else, so a staged file is not evidence the position is taken. Bumping moves the writer past a position nothing will ever fill, notePublishedSlot advances the watermark past it, and scanRunEventIds is max-based so no later writer backfills. The runtime reads the missing position as a durable hole and fails the run with CORRUPTED_EVENT_LOG.

Two ways in:

  1. An attempt killed between staging and promoting leaves its staged file behind. Its cleanup lives in a finally the kill skips, and the only other reaper runs on a terminal transition the run has not reached. Every later writer that draws that slot bumps off it, permanently.
  2. Two live writers draw the same candidate and the stager is later rejected by the terminal marker or the reap. The loser bumped off a slot the stager never published.

Provenance: staging arrived in 850777a03b (#2987) when ids were ULIDs, where the old comment was correct that the path "can only be occupied by a previous crashed attempt of this very event". 6786db9953 (#3389) turned ids into positions and kept the id-keyed staging name.

Fix: the staging name carries a nonce, so it is private to one attempt. The slot is then arbitrated only where it is actually taken, at the promote. This restores the file's own stated invariant: a slot is claimed by the publish that occupies it, never reserved ahead of a write that might still be rejected.

The spurious conflict, and the duplicate resume

When a resume loses the publish, the event now at that position can be the same resume, written by the other taker of the claim. That is the convergence the claim exists to force, and converge earlier in the function already answers it with the committed event. It just could not see it yet, because the other taker had not published when this attempt read.

The first pass answered that only for a pinned loser, which left the symmetric case open (caught in review). Only one of the two takers of a claim is pinned: the taker that writes the claim keeps its own id, unpinned, because a slot is a position another instance also hands out for unrelated events and refusing to move would fail this resume's append outright. The taker that adopts an existing claim is pinned to the claimed position. So the loser of the promote can be the unpinned owner, and a loser that bumps publishes a second hook_received for one resumeId. Nothing looks wrong afterwards (the log stays dense, both callers report success) but the resume is delivered twice on replay.

Fix: run the occupant convergence inside the publish loop, ahead of the bump, for any hook_received carrying a resumeId, with no pinning condition. An occupant that is not this resume is the unrelated-event collision the bump is for and still bumps, or conflicts when pinned. Both takers return the one committed event, matching the dedup contract hook-resume-dedup.test.ts already asserts. Reporting EntityConflictError instead gave the caller an error it cannot act on for a resume that did land (HTTP 500, queue retry).

Separately, a failure to stage under the nonced path now raises WorkflowWorldError rather than EntityConflictError: a nonced path cannot collide, so it is a filesystem fault, and EntityConflictError is the shape the runtime absorbs as a benign duplicate publish.

Repro script

scripts/event-log-race-repro-local.sh was postgres-only. It now takes --world postgres|local (default postgres, unchanged). Under --world local it exports WORKFLOW_TARGET_WORLD=local plus an absolute WORKFLOW_LOCAL_DATA_DIR and WORKFLOW_LOCAL_QUEUE_CONCURRENCY, clears the data directory instead of the queue, and skips all container bring-up, migration, and teardown.

pnpm run test:e2e:event-log-race-repro:local --world local

Testing

New packages/world-local/src/storage/hook-staging-slots.test.ts, 4 tests. Three are regression guards verified to fail with the fix removed:

  • a crashed attempt's leftover staged file no longer holes the log. Before the fix this produced slots [1, 2, 4] instead of [1, 2, 3].
  • both writers of one raced resume get the committed event back rather than a conflict.
  • when the unpinned claim owner loses the position to an adopter, one hook_received is written and both takers return it. This one mocks promoteExclusive to park whichever caller reaches it first until the other has linked, since the outcome hinges on that interleaving. Restoring the eventIdPinned && guard makes it fail with to have a length of 1 but got 2.

The fourth, keeps the log dense under live contention on one position, is not a regression guard and its comment says so: with two live stagers and no terminal transition the pre-fix code also ended dense, because the writer it bumped off the position was the one that went on to publish it. It covers that arbitrating at the promote still resolves two instances drawing one position.

Full world-local suite: 546/546 across 16 files. tsc --noEmit clean.

On the storm harness, stated plainly: it has never produced a CORRUPTED_EVENT_LOG outcome or an on-disk hole against world-local, across one 14-run pass and two 16-run passes before the fix. The corruption is demonstrated by the unit test, not by the storm. What the storm does show is the conflict volume: ~12-27 EntityConflictError per pass before, 1 after (a hook_created benign duplicate, which is the documented path), with all 14 runs completing and every run dense on disk (count == max, 8931 event files checked).

This matches the note now in AGENTS.md: world-local's storms come out clean far more often than world-postgres's, so reach for a unit test in packages/world-local/src/storage/ when a suspected filesystem race can be staged directly.

`hook_received` is the only event that does not publish straight into
`events/`: it stages under `.locks` first so a terminal transition can
reap it before it becomes reader-visible. That staging path was keyed by
the event id alone, and a collision on it bumped the writer to the next
slot.
Under slot ids a staging collision is not evidence the position is
taken. The allocator probes `events/` only, so bumping moves the writer
past a position nothing will ever fill, and `scanRunEventIds` is
max-based so no later writer backfills it. The runtime reads the missing
position as a durable hole and fails the run with CORRUPTED_EVENT_LOG.
Two triggers: an attempt killed between staging and promoting leaves its
staged file behind (cleanup lives in a `finally` the kill skips, and the
only other reaper runs on a terminal transition), and two live writers
drawing the same candidate where the stager is later rejected.
Staging now carries a nonce, so it is private to one attempt and the
slot is arbitrated only where it is actually taken, at the promote.
Second fix: when a pinned resume loses the publish, the event at the
pinned position is that same resume written by the other taker, which is
the convergence the pin exists to force. Return that committed event
instead of an EntityConflictError the caller cannot act on.
Also re-purposes scripts/event-log-race-repro-local.sh to drive either
world with `--world postgres|local`.
@VaguelySerious
VaguelySerious requested a review from a team as a code ownerAugust 12, 2026 16:46
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

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

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewAug 12, 2026 7:53pm
example-nextjs-workflow-webpackReadyReadyPreviewAug 12, 2026 7:53pm
example-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-astro-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-express-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-fastify-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-hono-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-nestjs-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-nitro-workflowBuildingBuildingPreviewAug 12, 2026 7:53pm
workbench-nuxt-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-python-workflowErrorErrorAug 12, 2026 7:53pm
workbench-sveltekit-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-tanstack-start-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-vite-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workflow-docsReadyReadyPreview, v0Aug 12, 2026 7:53pm
workflow-swc-playgroundReadyReadyPreviewAug 12, 2026 7:53pm
workflow-tarballsReadyReadyPreviewAug 12, 2026 7:53pm
workflow-webReadyReadyPreviewAug 12, 2026 7:53pm

@changeset-bot

changeset-botBot commented Aug 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c6bf782

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

This PR includes changesets to release 18 packages
NameType
@workflow/world-localPatch
@workflow/cliPatch
@workflow/corePatch
@workflow/vitestPatch
@workflow/webPatch
@workflow/world-postgresPatch
workflowPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/web-sharedPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

@github-actions

github-actionsBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

❌ Failed E2E Tests

▲ Vercel Production (1 failed)

nextjs-turbopack-node (1 failed):

  • distributedAbortController - manual abort triggers signal | wrun_41KZVSGQDP0GJSR1W5GDTRB65H | 🔍 observability

E2E Test Summary

Summary
PassedFailedSkippedTotal
❌ ▲ Vercel Production331215873900
✅ 💻 Local Development381005584368
✅ 📦 Local Production381005584368
✅ 🐘 Local Postgres381005584368
✅ 🪟 Windows31200312
✅ vercel-multi-region270027
Total150811226117343
Details by Category

❌ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node128028
✅ astro-quickjs128028
✅ example-node128028
✅ example-quickjs128028
✅ express-node128028
✅ express-quickjs128028
✅ fastify-node128028
✅ fastify-quickjs128028
✅ hono-node128028
✅ hono-quickjs128028
✅ nest-node128028
✅ nest-quickjs128028
❌ nextjs-turbopack-node15213
✅ nextjs-webpack-node15303
✅ nextjs-webpack-quickjs15303
✅ nitro-node128028
✅ nitro-quickjs128028
✅ nuxt-node128028
✅ nuxt-quickjs128028
✅ sveltekit-node14709
✅ sveltekit-quickjs14709
✅ tanstack-start-node128028
✅ tanstack-start-quickjs128028
✅ vite-node128028
✅ vite-quickjs128028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack-node15600
✅ nextjs-turbopack-quickjs15600

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@github-actions

github-actionsBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit c6bf782 · Wed, 12 Aug 2026 20:08:57 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1361 (+513%) 🔻1553 🔴 (+39%) 🔻1591 🔴 (+36%) 🔻1914 🔴 (+9.6%)30
TTFSstream1359 (+427%) 🔻1519 🔴 (+37%) 🔻1595 🔴 (+42%) 🔻1621 🔴 (+5.7%)30
TTFShook + stream1593 (+331%) 🔻1805 🔴 (+32%) 🔻1866 🔴 (+30%) 🔻1973 🔴 (-57%) 💚30
STSO1020 steps (inline)133 (-2.9%)177 (-16%) 💚198 (-19%) 💚296 (-26%) 💚1019
WO1020 steps176010 (-14%)176010 (-14%)176010 (-14%)176010 (-14%)1
SLstream latency109 (+18%) 🔻154 🔴 (+4.8%)182 🔴 (+0.6%)3136 🔴 (+602%) 🔻30
SOstream overhead (text)128 (+4.9%)185 (-31%) 💚210 (-57%) 💚282 (-71%) 💚30
SOstream overhead (structured)121 (-2.4%)169 (-39%) 💚198 (-77%) 💚235 (-99%) 💚30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 204038ms → this run 174609ms (Δ -29429ms, -14%)

 100-150 ms █░░┃ main 11 this 137 +126
150-200 ms ████████████████████░░░┃ main 643 this 785 +142
200-250 ms █┃██████ main 277 this 69 -208
250-300 ms ┃█ main 55 this 18 -37
300-350 ms ┃ main 10 this 6 -4
350-400 ms ┃ main 12 this 3 -9
400-450 ms ┃ main 3 this 0 -3
450-500 ms ┃ main 1 this 1 +0
550-600 ms ┃ main 1 this 0 -1
600-650 ms ┃ main 1 this 0 -1
650-700 ms ┃ main 4 this 0 -4
1000-1050 ms ┃ main 1 this 0 -1
📜 Previous results (1)

0161a14

Wed, 12 Aug 2026 17:06:24 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1262 (+79%) 🔻1448 🔴 (+44%) 🔻1489 🔴 (+47%) 🔻1737 🔴 (+15%) 🔻30
TTFSstream317 (-67%) 💚1381 🔴 (+40%) 🔻1410 🔴 (+40%) 🔻1505 🔴 (+43%) 🔻30
TTFShook + stream1618 (+33%) 🔻1718 🔴 (+33%) 🔻1786 🔴 (+33%) 🔻1875 🔴 (+16%) 🔻30
STSO1020 steps (inline)1311782034061019
WO1020 steps180588 (-53%) 💚180588 (-53%) 💚180588 (-53%) 💚180588 (-53%) 💚1
SLstream latency109 (+35%) 🔻139 🔴 (+5.3%)151 🔴 (+7.9%)210 🔴 (+17%) 🔻30
SOstream overhead (text)130 (+29%) 🔻238 (+32%) 🔻309 (+53%) 🔻420 (+68%) 🔻30
SOstream overhead (structured)127 (+28%) 🔻206 (+27%) 🔻238 (+22%) 🔻303 (+40%) 🔻30
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: is main, marks where this run lands, bridges the gap when this run has more samples in a bucket.

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) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

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) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

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.

@github-actions

github-actionsBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 Mint-ordered log — 6 fail of 41 total

log=mint-ordered · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted171.0mMISMATCH1
stale-read-equal-step-countscompleted141.0mMISMATCH1
step-vs-step-forkcompleted120msMISMATCH1
step-vs-step-fork-fencedcompleted120msMISMATCH1
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mMISMATCH1
in-flight-before-decision-countedcompleted201.0mok0
in-flight-after-decisionfailed142.0mMISMATCH1
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-mint.txt

🟢 Append-only log — 0 fail of 41 total

log=append-only · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted201.0mok0
stale-read-equal-step-countscompleted141.0mok0
step-vs-step-forkcompleted120msok0
step-vs-step-fork-fencedcompleted120msok0
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mok0
in-flight-before-decision-countedcompleted171.0mok0
in-flight-after-decisioncompleted192.0mok0
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-append-only.txt

@VaguelySerious

Copy link
Copy Markdown
MemberAuthor

CI status

E2E Required Check is red for exactly one reason, and it is not this PR.

UNIT_STATUS: failure
BUILD_STATUS: success
VERCEL_STATUS: success
LOCAL_DEV_STATUS: success
LOCAL_PROD_STATUS: success
POSTGRES_STATUS: success
WINDOWS_STATUS: success
unit (failure)

Unit Tests (windows-latest) times out in packages/world-local/src/storage.test.ts:1284, returns the complete preload when run_started is retried. That test writes 1000 sequential events and already carries an explicit 120_000 timeout.

It is red on main with the identical signature on the last four runs (31530061366, 31533009514, 31604115603, 31616799260) and passed on 08-10 (31446555530, 31431114276).

A marginal budget rather than a code regression. Whole-file duration on the Windows runner, same 245 tests either side of the boundary:

Runsrc/storage.test.tsResult
93641986199 (08-11 00:40, last green)143491mspass
94189757184 (this PR)154816msthis one test times out

8% apart. An added fs op per write would show a much bigger jump than that. Locally create is linear at ~0.95ms regardless of n (measured at n = 250 / 500 / 1000 / 2000), and the whole test runs in 1.17s on macOS.

Not fixing it here: this PR does not touch storage.test.ts, and the fix (parallelize the 1000 writes, or raise the budget with a stated reason) belongs on main rather than folded into a corruption fix. Flagging it because it blocks the required aggregate on every PR, not only this one.

Vercel – workbench-python-workflow also fails on main and every PR, and is not required.

Repro script, postgres lane

Checked that the --world refactor did not break the default path. Full build, container bring-up, migrations, harness: 13/14 completed, 0 CORRUPTED_EVENT_LOG, 1 hook-stormstuck, which is the documented local-runner artifact (one Next.js process holding every replay).

@pranaygppranaygp 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.

Reviewed with a focus on the testing story and on whether this closes all the slot-hole paths. The staging-nonce fix is correct and the crash-window regression guard is real — I verified 545/545 on the branch, tsc --noEmit clean, and that tests 1 and 2 fail deterministically with the fix reverted.

Three findings, detailed inline:

  1. A duplicate-resume window survives the fix (events-storage.ts): when the pinned adopter of a resume claim wins the promote link(2), the unpinned claim owner bumps and publishes a second hook_received for the same resumeId. Reproduced deterministically; repro in the inline comment. Not a CORRUPTED_EVENT_LOG (the log stays dense) — it's a violation of the dedup contract hook-resume-dedup.test.ts asserts, and it pre-exists this PR in a sibling interleaving.
  2. Test 3 is not a regression guard: it passes with the fix reverted (5/5 runs). Tests 1 and 2 do fail without the fix, so the PR description's claim is accurate for those two only.
  3. Minor: a nextjs app-name glob in the repro script doesn't match setupWorld's substring check, which could split-brain the data dir for future app names.

Also audited world-postgres for the analogous hole since it arbitrates slots differently: it doesn't have one. nextSlotId computes the position inside the INSERT itself (storage.ts:167) — nothing reserves a slot ahead of the write, so a rejected or crashed hook_received rolls back its transaction and leaves the numbering untouched; the terminal-race guard is a FOR UPDATE on the run row in the same transaction (storage.ts:1944). It also has no lazy resume-dedup path at all (no resumeId column, per the deliberate omission in drizzle/schema.ts:145), so the duplicate-resume window doesn't apply there either.

// read. Answer it the same way rather than reporting a conflict
// the caller cannot act on: the resume IS committed, exactly once,
// and the dedup contract is that both writers return that event.
if (eventIdPinned && data.eventType === 'hook_received') {

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.

This convergence covers the pinned loser, but the symmetric case is still open and produces a duplicate hook_received for one resumeId.

On a slot run the claim owner is unpinned (eventIdPinned = !isSlotEventId(eventId) up at the claim write), while a racing taker that adopts the claim is pinned. Both stage nonced files and race the promote at the claimed position S. If the adopter wins the link, the owner's promote returns 'exists', bumpEventSlot is allowed to move it (it isn't pinned), and the owner publishes a second hook_received for the same resume at S+1. Both callers return success; the log stays dense, so no CORRUPTED_EVENT_LOG — but replay now delivers one resume twice, which is exactly what the "collapses the two writers of ONE resume onto a single event" contract in hook-resume-dedup.test.ts forbids. (The claim rewrite after the owner's publish points redeliveries at the second event, so later converges mask it — but both events stay in the log and both replay.)

I reproduced this deterministically on this branch by parking the first promoteExclusive caller (the owner — it has a head start, since the adopter does a full findCommittedResumeEvent scan first) until the second caller has linked:

repro test (drop into src/storage/, uses a partial vi.mock of ../fs.js)
constgate={armed: false,released: false,firstRelease: nullas(()=>void)|null};vi.mock('../fs.js',async(importOriginal)=>{constactual=(awaitimportOriginal())asRecord<string,any>;return{
...actual,promoteExclusive: async(stagedPath: string,filePath: string)=>{if(gate.armed&&!gate.released){if(gate.firstRelease===null){awaitnewPromise<void>((r)=>{gate.firstRelease=r;});returnactual.promoteExclusive(stagedPath,filePath);}constresult=awaitactual.promoteExclusive(stagedPath,filePath);gate.released=true;gate.firstRelease?.();returnresult;}returnactual.promoteExclusive(stagedPath,filePath);},};});// setup: createRun + createHook, then:gate.armed=true;constresults=awaitPromise.allSettled([storage,createStorage(testDir)].map((inst)=>inst.events.create(runId,{eventType: 'hook_received',specVersion: SPEC_VERSION_CURRENT,correlationId: hook.hookId,eventData: {token: hook.token,payload: newUint8Array([1])},},{resumeId: 'resume_1',resumePayloadDigest: 'resume_1'})));

Result: zero rejections, and the log holds evnt_…003:hook_receivedandevnt_…004:hook_received, both with resumeId: 'resume_1'.

To be clear about provenance: this window is not introduced here — pre-PR, the same interleaving existed at the staging write (adopter stages the id-keyed path first → owner collides → bumps → duplicates). The nonce moves the collision from staging to promote, but the owner-loses arm is still resolved by bumping.

Suggested fix, symmetric with the one you added: run this occupant/isResumeEvent convergence for hook_received with a resumeIdbeforebumpEventSlot, regardless of pinning — if the occupant at the lost position is this same resume, return it instead of bumping. The pinned case then falls out of the same check, and an unrelated occupant still bumps (unpinned) or conflicts (pinned) as today.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Confirmed, and fixed in c6bf782.

I verified the asymmetry before changing anything: on a slot run the claim owner keeps its own id and eventIdPinned = !isSlotEventId(eventId) leaves it unpinned, while the adopter is pinned to the claimed position. So the loser of the promote can be the unpinned owner, it bumps, and a second hook_received lands for one resumeId.

The convergence now runs inside the publish loop, ahead of bumpEventSlot, for any hook_received carrying a resumeId, with no pinning condition. An occupant that is not this resume is the unrelated-event collision the bump exists for and still bumps, or conflicts when pinned. The post-loop pinned block is gone, since a resume that lost to its own committed event now returns from inside the loop, and reaching the end means the occupant is unrelated.

Regression guard: writes one event when the claim owner loses the position to an adopter. It mocks promoteExclusive to park whichever caller arrives first until the other has linked, which is the interleaving that decides the winner. Falsifiability check: restoring the eventIdPinned && guard makes it fail with expected [ { …(8) }, { …(8) } ] to have a length of 1 but got 2.

expect(data.filter((e) => e.eventType === 'hook_received')).toHaveLength(1);
});

it('keeps the log dense when two instances resume the same hook at once', async () => {

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.

This test passes without the fix — I reverted the two events-storage.ts hunks (keeping the tests) and ran this file five times: tests 1 and 2 failed deterministically every run, but this one passed 5/5.

That matches the old code's behavior for this shape: with two live stagers and no terminal transition, the bumped-off slot always gets backfilled — the loser of the id-keyed staging write bumps to N+1, but the staging winner still promotes N, so the log ends dense either way. The hole needs the stager to be rejected (terminal marker / reap) or crashed, which is what test 1 stages.

So the PR description's "Each regression guard was verified to fail with the fix removed" doesn't hold for this one. Two options:

  • make it a real guard by racing a terminal transition into the window (stager drawn and staged, then reaped, then a later writer draws the same slot), or
  • keep it as-is but reword the description/comment to position it as a sanity check that the new promote-point arbitration keeps density under live-vs-live contention (which it does usefully exercise — the 'exists' → bump path now only exists at the promote).

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

You're right, and thanks for actually running it rather than reading it. With two live stagers and no terminal transition the pre-fix code also ends dense, because the writer bumped off the position is the one that goes on to publish it. The hole needs the stager to be rejected or killed, which is what the crashed-attempt test stages.

Rather than contrive a guard here, I reframed it honestly and let the new owner/adopter test carry the weight. It is now keeps the log dense under live contention on one position, and the comment says outright that density here is not a regression guard, plus what it does cover: arbitrating at the promote still resolves two instances drawing the same position, which is the CLI-plus-app configuration this backend supports (each instance's allocator watermark is its own, so both hand out the same candidate).

Also corrected the PR description, which claimed all three tests fail with the fix removed. True for tests 1, 2, and the new one; not for this one.

# an absolute path so the app's cwd does not enter into it. `setupWorld`
# recomputes the same path for the harness process.
case "$APP_NAME" in
nextjs*|next-*) DATA_DIR_NAME=".next/workflow-data" ;;

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.

This prefix glob doesn't mirror the harness's derivation. setupWorld (packages/core/e2e/utils.ts:507) uses substring matching:

constisNextJs=appName.includes('nextjs')||appName.includes('next-');

so an app named e.g. example-nextjs would get .next/workflow-data from the harness but .workflow-data from this case — the exact silent split-brain the comment above warns about, where the app and the harness talk to different directories. Today it can't bite (only nextjs-turbopack/nextjs-webpack carry the repro fixtures, and both match), but since the whole point of this block is pinning the two processes to one path:

*nextjs*|*next-*) DATA_DIR_NAME=".next/workflow-data";;

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Good catch. Fixed to *nextjs*|*next-*) so it mirrors setupWorld. I checked packages/core/e2e/utils.ts:507 and it is the substring form you quoted, so an app named example-nextjs would have sent the app to .workflow-data and the harness to .next/workflow-data, which is exactly the split-brain the block exists to prevent. Comment above the case now states why it is a substring match.

if (await bumpEventSlot(attempt)) {
continue;
}
// A nonced path cannot already exist. Surfacing rather than

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.

Nit: the comment is right that this can't be a real duplicate — which makes EntityConflictError ("Event already exists") a slightly misleading surface for it. EntityConflictError is the shape the runtime's concurrent-replay paths treat as a benign duplicate publish, so a genuine filesystem fault here would get absorbed as "someone else already wrote it" instead of surfacing as infra trouble. A WorkflowWorldError would keep the impossible case loud. Fine to leave if you'd rather not grow the error surface, since the branch is effectively unreachable.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Taken. It is now a WorkflowWorldError. EntityConflictError is the shape the runtime reads as a benign duplicate publish, so raising it for a nonced path that cannot collide would absorb a filesystem fault as "someone else already wrote it". The comment says that.

Only one of the two takers of a resume claim is pinned. The taker that
writes the claim keeps its own id, unpinned, because a slot is a position
another instance also hands out for unrelated events and refusing to move
would fail the append outright. The taker that adopts an existing claim is
pinned to the claimed position.
So the loser of the promote can be the unpinned owner, and a loser that
bumps publishes a second `hook_received` for one resumeId. The log stays
dense and both callers report success, but replay delivers the resume
twice, which is what the dedup contract forbids.
Run the occupant convergence inside the publish loop, before the bump, for
any `hook_received` carrying a resumeId. An occupant that is not this
resume is the unrelated-event collision the bump is for and still bumps,
or conflicts when pinned. The post-loop pinned check is now redundant.
Also:
- staging-path faults raise WorkflowWorldError rather than
EntityConflictError, which the runtime absorbs as a benign duplicate.
- the repro script's app-name case mirrors `setupWorld`'s substring match
so a future `example-nextjs` cannot split-brain the data dir.
- the live-vs-live density test says what it actually guards: it passes
without the fix, because a bumped-off position still gets published by
the stager that won it.

@pranaygppranaygp 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.

All four findings addressed in c6bf782, and I re-verified each empirically on the branch:

  • Duplicate-resume window: the convergence now runs inside the publish loop before bumpEventSlot, ungated on pinning — exactly the symmetric fix. I confirmed the new writes one event when the claim owner loses the position to an adopter guard is real: with that hunk reverted it fails deterministically (length of 1 but got 2, the duplicate), and with it the file and the full suite pass (546/546, tsc --noEmit clean).
  • Density test: honestly reframed as not-a-regression-guard, and the PR description now matches what the tests actually prove.
  • Script glob mirrors setupWorld's substring match, with the reasoning in a comment.
  • Staging fault now raises WorkflowWorldError so infra trouble can't be absorbed as a benign duplicate.

One non-blocking observation, pre-existing and inherited rather than introduced: isResumeEvent matches an occupant with resumeId === undefined by position, so the in-loop convergence could in principle adopt a plain-pathhook_received (no resumeId) for the same hook as this resume's committed event, dropping the resume's payload. That requires concurrently mixing the lazy and plain resume paths on one hook plus a slot collision — outside the dedup contract's supported shape, and the same predicate already governs converge/findCommittedResumeEvent. Fine to leave; noting it in case slot-run positional matching ever gets tightened.

@VaguelySerious

Copy link
Copy Markdown
MemberAuthor

CI update: everything is green except Unit Tests (windows-latest), which fails at packages/world-local/src/storage.test.ts:1284 with Test timed out in 120000ms. Same signature on main's last runs, so it is not from this PR.

I opened #3503 to fix it on main. It is a marginal budget rather than a step regression: same 245 tests, whole-file duration on the Windows runner went 143491ms (last green) to 154816ms (first red), 8% apart. Since Unit Tests feeds E2E Required Check, that PR is what unblocks the required aggregate here.

@VaguelySerious
VaguelySerious merged commit 0f4b35f into mainAug 12, 2026
160 of 165 checks passed
@VaguelySerious
VaguelySerious deleted the peter/world-local-event-race branch August 12, 2026 20:16
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 0f4b35f (AI decision).

This is a genuine correctness fix, but it targets code that only exists on main: origin/stable's packages/world-local/src/storage/events-storage.ts has no pendingHookEventPath staging, no slot-based event ids, and no bumpEventSlot/notePublishedSlot allocator, so the CORRUPTED_EVENT_LOG hole and the duplicate-resume path being fixed cannot occur there. The remaining changes are also main-only or unmaintained on stablescripts/event-log-race-repro-local.sh is absent from origin/stable, and the AGENTS.md/new-test changes describe main-only harness and storage behavior.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

0f4b35f62945327417013060f6e5de5111fe6ff1

Sign up for freeto 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.

2 participants

@VaguelySerious@pranaygp
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [world-local] Fix CORRUPTED_EVENT_LOG from hook-resume staging slot holes by VaguelySerious · Pull Request #3492 · vercel/workflow · GitHub
Skip to content

[world-local] Fix CORRUPTED_EVENT_LOG from hook-resume staging slot holes - #3492

Merged
VaguelySerious merged 2 commits into
mainfrom
peter/world-local-event-race
Aug 12, 2026
Merged

[world-local] Fix CORRUPTED_EVENT_LOG from hook-resume staging slot holes#3492
VaguelySerious merged 2 commits into
mainfrom
peter/world-local-event-race

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented Aug 12, 2026

Copy link
Copy Markdown
Member

What

Three fixes in @workflow/world-local, plus the local event-log race repro script gains a --world local lane.

The corruption

hook_received is the one event that does not publish straight into events/. It stages under .locks first, so a terminal transition can reap it before it ever becomes reader-visible (#2987). That staging path was keyed by the event id alone, and a collision on it bumped the writer to the next slot.

Under slot event ids (#3389) that bump is wrong. The slot allocator probes events/ and nothing else, so a staged file is not evidence the position is taken. Bumping moves the writer past a position nothing will ever fill, notePublishedSlot advances the watermark past it, and scanRunEventIds is max-based so no later writer backfills. The runtime reads the missing position as a durable hole and fails the run with CORRUPTED_EVENT_LOG.

Two ways in:

  1. An attempt killed between staging and promoting leaves its staged file behind. Its cleanup lives in a finally the kill skips, and the only other reaper runs on a terminal transition the run has not reached. Every later writer that draws that slot bumps off it, permanently.
  2. Two live writers draw the same candidate and the stager is later rejected by the terminal marker or the reap. The loser bumped off a slot the stager never published.

Provenance: staging arrived in 850777a03b (#2987) when ids were ULIDs, where the old comment was correct that the path "can only be occupied by a previous crashed attempt of this very event". 6786db9953 (#3389) turned ids into positions and kept the id-keyed staging name.

Fix: the staging name carries a nonce, so it is private to one attempt. The slot is then arbitrated only where it is actually taken, at the promote. This restores the file's own stated invariant: a slot is claimed by the publish that occupies it, never reserved ahead of a write that might still be rejected.

The spurious conflict, and the duplicate resume

When a resume loses the publish, the event now at that position can be the same resume, written by the other taker of the claim. That is the convergence the claim exists to force, and converge earlier in the function already answers it with the committed event. It just could not see it yet, because the other taker had not published when this attempt read.

The first pass answered that only for a pinned loser, which left the symmetric case open (caught in review). Only one of the two takers of a claim is pinned: the taker that writes the claim keeps its own id, unpinned, because a slot is a position another instance also hands out for unrelated events and refusing to move would fail this resume's append outright. The taker that adopts an existing claim is pinned to the claimed position. So the loser of the promote can be the unpinned owner, and a loser that bumps publishes a second hook_received for one resumeId. Nothing looks wrong afterwards (the log stays dense, both callers report success) but the resume is delivered twice on replay.

Fix: run the occupant convergence inside the publish loop, ahead of the bump, for any hook_received carrying a resumeId, with no pinning condition. An occupant that is not this resume is the unrelated-event collision the bump is for and still bumps, or conflicts when pinned. Both takers return the one committed event, matching the dedup contract hook-resume-dedup.test.ts already asserts. Reporting EntityConflictError instead gave the caller an error it cannot act on for a resume that did land (HTTP 500, queue retry).

Separately, a failure to stage under the nonced path now raises WorkflowWorldError rather than EntityConflictError: a nonced path cannot collide, so it is a filesystem fault, and EntityConflictError is the shape the runtime absorbs as a benign duplicate publish.

Repro script

scripts/event-log-race-repro-local.sh was postgres-only. It now takes --world postgres|local (default postgres, unchanged). Under --world local it exports WORKFLOW_TARGET_WORLD=local plus an absolute WORKFLOW_LOCAL_DATA_DIR and WORKFLOW_LOCAL_QUEUE_CONCURRENCY, clears the data directory instead of the queue, and skips all container bring-up, migration, and teardown.

pnpm run test:e2e:event-log-race-repro:local --world local

Testing

New packages/world-local/src/storage/hook-staging-slots.test.ts, 4 tests. Three are regression guards verified to fail with the fix removed:

  • a crashed attempt's leftover staged file no longer holes the log. Before the fix this produced slots [1, 2, 4] instead of [1, 2, 3].
  • both writers of one raced resume get the committed event back rather than a conflict.
  • when the unpinned claim owner loses the position to an adopter, one hook_received is written and both takers return it. This one mocks promoteExclusive to park whichever caller reaches it first until the other has linked, since the outcome hinges on that interleaving. Restoring the eventIdPinned && guard makes it fail with to have a length of 1 but got 2.

The fourth, keeps the log dense under live contention on one position, is not a regression guard and its comment says so: with two live stagers and no terminal transition the pre-fix code also ended dense, because the writer it bumped off the position was the one that went on to publish it. It covers that arbitrating at the promote still resolves two instances drawing one position.

Full world-local suite: 546/546 across 16 files. tsc --noEmit clean.

On the storm harness, stated plainly: it has never produced a CORRUPTED_EVENT_LOG outcome or an on-disk hole against world-local, across one 14-run pass and two 16-run passes before the fix. The corruption is demonstrated by the unit test, not by the storm. What the storm does show is the conflict volume: ~12-27 EntityConflictError per pass before, 1 after (a hook_created benign duplicate, which is the documented path), with all 14 runs completing and every run dense on disk (count == max, 8931 event files checked).

This matches the note now in AGENTS.md: world-local's storms come out clean far more often than world-postgres's, so reach for a unit test in packages/world-local/src/storage/ when a suspected filesystem race can be staged directly.

`hook_received` is the only event that does not publish straight into
`events/`: it stages under `.locks` first so a terminal transition can
reap it before it becomes reader-visible. That staging path was keyed by
the event id alone, and a collision on it bumped the writer to the next
slot.
Under slot ids a staging collision is not evidence the position is
taken. The allocator probes `events/` only, so bumping moves the writer
past a position nothing will ever fill, and `scanRunEventIds` is
max-based so no later writer backfills it. The runtime reads the missing
position as a durable hole and fails the run with CORRUPTED_EVENT_LOG.
Two triggers: an attempt killed between staging and promoting leaves its
staged file behind (cleanup lives in a `finally` the kill skips, and the
only other reaper runs on a terminal transition), and two live writers
drawing the same candidate where the stager is later rejected.
Staging now carries a nonce, so it is private to one attempt and the
slot is arbitrated only where it is actually taken, at the promote.
Second fix: when a pinned resume loses the publish, the event at the
pinned position is that same resume written by the other taker, which is
the convergence the pin exists to force. Return that committed event
instead of an EntityConflictError the caller cannot act on.
Also re-purposes scripts/event-log-race-repro-local.sh to drive either
world with `--world postgres|local`.
@VaguelySerious
VaguelySerious requested a review from a team as a code ownerAugust 12, 2026 16:46
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

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

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewAug 12, 2026 7:53pm
example-nextjs-workflow-webpackReadyReadyPreviewAug 12, 2026 7:53pm
example-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-astro-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-express-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-fastify-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-hono-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-nestjs-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-nitro-workflowBuildingBuildingPreviewAug 12, 2026 7:53pm
workbench-nuxt-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-python-workflowErrorErrorAug 12, 2026 7:53pm
workbench-sveltekit-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-tanstack-start-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-vite-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workflow-docsReadyReadyPreview, v0Aug 12, 2026 7:53pm
workflow-swc-playgroundReadyReadyPreviewAug 12, 2026 7:53pm
workflow-tarballsReadyReadyPreviewAug 12, 2026 7:53pm
workflow-webReadyReadyPreviewAug 12, 2026 7:53pm

@changeset-bot

changeset-botBot commented Aug 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c6bf782

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

This PR includes changesets to release 18 packages
NameType
@workflow/world-localPatch
@workflow/cliPatch
@workflow/corePatch
@workflow/vitestPatch
@workflow/webPatch
@workflow/world-postgresPatch
workflowPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/web-sharedPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

@github-actions

github-actionsBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

❌ Failed E2E Tests

▲ Vercel Production (1 failed)

nextjs-turbopack-node (1 failed):

  • distributedAbortController - manual abort triggers signal | wrun_41KZVSGQDP0GJSR1W5GDTRB65H | 🔍 observability

E2E Test Summary

Summary
PassedFailedSkippedTotal
❌ ▲ Vercel Production331215873900
✅ 💻 Local Development381005584368
✅ 📦 Local Production381005584368
✅ 🐘 Local Postgres381005584368
✅ 🪟 Windows31200312
✅ vercel-multi-region270027
Total150811226117343
Details by Category

❌ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node128028
✅ astro-quickjs128028
✅ example-node128028
✅ example-quickjs128028
✅ express-node128028
✅ express-quickjs128028
✅ fastify-node128028
✅ fastify-quickjs128028
✅ hono-node128028
✅ hono-quickjs128028
✅ nest-node128028
✅ nest-quickjs128028
❌ nextjs-turbopack-node15213
✅ nextjs-webpack-node15303
✅ nextjs-webpack-quickjs15303
✅ nitro-node128028
✅ nitro-quickjs128028
✅ nuxt-node128028
✅ nuxt-quickjs128028
✅ sveltekit-node14709
✅ sveltekit-quickjs14709
✅ tanstack-start-node128028
✅ tanstack-start-quickjs128028
✅ vite-node128028
✅ vite-quickjs128028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack-node15600
✅ nextjs-turbopack-quickjs15600

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@github-actions

github-actionsBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit c6bf782 · Wed, 12 Aug 2026 20:08:57 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1361 (+513%) 🔻1553 🔴 (+39%) 🔻1591 🔴 (+36%) 🔻1914 🔴 (+9.6%)30
TTFSstream1359 (+427%) 🔻1519 🔴 (+37%) 🔻1595 🔴 (+42%) 🔻1621 🔴 (+5.7%)30
TTFShook + stream1593 (+331%) 🔻1805 🔴 (+32%) 🔻1866 🔴 (+30%) 🔻1973 🔴 (-57%) 💚30
STSO1020 steps (inline)133 (-2.9%)177 (-16%) 💚198 (-19%) 💚296 (-26%) 💚1019
WO1020 steps176010 (-14%)176010 (-14%)176010 (-14%)176010 (-14%)1
SLstream latency109 (+18%) 🔻154 🔴 (+4.8%)182 🔴 (+0.6%)3136 🔴 (+602%) 🔻30
SOstream overhead (text)128 (+4.9%)185 (-31%) 💚210 (-57%) 💚282 (-71%) 💚30
SOstream overhead (structured)121 (-2.4%)169 (-39%) 💚198 (-77%) 💚235 (-99%) 💚30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 204038ms → this run 174609ms (Δ -29429ms, -14%)

 100-150 ms █░░┃ main 11 this 137 +126
150-200 ms ████████████████████░░░┃ main 643 this 785 +142
200-250 ms █┃██████ main 277 this 69 -208
250-300 ms ┃█ main 55 this 18 -37
300-350 ms ┃ main 10 this 6 -4
350-400 ms ┃ main 12 this 3 -9
400-450 ms ┃ main 3 this 0 -3
450-500 ms ┃ main 1 this 1 +0
550-600 ms ┃ main 1 this 0 -1
600-650 ms ┃ main 1 this 0 -1
650-700 ms ┃ main 4 this 0 -4
1000-1050 ms ┃ main 1 this 0 -1
📜 Previous results (1)

0161a14

Wed, 12 Aug 2026 17:06:24 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1262 (+79%) 🔻1448 🔴 (+44%) 🔻1489 🔴 (+47%) 🔻1737 🔴 (+15%) 🔻30
TTFSstream317 (-67%) 💚1381 🔴 (+40%) 🔻1410 🔴 (+40%) 🔻1505 🔴 (+43%) 🔻30
TTFShook + stream1618 (+33%) 🔻1718 🔴 (+33%) 🔻1786 🔴 (+33%) 🔻1875 🔴 (+16%) 🔻30
STSO1020 steps (inline)1311782034061019
WO1020 steps180588 (-53%) 💚180588 (-53%) 💚180588 (-53%) 💚180588 (-53%) 💚1
SLstream latency109 (+35%) 🔻139 🔴 (+5.3%)151 🔴 (+7.9%)210 🔴 (+17%) 🔻30
SOstream overhead (text)130 (+29%) 🔻238 (+32%) 🔻309 (+53%) 🔻420 (+68%) 🔻30
SOstream overhead (structured)127 (+28%) 🔻206 (+27%) 🔻238 (+22%) 🔻303 (+40%) 🔻30
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: is main, marks where this run lands, bridges the gap when this run has more samples in a bucket.

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) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

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) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

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.

@github-actions

github-actionsBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 Mint-ordered log — 6 fail of 41 total

log=mint-ordered · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted171.0mMISMATCH1
stale-read-equal-step-countscompleted141.0mMISMATCH1
step-vs-step-forkcompleted120msMISMATCH1
step-vs-step-fork-fencedcompleted120msMISMATCH1
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mMISMATCH1
in-flight-before-decision-countedcompleted201.0mok0
in-flight-after-decisionfailed142.0mMISMATCH1
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-mint.txt

🟢 Append-only log — 0 fail of 41 total

log=append-only · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted201.0mok0
stale-read-equal-step-countscompleted141.0mok0
step-vs-step-forkcompleted120msok0
step-vs-step-fork-fencedcompleted120msok0
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mok0
in-flight-before-decision-countedcompleted171.0mok0
in-flight-after-decisioncompleted192.0mok0
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-append-only.txt

@VaguelySerious

Copy link
Copy Markdown
MemberAuthor

CI status

E2E Required Check is red for exactly one reason, and it is not this PR.

UNIT_STATUS: failure
BUILD_STATUS: success
VERCEL_STATUS: success
LOCAL_DEV_STATUS: success
LOCAL_PROD_STATUS: success
POSTGRES_STATUS: success
WINDOWS_STATUS: success
unit (failure)

Unit Tests (windows-latest) times out in packages/world-local/src/storage.test.ts:1284, returns the complete preload when run_started is retried. That test writes 1000 sequential events and already carries an explicit 120_000 timeout.

It is red on main with the identical signature on the last four runs (31530061366, 31533009514, 31604115603, 31616799260) and passed on 08-10 (31446555530, 31431114276).

A marginal budget rather than a code regression. Whole-file duration on the Windows runner, same 245 tests either side of the boundary:

Runsrc/storage.test.tsResult
93641986199 (08-11 00:40, last green)143491mspass
94189757184 (this PR)154816msthis one test times out

8% apart. An added fs op per write would show a much bigger jump than that. Locally create is linear at ~0.95ms regardless of n (measured at n = 250 / 500 / 1000 / 2000), and the whole test runs in 1.17s on macOS.

Not fixing it here: this PR does not touch storage.test.ts, and the fix (parallelize the 1000 writes, or raise the budget with a stated reason) belongs on main rather than folded into a corruption fix. Flagging it because it blocks the required aggregate on every PR, not only this one.

Vercel – workbench-python-workflow also fails on main and every PR, and is not required.

Repro script, postgres lane

Checked that the --world refactor did not break the default path. Full build, container bring-up, migrations, harness: 13/14 completed, 0 CORRUPTED_EVENT_LOG, 1 hook-stormstuck, which is the documented local-runner artifact (one Next.js process holding every replay).

@pranaygppranaygp 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.

Reviewed with a focus on the testing story and on whether this closes all the slot-hole paths. The staging-nonce fix is correct and the crash-window regression guard is real — I verified 545/545 on the branch, tsc --noEmit clean, and that tests 1 and 2 fail deterministically with the fix reverted.

Three findings, detailed inline:

  1. A duplicate-resume window survives the fix (events-storage.ts): when the pinned adopter of a resume claim wins the promote link(2), the unpinned claim owner bumps and publishes a second hook_received for the same resumeId. Reproduced deterministically; repro in the inline comment. Not a CORRUPTED_EVENT_LOG (the log stays dense) — it's a violation of the dedup contract hook-resume-dedup.test.ts asserts, and it pre-exists this PR in a sibling interleaving.
  2. Test 3 is not a regression guard: it passes with the fix reverted (5/5 runs). Tests 1 and 2 do fail without the fix, so the PR description's claim is accurate for those two only.
  3. Minor: a nextjs app-name glob in the repro script doesn't match setupWorld's substring check, which could split-brain the data dir for future app names.

Also audited world-postgres for the analogous hole since it arbitrates slots differently: it doesn't have one. nextSlotId computes the position inside the INSERT itself (storage.ts:167) — nothing reserves a slot ahead of the write, so a rejected or crashed hook_received rolls back its transaction and leaves the numbering untouched; the terminal-race guard is a FOR UPDATE on the run row in the same transaction (storage.ts:1944). It also has no lazy resume-dedup path at all (no resumeId column, per the deliberate omission in drizzle/schema.ts:145), so the duplicate-resume window doesn't apply there either.

// read. Answer it the same way rather than reporting a conflict
// the caller cannot act on: the resume IS committed, exactly once,
// and the dedup contract is that both writers return that event.
if (eventIdPinned && data.eventType === 'hook_received') {

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.

This convergence covers the pinned loser, but the symmetric case is still open and produces a duplicate hook_received for one resumeId.

On a slot run the claim owner is unpinned (eventIdPinned = !isSlotEventId(eventId) up at the claim write), while a racing taker that adopts the claim is pinned. Both stage nonced files and race the promote at the claimed position S. If the adopter wins the link, the owner's promote returns 'exists', bumpEventSlot is allowed to move it (it isn't pinned), and the owner publishes a second hook_received for the same resume at S+1. Both callers return success; the log stays dense, so no CORRUPTED_EVENT_LOG — but replay now delivers one resume twice, which is exactly what the "collapses the two writers of ONE resume onto a single event" contract in hook-resume-dedup.test.ts forbids. (The claim rewrite after the owner's publish points redeliveries at the second event, so later converges mask it — but both events stay in the log and both replay.)

I reproduced this deterministically on this branch by parking the first promoteExclusive caller (the owner — it has a head start, since the adopter does a full findCommittedResumeEvent scan first) until the second caller has linked:

repro test (drop into src/storage/, uses a partial vi.mock of ../fs.js)
constgate={armed: false,released: false,firstRelease: nullas(()=>void)|null};vi.mock('../fs.js',async(importOriginal)=>{constactual=(awaitimportOriginal())asRecord<string,any>;return{
...actual,promoteExclusive: async(stagedPath: string,filePath: string)=>{if(gate.armed&&!gate.released){if(gate.firstRelease===null){awaitnewPromise<void>((r)=>{gate.firstRelease=r;});returnactual.promoteExclusive(stagedPath,filePath);}constresult=awaitactual.promoteExclusive(stagedPath,filePath);gate.released=true;gate.firstRelease?.();returnresult;}returnactual.promoteExclusive(stagedPath,filePath);},};});// setup: createRun + createHook, then:gate.armed=true;constresults=awaitPromise.allSettled([storage,createStorage(testDir)].map((inst)=>inst.events.create(runId,{eventType: 'hook_received',specVersion: SPEC_VERSION_CURRENT,correlationId: hook.hookId,eventData: {token: hook.token,payload: newUint8Array([1])},},{resumeId: 'resume_1',resumePayloadDigest: 'resume_1'})));

Result: zero rejections, and the log holds evnt_…003:hook_receivedandevnt_…004:hook_received, both with resumeId: 'resume_1'.

To be clear about provenance: this window is not introduced here — pre-PR, the same interleaving existed at the staging write (adopter stages the id-keyed path first → owner collides → bumps → duplicates). The nonce moves the collision from staging to promote, but the owner-loses arm is still resolved by bumping.

Suggested fix, symmetric with the one you added: run this occupant/isResumeEvent convergence for hook_received with a resumeIdbeforebumpEventSlot, regardless of pinning — if the occupant at the lost position is this same resume, return it instead of bumping. The pinned case then falls out of the same check, and an unrelated occupant still bumps (unpinned) or conflicts (pinned) as today.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Confirmed, and fixed in c6bf782.

I verified the asymmetry before changing anything: on a slot run the claim owner keeps its own id and eventIdPinned = !isSlotEventId(eventId) leaves it unpinned, while the adopter is pinned to the claimed position. So the loser of the promote can be the unpinned owner, it bumps, and a second hook_received lands for one resumeId.

The convergence now runs inside the publish loop, ahead of bumpEventSlot, for any hook_received carrying a resumeId, with no pinning condition. An occupant that is not this resume is the unrelated-event collision the bump exists for and still bumps, or conflicts when pinned. The post-loop pinned block is gone, since a resume that lost to its own committed event now returns from inside the loop, and reaching the end means the occupant is unrelated.

Regression guard: writes one event when the claim owner loses the position to an adopter. It mocks promoteExclusive to park whichever caller arrives first until the other has linked, which is the interleaving that decides the winner. Falsifiability check: restoring the eventIdPinned && guard makes it fail with expected [ { …(8) }, { …(8) } ] to have a length of 1 but got 2.

expect(data.filter((e) => e.eventType === 'hook_received')).toHaveLength(1);
});

it('keeps the log dense when two instances resume the same hook at once', async () => {

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.

This test passes without the fix — I reverted the two events-storage.ts hunks (keeping the tests) and ran this file five times: tests 1 and 2 failed deterministically every run, but this one passed 5/5.

That matches the old code's behavior for this shape: with two live stagers and no terminal transition, the bumped-off slot always gets backfilled — the loser of the id-keyed staging write bumps to N+1, but the staging winner still promotes N, so the log ends dense either way. The hole needs the stager to be rejected (terminal marker / reap) or crashed, which is what test 1 stages.

So the PR description's "Each regression guard was verified to fail with the fix removed" doesn't hold for this one. Two options:

  • make it a real guard by racing a terminal transition into the window (stager drawn and staged, then reaped, then a later writer draws the same slot), or
  • keep it as-is but reword the description/comment to position it as a sanity check that the new promote-point arbitration keeps density under live-vs-live contention (which it does usefully exercise — the 'exists' → bump path now only exists at the promote).

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

You're right, and thanks for actually running it rather than reading it. With two live stagers and no terminal transition the pre-fix code also ends dense, because the writer bumped off the position is the one that goes on to publish it. The hole needs the stager to be rejected or killed, which is what the crashed-attempt test stages.

Rather than contrive a guard here, I reframed it honestly and let the new owner/adopter test carry the weight. It is now keeps the log dense under live contention on one position, and the comment says outright that density here is not a regression guard, plus what it does cover: arbitrating at the promote still resolves two instances drawing the same position, which is the CLI-plus-app configuration this backend supports (each instance's allocator watermark is its own, so both hand out the same candidate).

Also corrected the PR description, which claimed all three tests fail with the fix removed. True for tests 1, 2, and the new one; not for this one.

# an absolute path so the app's cwd does not enter into it. `setupWorld`
# recomputes the same path for the harness process.
case "$APP_NAME" in
nextjs*|next-*) DATA_DIR_NAME=".next/workflow-data" ;;

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.

This prefix glob doesn't mirror the harness's derivation. setupWorld (packages/core/e2e/utils.ts:507) uses substring matching:

constisNextJs=appName.includes('nextjs')||appName.includes('next-');

so an app named e.g. example-nextjs would get .next/workflow-data from the harness but .workflow-data from this case — the exact silent split-brain the comment above warns about, where the app and the harness talk to different directories. Today it can't bite (only nextjs-turbopack/nextjs-webpack carry the repro fixtures, and both match), but since the whole point of this block is pinning the two processes to one path:

*nextjs*|*next-*) DATA_DIR_NAME=".next/workflow-data";;

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Good catch. Fixed to *nextjs*|*next-*) so it mirrors setupWorld. I checked packages/core/e2e/utils.ts:507 and it is the substring form you quoted, so an app named example-nextjs would have sent the app to .workflow-data and the harness to .next/workflow-data, which is exactly the split-brain the block exists to prevent. Comment above the case now states why it is a substring match.

if (await bumpEventSlot(attempt)) {
continue;
}
// A nonced path cannot already exist. Surfacing rather than

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.

Nit: the comment is right that this can't be a real duplicate — which makes EntityConflictError ("Event already exists") a slightly misleading surface for it. EntityConflictError is the shape the runtime's concurrent-replay paths treat as a benign duplicate publish, so a genuine filesystem fault here would get absorbed as "someone else already wrote it" instead of surfacing as infra trouble. A WorkflowWorldError would keep the impossible case loud. Fine to leave if you'd rather not grow the error surface, since the branch is effectively unreachable.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Taken. It is now a WorkflowWorldError. EntityConflictError is the shape the runtime reads as a benign duplicate publish, so raising it for a nonced path that cannot collide would absorb a filesystem fault as "someone else already wrote it". The comment says that.

Only one of the two takers of a resume claim is pinned. The taker that
writes the claim keeps its own id, unpinned, because a slot is a position
another instance also hands out for unrelated events and refusing to move
would fail the append outright. The taker that adopts an existing claim is
pinned to the claimed position.
So the loser of the promote can be the unpinned owner, and a loser that
bumps publishes a second `hook_received` for one resumeId. The log stays
dense and both callers report success, but replay delivers the resume
twice, which is what the dedup contract forbids.
Run the occupant convergence inside the publish loop, before the bump, for
any `hook_received` carrying a resumeId. An occupant that is not this
resume is the unrelated-event collision the bump is for and still bumps,
or conflicts when pinned. The post-loop pinned check is now redundant.
Also:
- staging-path faults raise WorkflowWorldError rather than
EntityConflictError, which the runtime absorbs as a benign duplicate.
- the repro script's app-name case mirrors `setupWorld`'s substring match
so a future `example-nextjs` cannot split-brain the data dir.
- the live-vs-live density test says what it actually guards: it passes
without the fix, because a bumped-off position still gets published by
the stager that won it.

@pranaygppranaygp 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.

All four findings addressed in c6bf782, and I re-verified each empirically on the branch:

  • Duplicate-resume window: the convergence now runs inside the publish loop before bumpEventSlot, ungated on pinning — exactly the symmetric fix. I confirmed the new writes one event when the claim owner loses the position to an adopter guard is real: with that hunk reverted it fails deterministically (length of 1 but got 2, the duplicate), and with it the file and the full suite pass (546/546, tsc --noEmit clean).
  • Density test: honestly reframed as not-a-regression-guard, and the PR description now matches what the tests actually prove.
  • Script glob mirrors setupWorld's substring match, with the reasoning in a comment.
  • Staging fault now raises WorkflowWorldError so infra trouble can't be absorbed as a benign duplicate.

One non-blocking observation, pre-existing and inherited rather than introduced: isResumeEvent matches an occupant with resumeId === undefined by position, so the in-loop convergence could in principle adopt a plain-pathhook_received (no resumeId) for the same hook as this resume's committed event, dropping the resume's payload. That requires concurrently mixing the lazy and plain resume paths on one hook plus a slot collision — outside the dedup contract's supported shape, and the same predicate already governs converge/findCommittedResumeEvent. Fine to leave; noting it in case slot-run positional matching ever gets tightened.

@VaguelySerious

Copy link
Copy Markdown
MemberAuthor

CI update: everything is green except Unit Tests (windows-latest), which fails at packages/world-local/src/storage.test.ts:1284 with Test timed out in 120000ms. Same signature on main's last runs, so it is not from this PR.

I opened #3503 to fix it on main. It is a marginal budget rather than a step regression: same 245 tests, whole-file duration on the Windows runner went 143491ms (last green) to 154816ms (first red), 8% apart. Since Unit Tests feeds E2E Required Check, that PR is what unblocks the required aggregate here.

@VaguelySerious
VaguelySerious merged commit 0f4b35f into mainAug 12, 2026
160 of 165 checks passed
@VaguelySerious
VaguelySerious deleted the peter/world-local-event-race branch August 12, 2026 20:16
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 0f4b35f (AI decision).

This is a genuine correctness fix, but it targets code that only exists on main: origin/stable's packages/world-local/src/storage/events-storage.ts has no pendingHookEventPath staging, no slot-based event ids, and no bumpEventSlot/notePublishedSlot allocator, so the CORRUPTED_EVENT_LOG hole and the duplicate-resume path being fixed cannot occur there. The remaining changes are also main-only or unmaintained on stablescripts/event-log-race-repro-local.sh is absent from origin/stable, and the AGENTS.md/new-test changes describe main-only harness and storage behavior.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

0f4b35f62945327417013060f6e5de5111fe6ff1

Sign up for freeto 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.

2 participants

@VaguelySerious@pranaygp
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); [world-local] Fix CORRUPTED_EVENT_LOG from hook-resume staging slot holes by VaguelySerious · Pull Request #3492 · vercel/workflow · GitHub
Skip to content

[world-local] Fix CORRUPTED_EVENT_LOG from hook-resume staging slot holes - #3492

Merged
VaguelySerious merged 2 commits into
mainfrom
peter/world-local-event-race
Aug 12, 2026
Merged

[world-local] Fix CORRUPTED_EVENT_LOG from hook-resume staging slot holes#3492
VaguelySerious merged 2 commits into
mainfrom
peter/world-local-event-race

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented Aug 12, 2026

Copy link
Copy Markdown
Member

What

Three fixes in @workflow/world-local, plus the local event-log race repro script gains a --world local lane.

The corruption

hook_received is the one event that does not publish straight into events/. It stages under .locks first, so a terminal transition can reap it before it ever becomes reader-visible (#2987). That staging path was keyed by the event id alone, and a collision on it bumped the writer to the next slot.

Under slot event ids (#3389) that bump is wrong. The slot allocator probes events/ and nothing else, so a staged file is not evidence the position is taken. Bumping moves the writer past a position nothing will ever fill, notePublishedSlot advances the watermark past it, and scanRunEventIds is max-based so no later writer backfills. The runtime reads the missing position as a durable hole and fails the run with CORRUPTED_EVENT_LOG.

Two ways in:

  1. An attempt killed between staging and promoting leaves its staged file behind. Its cleanup lives in a finally the kill skips, and the only other reaper runs on a terminal transition the run has not reached. Every later writer that draws that slot bumps off it, permanently.
  2. Two live writers draw the same candidate and the stager is later rejected by the terminal marker or the reap. The loser bumped off a slot the stager never published.

Provenance: staging arrived in 850777a03b (#2987) when ids were ULIDs, where the old comment was correct that the path "can only be occupied by a previous crashed attempt of this very event". 6786db9953 (#3389) turned ids into positions and kept the id-keyed staging name.

Fix: the staging name carries a nonce, so it is private to one attempt. The slot is then arbitrated only where it is actually taken, at the promote. This restores the file's own stated invariant: a slot is claimed by the publish that occupies it, never reserved ahead of a write that might still be rejected.

The spurious conflict, and the duplicate resume

When a resume loses the publish, the event now at that position can be the same resume, written by the other taker of the claim. That is the convergence the claim exists to force, and converge earlier in the function already answers it with the committed event. It just could not see it yet, because the other taker had not published when this attempt read.

The first pass answered that only for a pinned loser, which left the symmetric case open (caught in review). Only one of the two takers of a claim is pinned: the taker that writes the claim keeps its own id, unpinned, because a slot is a position another instance also hands out for unrelated events and refusing to move would fail this resume's append outright. The taker that adopts an existing claim is pinned to the claimed position. So the loser of the promote can be the unpinned owner, and a loser that bumps publishes a second hook_received for one resumeId. Nothing looks wrong afterwards (the log stays dense, both callers report success) but the resume is delivered twice on replay.

Fix: run the occupant convergence inside the publish loop, ahead of the bump, for any hook_received carrying a resumeId, with no pinning condition. An occupant that is not this resume is the unrelated-event collision the bump is for and still bumps, or conflicts when pinned. Both takers return the one committed event, matching the dedup contract hook-resume-dedup.test.ts already asserts. Reporting EntityConflictError instead gave the caller an error it cannot act on for a resume that did land (HTTP 500, queue retry).

Separately, a failure to stage under the nonced path now raises WorkflowWorldError rather than EntityConflictError: a nonced path cannot collide, so it is a filesystem fault, and EntityConflictError is the shape the runtime absorbs as a benign duplicate publish.

Repro script

scripts/event-log-race-repro-local.sh was postgres-only. It now takes --world postgres|local (default postgres, unchanged). Under --world local it exports WORKFLOW_TARGET_WORLD=local plus an absolute WORKFLOW_LOCAL_DATA_DIR and WORKFLOW_LOCAL_QUEUE_CONCURRENCY, clears the data directory instead of the queue, and skips all container bring-up, migration, and teardown.

pnpm run test:e2e:event-log-race-repro:local --world local

Testing

New packages/world-local/src/storage/hook-staging-slots.test.ts, 4 tests. Three are regression guards verified to fail with the fix removed:

  • a crashed attempt's leftover staged file no longer holes the log. Before the fix this produced slots [1, 2, 4] instead of [1, 2, 3].
  • both writers of one raced resume get the committed event back rather than a conflict.
  • when the unpinned claim owner loses the position to an adopter, one hook_received is written and both takers return it. This one mocks promoteExclusive to park whichever caller reaches it first until the other has linked, since the outcome hinges on that interleaving. Restoring the eventIdPinned && guard makes it fail with to have a length of 1 but got 2.

The fourth, keeps the log dense under live contention on one position, is not a regression guard and its comment says so: with two live stagers and no terminal transition the pre-fix code also ended dense, because the writer it bumped off the position was the one that went on to publish it. It covers that arbitrating at the promote still resolves two instances drawing one position.

Full world-local suite: 546/546 across 16 files. tsc --noEmit clean.

On the storm harness, stated plainly: it has never produced a CORRUPTED_EVENT_LOG outcome or an on-disk hole against world-local, across one 14-run pass and two 16-run passes before the fix. The corruption is demonstrated by the unit test, not by the storm. What the storm does show is the conflict volume: ~12-27 EntityConflictError per pass before, 1 after (a hook_created benign duplicate, which is the documented path), with all 14 runs completing and every run dense on disk (count == max, 8931 event files checked).

This matches the note now in AGENTS.md: world-local's storms come out clean far more often than world-postgres's, so reach for a unit test in packages/world-local/src/storage/ when a suspected filesystem race can be staged directly.

`hook_received` is the only event that does not publish straight into
`events/`: it stages under `.locks` first so a terminal transition can
reap it before it becomes reader-visible. That staging path was keyed by
the event id alone, and a collision on it bumped the writer to the next
slot.
Under slot ids a staging collision is not evidence the position is
taken. The allocator probes `events/` only, so bumping moves the writer
past a position nothing will ever fill, and `scanRunEventIds` is
max-based so no later writer backfills it. The runtime reads the missing
position as a durable hole and fails the run with CORRUPTED_EVENT_LOG.
Two triggers: an attempt killed between staging and promoting leaves its
staged file behind (cleanup lives in a `finally` the kill skips, and the
only other reaper runs on a terminal transition), and two live writers
drawing the same candidate where the stager is later rejected.
Staging now carries a nonce, so it is private to one attempt and the
slot is arbitrated only where it is actually taken, at the promote.
Second fix: when a pinned resume loses the publish, the event at the
pinned position is that same resume written by the other taker, which is
the convergence the pin exists to force. Return that committed event
instead of an EntityConflictError the caller cannot act on.
Also re-purposes scripts/event-log-race-repro-local.sh to drive either
world with `--world postgres|local`.
@VaguelySerious
VaguelySerious requested a review from a team as a code ownerAugust 12, 2026 16:46
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

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

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewAug 12, 2026 7:53pm
example-nextjs-workflow-webpackReadyReadyPreviewAug 12, 2026 7:53pm
example-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-astro-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-express-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-fastify-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-hono-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-nestjs-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-nitro-workflowBuildingBuildingPreviewAug 12, 2026 7:53pm
workbench-nuxt-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-python-workflowErrorErrorAug 12, 2026 7:53pm
workbench-sveltekit-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-tanstack-start-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workbench-vite-workflowReadyReadyPreviewAug 12, 2026 7:53pm
workflow-docsReadyReadyPreview, v0Aug 12, 2026 7:53pm
workflow-swc-playgroundReadyReadyPreviewAug 12, 2026 7:53pm
workflow-tarballsReadyReadyPreviewAug 12, 2026 7:53pm
workflow-webReadyReadyPreviewAug 12, 2026 7:53pm

@changeset-bot

changeset-botBot commented Aug 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c6bf782

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

This PR includes changesets to release 18 packages
NameType
@workflow/world-localPatch
@workflow/cliPatch
@workflow/corePatch
@workflow/vitestPatch
@workflow/webPatch
@workflow/world-postgresPatch
workflowPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/web-sharedPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

@github-actions

github-actionsBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

❌ Failed E2E Tests

▲ Vercel Production (1 failed)

nextjs-turbopack-node (1 failed):

  • distributedAbortController - manual abort triggers signal | wrun_41KZVSGQDP0GJSR1W5GDTRB65H | 🔍 observability

E2E Test Summary

Summary
PassedFailedSkippedTotal
❌ ▲ Vercel Production331215873900
✅ 💻 Local Development381005584368
✅ 📦 Local Production381005584368
✅ 🐘 Local Postgres381005584368
✅ 🪟 Windows31200312
✅ vercel-multi-region270027
Total150811226117343
Details by Category

❌ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node128028
✅ astro-quickjs128028
✅ example-node128028
✅ example-quickjs128028
✅ express-node128028
✅ express-quickjs128028
✅ fastify-node128028
✅ fastify-quickjs128028
✅ hono-node128028
✅ hono-quickjs128028
✅ nest-node128028
✅ nest-quickjs128028
❌ nextjs-turbopack-node15213
✅ nextjs-webpack-node15303
✅ nextjs-webpack-quickjs15303
✅ nitro-node128028
✅ nitro-quickjs128028
✅ nuxt-node128028
✅ nuxt-quickjs128028
✅ sveltekit-node14709
✅ sveltekit-quickjs14709
✅ tanstack-start-node128028
✅ tanstack-start-quickjs128028
✅ vite-node128028
✅ vite-quickjs128028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack-node15600
✅ nextjs-turbopack-quickjs15600

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@github-actions

github-actionsBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit c6bf782 · Wed, 12 Aug 2026 20:08:57 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1361 (+513%) 🔻1553 🔴 (+39%) 🔻1591 🔴 (+36%) 🔻1914 🔴 (+9.6%)30
TTFSstream1359 (+427%) 🔻1519 🔴 (+37%) 🔻1595 🔴 (+42%) 🔻1621 🔴 (+5.7%)30
TTFShook + stream1593 (+331%) 🔻1805 🔴 (+32%) 🔻1866 🔴 (+30%) 🔻1973 🔴 (-57%) 💚30
STSO1020 steps (inline)133 (-2.9%)177 (-16%) 💚198 (-19%) 💚296 (-26%) 💚1019
WO1020 steps176010 (-14%)176010 (-14%)176010 (-14%)176010 (-14%)1
SLstream latency109 (+18%) 🔻154 🔴 (+4.8%)182 🔴 (+0.6%)3136 🔴 (+602%) 🔻30
SOstream overhead (text)128 (+4.9%)185 (-31%) 💚210 (-57%) 💚282 (-71%) 💚30
SOstream overhead (structured)121 (-2.4%)169 (-39%) 💚198 (-77%) 💚235 (-99%) 💚30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 204038ms → this run 174609ms (Δ -29429ms, -14%)

 100-150 ms █░░┃ main 11 this 137 +126
150-200 ms ████████████████████░░░┃ main 643 this 785 +142
200-250 ms █┃██████ main 277 this 69 -208
250-300 ms ┃█ main 55 this 18 -37
300-350 ms ┃ main 10 this 6 -4
350-400 ms ┃ main 12 this 3 -9
400-450 ms ┃ main 3 this 0 -3
450-500 ms ┃ main 1 this 1 +0
550-600 ms ┃ main 1 this 0 -1
600-650 ms ┃ main 1 this 0 -1
650-700 ms ┃ main 4 this 0 -4
1000-1050 ms ┃ main 1 this 0 -1
📜 Previous results (1)

0161a14

Wed, 12 Aug 2026 17:06:24 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1262 (+79%) 🔻1448 🔴 (+44%) 🔻1489 🔴 (+47%) 🔻1737 🔴 (+15%) 🔻30
TTFSstream317 (-67%) 💚1381 🔴 (+40%) 🔻1410 🔴 (+40%) 🔻1505 🔴 (+43%) 🔻30
TTFShook + stream1618 (+33%) 🔻1718 🔴 (+33%) 🔻1786 🔴 (+33%) 🔻1875 🔴 (+16%) 🔻30
STSO1020 steps (inline)1311782034061019
WO1020 steps180588 (-53%) 💚180588 (-53%) 💚180588 (-53%) 💚180588 (-53%) 💚1
SLstream latency109 (+35%) 🔻139 🔴 (+5.3%)151 🔴 (+7.9%)210 🔴 (+17%) 🔻30
SOstream overhead (text)130 (+29%) 🔻238 (+32%) 🔻309 (+53%) 🔻420 (+68%) 🔻30
SOstream overhead (structured)127 (+28%) 🔻206 (+27%) 🔻238 (+22%) 🔻303 (+40%) 🔻30
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: is main, marks where this run lands, bridges the gap when this run has more samples in a bucket.

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) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

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) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

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.

@github-actions

github-actionsBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 Mint-ordered log — 6 fail of 41 total

log=mint-ordered · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted171.0mMISMATCH1
stale-read-equal-step-countscompleted141.0mMISMATCH1
step-vs-step-forkcompleted120msMISMATCH1
step-vs-step-fork-fencedcompleted120msMISMATCH1
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mMISMATCH1
in-flight-before-decision-countedcompleted201.0mok0
in-flight-after-decisionfailed142.0mMISMATCH1
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-mint.txt

🟢 Append-only log — 0 fail of 41 total

log=append-only · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted201.0mok0
stale-read-equal-step-countscompleted141.0mok0
step-vs-step-forkcompleted120msok0
step-vs-step-fork-fencedcompleted120msok0
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mok0
in-flight-before-decision-countedcompleted171.0mok0
in-flight-after-decisioncompleted192.0mok0
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-append-only.txt

@VaguelySerious

Copy link
Copy Markdown
MemberAuthor

CI status

E2E Required Check is red for exactly one reason, and it is not this PR.

UNIT_STATUS: failure
BUILD_STATUS: success
VERCEL_STATUS: success
LOCAL_DEV_STATUS: success
LOCAL_PROD_STATUS: success
POSTGRES_STATUS: success
WINDOWS_STATUS: success
unit (failure)

Unit Tests (windows-latest) times out in packages/world-local/src/storage.test.ts:1284, returns the complete preload when run_started is retried. That test writes 1000 sequential events and already carries an explicit 120_000 timeout.

It is red on main with the identical signature on the last four runs (31530061366, 31533009514, 31604115603, 31616799260) and passed on 08-10 (31446555530, 31431114276).

A marginal budget rather than a code regression. Whole-file duration on the Windows runner, same 245 tests either side of the boundary:

Runsrc/storage.test.tsResult
93641986199 (08-11 00:40, last green)143491mspass
94189757184 (this PR)154816msthis one test times out

8% apart. An added fs op per write would show a much bigger jump than that. Locally create is linear at ~0.95ms regardless of n (measured at n = 250 / 500 / 1000 / 2000), and the whole test runs in 1.17s on macOS.

Not fixing it here: this PR does not touch storage.test.ts, and the fix (parallelize the 1000 writes, or raise the budget with a stated reason) belongs on main rather than folded into a corruption fix. Flagging it because it blocks the required aggregate on every PR, not only this one.

Vercel – workbench-python-workflow also fails on main and every PR, and is not required.

Repro script, postgres lane

Checked that the --world refactor did not break the default path. Full build, container bring-up, migrations, harness: 13/14 completed, 0 CORRUPTED_EVENT_LOG, 1 hook-stormstuck, which is the documented local-runner artifact (one Next.js process holding every replay).

@pranaygppranaygp 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.

Reviewed with a focus on the testing story and on whether this closes all the slot-hole paths. The staging-nonce fix is correct and the crash-window regression guard is real — I verified 545/545 on the branch, tsc --noEmit clean, and that tests 1 and 2 fail deterministically with the fix reverted.

Three findings, detailed inline:

  1. A duplicate-resume window survives the fix (events-storage.ts): when the pinned adopter of a resume claim wins the promote link(2), the unpinned claim owner bumps and publishes a second hook_received for the same resumeId. Reproduced deterministically; repro in the inline comment. Not a CORRUPTED_EVENT_LOG (the log stays dense) — it's a violation of the dedup contract hook-resume-dedup.test.ts asserts, and it pre-exists this PR in a sibling interleaving.
  2. Test 3 is not a regression guard: it passes with the fix reverted (5/5 runs). Tests 1 and 2 do fail without the fix, so the PR description's claim is accurate for those two only.
  3. Minor: a nextjs app-name glob in the repro script doesn't match setupWorld's substring check, which could split-brain the data dir for future app names.

Also audited world-postgres for the analogous hole since it arbitrates slots differently: it doesn't have one. nextSlotId computes the position inside the INSERT itself (storage.ts:167) — nothing reserves a slot ahead of the write, so a rejected or crashed hook_received rolls back its transaction and leaves the numbering untouched; the terminal-race guard is a FOR UPDATE on the run row in the same transaction (storage.ts:1944). It also has no lazy resume-dedup path at all (no resumeId column, per the deliberate omission in drizzle/schema.ts:145), so the duplicate-resume window doesn't apply there either.

// read. Answer it the same way rather than reporting a conflict
// the caller cannot act on: the resume IS committed, exactly once,
// and the dedup contract is that both writers return that event.
if (eventIdPinned && data.eventType === 'hook_received') {

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.

This convergence covers the pinned loser, but the symmetric case is still open and produces a duplicate hook_received for one resumeId.

On a slot run the claim owner is unpinned (eventIdPinned = !isSlotEventId(eventId) up at the claim write), while a racing taker that adopts the claim is pinned. Both stage nonced files and race the promote at the claimed position S. If the adopter wins the link, the owner's promote returns 'exists', bumpEventSlot is allowed to move it (it isn't pinned), and the owner publishes a second hook_received for the same resume at S+1. Both callers return success; the log stays dense, so no CORRUPTED_EVENT_LOG — but replay now delivers one resume twice, which is exactly what the "collapses the two writers of ONE resume onto a single event" contract in hook-resume-dedup.test.ts forbids. (The claim rewrite after the owner's publish points redeliveries at the second event, so later converges mask it — but both events stay in the log and both replay.)

I reproduced this deterministically on this branch by parking the first promoteExclusive caller (the owner — it has a head start, since the adopter does a full findCommittedResumeEvent scan first) until the second caller has linked:

repro test (drop into src/storage/, uses a partial vi.mock of ../fs.js)
constgate={armed: false,released: false,firstRelease: nullas(()=>void)|null};vi.mock('../fs.js',async(importOriginal)=>{constactual=(awaitimportOriginal())asRecord<string,any>;return{
...actual,promoteExclusive: async(stagedPath: string,filePath: string)=>{if(gate.armed&&!gate.released){if(gate.firstRelease===null){awaitnewPromise<void>((r)=>{gate.firstRelease=r;});returnactual.promoteExclusive(stagedPath,filePath);}constresult=awaitactual.promoteExclusive(stagedPath,filePath);gate.released=true;gate.firstRelease?.();returnresult;}returnactual.promoteExclusive(stagedPath,filePath);},};});// setup: createRun + createHook, then:gate.armed=true;constresults=awaitPromise.allSettled([storage,createStorage(testDir)].map((inst)=>inst.events.create(runId,{eventType: 'hook_received',specVersion: SPEC_VERSION_CURRENT,correlationId: hook.hookId,eventData: {token: hook.token,payload: newUint8Array([1])},},{resumeId: 'resume_1',resumePayloadDigest: 'resume_1'})));

Result: zero rejections, and the log holds evnt_…003:hook_receivedandevnt_…004:hook_received, both with resumeId: 'resume_1'.

To be clear about provenance: this window is not introduced here — pre-PR, the same interleaving existed at the staging write (adopter stages the id-keyed path first → owner collides → bumps → duplicates). The nonce moves the collision from staging to promote, but the owner-loses arm is still resolved by bumping.

Suggested fix, symmetric with the one you added: run this occupant/isResumeEvent convergence for hook_received with a resumeIdbeforebumpEventSlot, regardless of pinning — if the occupant at the lost position is this same resume, return it instead of bumping. The pinned case then falls out of the same check, and an unrelated occupant still bumps (unpinned) or conflicts (pinned) as today.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Confirmed, and fixed in c6bf782.

I verified the asymmetry before changing anything: on a slot run the claim owner keeps its own id and eventIdPinned = !isSlotEventId(eventId) leaves it unpinned, while the adopter is pinned to the claimed position. So the loser of the promote can be the unpinned owner, it bumps, and a second hook_received lands for one resumeId.

The convergence now runs inside the publish loop, ahead of bumpEventSlot, for any hook_received carrying a resumeId, with no pinning condition. An occupant that is not this resume is the unrelated-event collision the bump exists for and still bumps, or conflicts when pinned. The post-loop pinned block is gone, since a resume that lost to its own committed event now returns from inside the loop, and reaching the end means the occupant is unrelated.

Regression guard: writes one event when the claim owner loses the position to an adopter. It mocks promoteExclusive to park whichever caller arrives first until the other has linked, which is the interleaving that decides the winner. Falsifiability check: restoring the eventIdPinned && guard makes it fail with expected [ { …(8) }, { …(8) } ] to have a length of 1 but got 2.

expect(data.filter((e) => e.eventType === 'hook_received')).toHaveLength(1);
});

it('keeps the log dense when two instances resume the same hook at once', async () => {

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.

This test passes without the fix — I reverted the two events-storage.ts hunks (keeping the tests) and ran this file five times: tests 1 and 2 failed deterministically every run, but this one passed 5/5.

That matches the old code's behavior for this shape: with two live stagers and no terminal transition, the bumped-off slot always gets backfilled — the loser of the id-keyed staging write bumps to N+1, but the staging winner still promotes N, so the log ends dense either way. The hole needs the stager to be rejected (terminal marker / reap) or crashed, which is what test 1 stages.

So the PR description's "Each regression guard was verified to fail with the fix removed" doesn't hold for this one. Two options:

  • make it a real guard by racing a terminal transition into the window (stager drawn and staged, then reaped, then a later writer draws the same slot), or
  • keep it as-is but reword the description/comment to position it as a sanity check that the new promote-point arbitration keeps density under live-vs-live contention (which it does usefully exercise — the 'exists' → bump path now only exists at the promote).

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

You're right, and thanks for actually running it rather than reading it. With two live stagers and no terminal transition the pre-fix code also ends dense, because the writer bumped off the position is the one that goes on to publish it. The hole needs the stager to be rejected or killed, which is what the crashed-attempt test stages.

Rather than contrive a guard here, I reframed it honestly and let the new owner/adopter test carry the weight. It is now keeps the log dense under live contention on one position, and the comment says outright that density here is not a regression guard, plus what it does cover: arbitrating at the promote still resolves two instances drawing the same position, which is the CLI-plus-app configuration this backend supports (each instance's allocator watermark is its own, so both hand out the same candidate).

Also corrected the PR description, which claimed all three tests fail with the fix removed. True for tests 1, 2, and the new one; not for this one.

# an absolute path so the app's cwd does not enter into it. `setupWorld`
# recomputes the same path for the harness process.
case "$APP_NAME" in
nextjs*|next-*) DATA_DIR_NAME=".next/workflow-data" ;;

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.

This prefix glob doesn't mirror the harness's derivation. setupWorld (packages/core/e2e/utils.ts:507) uses substring matching:

constisNextJs=appName.includes('nextjs')||appName.includes('next-');

so an app named e.g. example-nextjs would get .next/workflow-data from the harness but .workflow-data from this case — the exact silent split-brain the comment above warns about, where the app and the harness talk to different directories. Today it can't bite (only nextjs-turbopack/nextjs-webpack carry the repro fixtures, and both match), but since the whole point of this block is pinning the two processes to one path:

*nextjs*|*next-*) DATA_DIR_NAME=".next/workflow-data";;

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Good catch. Fixed to *nextjs*|*next-*) so it mirrors setupWorld. I checked packages/core/e2e/utils.ts:507 and it is the substring form you quoted, so an app named example-nextjs would have sent the app to .workflow-data and the harness to .next/workflow-data, which is exactly the split-brain the block exists to prevent. Comment above the case now states why it is a substring match.

if (await bumpEventSlot(attempt)) {
continue;
}
// A nonced path cannot already exist. Surfacing rather than

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.

Nit: the comment is right that this can't be a real duplicate — which makes EntityConflictError ("Event already exists") a slightly misleading surface for it. EntityConflictError is the shape the runtime's concurrent-replay paths treat as a benign duplicate publish, so a genuine filesystem fault here would get absorbed as "someone else already wrote it" instead of surfacing as infra trouble. A WorkflowWorldError would keep the impossible case loud. Fine to leave if you'd rather not grow the error surface, since the branch is effectively unreachable.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Taken. It is now a WorkflowWorldError. EntityConflictError is the shape the runtime reads as a benign duplicate publish, so raising it for a nonced path that cannot collide would absorb a filesystem fault as "someone else already wrote it". The comment says that.

Only one of the two takers of a resume claim is pinned. The taker that
writes the claim keeps its own id, unpinned, because a slot is a position
another instance also hands out for unrelated events and refusing to move
would fail the append outright. The taker that adopts an existing claim is
pinned to the claimed position.
So the loser of the promote can be the unpinned owner, and a loser that
bumps publishes a second `hook_received` for one resumeId. The log stays
dense and both callers report success, but replay delivers the resume
twice, which is what the dedup contract forbids.
Run the occupant convergence inside the publish loop, before the bump, for
any `hook_received` carrying a resumeId. An occupant that is not this
resume is the unrelated-event collision the bump is for and still bumps,
or conflicts when pinned. The post-loop pinned check is now redundant.
Also:
- staging-path faults raise WorkflowWorldError rather than
EntityConflictError, which the runtime absorbs as a benign duplicate.
- the repro script's app-name case mirrors `setupWorld`'s substring match
so a future `example-nextjs` cannot split-brain the data dir.
- the live-vs-live density test says what it actually guards: it passes
without the fix, because a bumped-off position still gets published by
the stager that won it.

@pranaygppranaygp 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.

All four findings addressed in c6bf782, and I re-verified each empirically on the branch:

  • Duplicate-resume window: the convergence now runs inside the publish loop before bumpEventSlot, ungated on pinning — exactly the symmetric fix. I confirmed the new writes one event when the claim owner loses the position to an adopter guard is real: with that hunk reverted it fails deterministically (length of 1 but got 2, the duplicate), and with it the file and the full suite pass (546/546, tsc --noEmit clean).
  • Density test: honestly reframed as not-a-regression-guard, and the PR description now matches what the tests actually prove.
  • Script glob mirrors setupWorld's substring match, with the reasoning in a comment.
  • Staging fault now raises WorkflowWorldError so infra trouble can't be absorbed as a benign duplicate.

One non-blocking observation, pre-existing and inherited rather than introduced: isResumeEvent matches an occupant with resumeId === undefined by position, so the in-loop convergence could in principle adopt a plain-pathhook_received (no resumeId) for the same hook as this resume's committed event, dropping the resume's payload. That requires concurrently mixing the lazy and plain resume paths on one hook plus a slot collision — outside the dedup contract's supported shape, and the same predicate already governs converge/findCommittedResumeEvent. Fine to leave; noting it in case slot-run positional matching ever gets tightened.

@VaguelySerious

Copy link
Copy Markdown
MemberAuthor

CI update: everything is green except Unit Tests (windows-latest), which fails at packages/world-local/src/storage.test.ts:1284 with Test timed out in 120000ms. Same signature on main's last runs, so it is not from this PR.

I opened #3503 to fix it on main. It is a marginal budget rather than a step regression: same 245 tests, whole-file duration on the Windows runner went 143491ms (last green) to 154816ms (first red), 8% apart. Since Unit Tests feeds E2E Required Check, that PR is what unblocks the required aggregate here.

@VaguelySerious
VaguelySerious merged commit 0f4b35f into mainAug 12, 2026
160 of 165 checks passed
@VaguelySerious
VaguelySerious deleted the peter/world-local-event-race branch August 12, 2026 20:16
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 0f4b35f (AI decision).

This is a genuine correctness fix, but it targets code that only exists on main: origin/stable's packages/world-local/src/storage/events-storage.ts has no pendingHookEventPath staging, no slot-based event ids, and no bumpEventSlot/notePublishedSlot allocator, so the CORRUPTED_EVENT_LOG hole and the duplicate-resume path being fixed cannot occur there. The remaining changes are also main-only or unmaintained on stablescripts/event-log-race-repro-local.sh is absent from origin/stable, and the AGENTS.md/new-test changes describe main-only harness and storage behavior.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

0f4b35f62945327417013060f6e5de5111fe6ff1

Sign up for freeto 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.

2 participants

@VaguelySerious@pranaygp