Skip to content

fix(core): preserve event-log order in hook-vs-sleep replay races - #2171

Merged
VaguelySerious merged 2 commits into
stablefrom
nate/fix-hook-sleep-replay-ordering
May 31, 2026
Merged

fix(core): preserve event-log order in hook-vs-sleep replay races#2171
VaguelySerious merged 2 commits into
stablefrom
nate/fix-hook-sleep-replay-ordering

Conversation

@TooTallNate

@TooTallNate TooTallNate commented May 30, 2026

Copy link
Copy Markdown
Member

Summary

Fixes a replay divergence where a buffered hook payload races a concurrent sleep, and sleep can win a Promise.race that the committed event log says the hook won — surfacing as CorruptedEventLogError on replay (seen in production as a step_created for one step being consumed by a different step's consumer).

Fixes tests introduced in #2169

Root cause

  • A buffered hook payload (a hook_received consumed before the workflow awaited the hook) was delivered to its consumer only at claim time (iterator.next() / await).
  • A concurrent wait_completed resolved synchronously in its promiseQueue slot — no hydration, fewer microtask hops — while the hook payload reaches the consumer through the async hook iterator (yield await this), which adds hops.
  • In Promise.race([hook, sleep]), sleep could therefore preempt an earlier-in-log hook payload, diverging from the committed log.

This is specific to hook-vs-sleep: hook-vs-step and two-hook ordering already resolve correctly (verified by the added characterization tests), because the existing serial promiseQueue discipline is decryption-time independent for those.

Fix (three timing-independent parts)

  1. Anchor resolution at log position. A buffered hook payload now resolves through a promiseQueue slot chained at its log position (not at the later claim site), so ordering follows the event log regardless of hydration/decryption time.
  2. Cross-entity ordering barrier. Each in-flight buffered delivery registers a barrier keyed by its source hook_received eventId (ctx.pendingHookDeliveries). A later-in-log entity (sleep) defers behind any earlier-in-log in-flight hook delivery via awaitEarlierHookDeliveries.
  3. Macrotask release (hop-count independent). The barrier releases on a macrotask (setTimeout(0)) after the payload is claimed, so the consumer's branch decision — however many await hops deep — always commits before the deferring entity proceeds. This reuses the same macrotask-boundary technique scheduleWhenIdle already relies on; no microtask-hop heuristic. awaitEarlierHookDeliveries bounds its wait with a one-macrotask fallback so an unclaimed payload can't deadlock a deferring entity.

Why timing-independent

Decryption time, hydration time, and consumer await-chain depth are all irrelevant: a macrotask runs only after the entire pending microtask queue drains. Empirically validated by modeling the race (the iterator path costs 3 microtask hops; a fixed-K microtask release was fragile — the macrotask boundary is not).

Tests

  • hook-sleep-interaction.test.ts: hook-vs-sleep (the repro, both sync + async-deser modes), hook-vs-step, two-hook slow-decrypt ordering.
  • Full @workflow/core suite: 630/630, typecheck clean, stable across repeated runs, no hangs.

Scope

Pre-existing runtime bug on stable; independent of the OCC / fenced-write work, so it can ship on its own.

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com

Copilot AI review requested due to automatic review settings May 30, 2026 00:43
@TooTallNate
TooTallNate requested a review from a team as a code owner May 30, 2026 00:43
@changeset-bot

changeset-bot Bot commented May 30, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9fe8530

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

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

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

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

@vercel

vercel Bot commented May 30, 2026

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated (UTC)
example-nextjs-workflow-turbopack Ready Ready Preview, Comment May 31, 2026 7:01am
example-nextjs-workflow-webpack Ready Ready Preview, Comment May 31, 2026 7:01am
example-workflow Ready Ready Preview, Comment May 31, 2026 7:01am
workbench-astro-workflow Ready Ready Preview, Comment May 31, 2026 7:01am
workbench-express-workflow Ready Ready Preview, Comment May 31, 2026 7:01am
workbench-fastify-workflow Ready Ready Preview, Comment May 31, 2026 7:01am
workbench-hono-workflow Ready Ready Preview, Comment May 31, 2026 7:01am
workbench-nitro-workflow Ready Ready Preview, Comment May 31, 2026 7:01am
workbench-nuxt-workflow Ready Ready Preview, Comment May 31, 2026 7:01am
workbench-sveltekit-workflow Ready Ready Preview, Comment May 31, 2026 7:01am
workbench-tanstack-start-workflow Ready Ready Preview, Comment May 31, 2026 7:01am
workbench-vite-workflow Ready Ready Preview, Comment May 31, 2026 7:01am
workflow-docs Ready Ready Preview, Comment, Open in v0 May 31, 2026 7:01am
workflow-swc-playground Ready Ready Preview, Comment May 31, 2026 7:01am
workflow-tarballs Ready Ready Preview, Comment May 31, 2026 7:01am
workflow-web Ready Ready Preview, Comment May 31, 2026 7:01am

Copilot AI 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.

Pull request overview

This PR fixes a deterministic-replay divergence in @workflow/core where a buffered hook_received payload could lose a Promise.race against a concurrently-resolving sleep (wait_completed) during replay, even when the committed event log indicates the hook branch won. The change anchors buffered hook payload delivery to its event-log position and introduces a cross-entity ordering barrier so later-in-log sleeps defer until earlier hook deliveries are observed.

Changes:

  • Anchor buffered hook_received payload hydration to the payload’s log position via ctx.promiseQueue (instead of scheduling at claim/iterator.next() time).
  • Add ctx.pendingHookDeliveries and awaitEarlierHookDeliveries() to defer wait_completed behind earlier-in-log buffered hook deliveries.
  • Add characterization/regression tests covering hook-vs-sleep, hook-vs-step, and two-hook ordering under slow async hydration.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/core/src/workflow/sleep.ts Defers wait_completed resolution behind earlier buffered hook deliveries.
packages/core/src/workflow/hook.ts Reworks buffered hook payload handling to resolve at log position and register ordering barriers.
packages/core/src/private.ts Adds pendingHookDeliveries to context and implements awaitEarlierHookDeliveries().
packages/core/src/workflow.ts Initializes pendingHookDeliveries in the runtime context.
packages/core/src/hook-sleep-interaction.test.ts Adds regression/characterization tests for ordering across hook/sleep/step races.
packages/core/src/workflow/sleep.test.ts Updates test harness context initialization with pendingHookDeliveries.
packages/core/src/step.test.ts Updates test harness context initialization with pendingHookDeliveries.
packages/core/src/async-deserialization-ordering.test.ts Updates test harness context initialization with pendingHookDeliveries.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/core/src/private.ts Outdated
Comment thread packages/core/src/private.ts Outdated
Comment thread packages/core/src/hook-sleep-interaction.test.ts Outdated
Base automatically changed from codex/runtime-only-reused-sleep-repro to stable May 30, 2026 07:31
@github-actions

github-actions Bot commented May 30, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

Passed Failed Skipped Total
❌ ▲ Vercel Production 900 1 67 968
✅ 💻 Local Development 970 0 86 1056
✅ 📦 Local Production 970 0 86 1056
✅ 🐘 Local Postgres 970 0 86 1056
✅ 🪟 Windows 88 0 0 88
❌ 🌍 Community Worlds 15 69 0 84
✅ 📋 Other 492 0 36 528
Total 4405 70 361 4836

❌ Failed Tests

▲ Vercel Production (1 failed)

hono (1 failed):

  • outputStreamWorkflow - getTailIndex and getStreamChunks getStreamChunks returns same content as reading the stream
🌍 Community Worlds (69 failed)

mongodb-dev (1 failed):

  • dev e2e should rebuild on imported step dependency change

redis-dev (1 failed):

  • dev e2e should rebuild on imported step dependency change

turso-dev (1 failed):

  • dev e2e should rebuild on imported step dependency change

turso (66 failed):

  • addTenWorkflow | wrun_01KSYD86Y8EXQPAJYH36FWS54R
  • addTenWorkflow | wrun_01KSYD86Y8EXQPAJYH36FWS54R
  • wellKnownAgentWorkflow (.well-known/agent) | wrun_01KSYD9JE29HHV0FJXZNX840NM
  • should work with react rendering in step
  • promiseAllWorkflow | wrun_01KSYD8DZ9WVPZ4W9BCPRCFYXG
  • promiseRaceWorkflow | wrun_01KSYD8JWAC1FGQZ6AZ8416FN7
  • promiseAnyWorkflow | wrun_01KSYD8N53WAAMKRCSHNKQ1K92
  • importedStepOnlyWorkflow | wrun_01KSYD9YXGN0GR2VHA520JQ6GT
  • readableStreamWorkflow | wrun_01KSYD8QB47N2CB8TQ8WFZ4998
  • hookWorkflow | wrun_01KSYD971925F8B68GGMXD8MX9
  • hookWorkflow is not resumable via public webhook endpoint | wrun_01KSYD9GARSJ1MPW63GRGHHQQE
  • webhookWorkflow | wrun_01KSYD9N6CDQDF80RZ2P0CC8WT
  • sleepingWorkflow | wrun_01KSYD9VF7X59YWAJ8EGWRB655
  • parallelSleepWorkflow | wrun_01KSYDAEX1P2NMHPVP9HTHDKX5
  • nullByteWorkflow | wrun_01KSYDAKJ2SPBGNP9NMJZA5HD0
  • workflowAndStepMetadataWorkflow | wrun_01KSYDAQ78CV2SWVH46AJCBTWY
  • outputStreamWorkflow no startIndex (reads all chunks)
  • outputStreamWorkflow positive startIndex (skips first chunk)
  • outputStreamWorkflow negative startIndex (reads from end)
  • outputStreamWorkflow - getTailIndex and getStreamChunks getTailIndex returns correct index after stream completes
  • outputStreamWorkflow - getTailIndex and getStreamChunks getTailIndex returns -1 before any chunks are written
  • outputStreamWorkflow - getTailIndex and getStreamChunks getStreamChunks returns same content as reading the stream
  • outputStreamInsideStepWorkflow - getWritable() called inside step functions | wrun_01KSYDD68ZKVNGDD1CM5AERV2A
  • fetchWorkflow | wrun_01KSYDDMKYB5Y5JA7B0YMARS2F
  • promiseRaceStressTestWorkflow | wrun_01KSYDDRBZB0KR3761NWEWQTRW
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • error handling not registered WorkflowNotRegisteredError fails the run when workflow does not exist
  • error handling not registered StepNotRegisteredError fails the step but workflow can catch it
  • error handling not registered StepNotRegisteredError fails the run when not caught in workflow
  • hookCleanupTestWorkflow - hook token reuse after workflow completion | wrun_01KSYDHT08WZZ0TQ6AVKFK70P3
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KSYDJ6YE52XV5G53M61QTWK0
  • hookDisposeTestWorkflow - hook token reuse after explicit disposal while workflow still running | wrun_01KSYDJPDZJPVWWES9VB110MAD
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars) | wrun_01KSYDK6PT1N32AFA0RE4H0KYJ
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument | wrun_01KSYDKG0YTF3BD07P2PWG8YEE
  • closureVariableWorkflow - nested step functions with closure variables | wrun_01KSYDKP40A0STQ03STT8R5Y9W
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step | wrun_01KSYDKR9NQ9FYDGYAF77GCGTX
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • health check (CLI) - workflow health command reports healthy endpoints
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly | wrun_01KSYDMEYC8HFGR34WHJ89NSMZ
  • Calculator.calculate - static workflow method using static step methods from another class | wrun_01KSYDMN109FP1ZDKCNDAAXJ27
  • AllInOneService.processNumber - static workflow method using sibling static step methods | wrun_01KSYDMVYP450ESXQZWMAY1BH9
  • ChainableService.processWithThis - static step methods using this to reference the class | wrun_01KSYDN2YF7WVBEXM3SQXP0FQ4
  • thisSerializationWorkflow - step function invoked with .call() and .apply() | wrun_01KSYDNHXMYZTD5DH2AR96HW4V
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE | wrun_01KSYDNTN82V2GXKP9AXGGYGQ7
  • instanceMethodStepWorkflow - instance methods with "use step" directive | wrun_01KSYDP1DXA09D2MQ8ZFZKQVM2
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context | wrun_01KSYDPE8WMNPZ9XAHPZ3PXT1M
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument | wrun_01KSYDPQR10EQD08PJXKFZQV9H
  • cancelRun - cancelling a running workflow | wrun_01KSYDQ095PTDPE0MEEYRCWE3R
  • cancelRun via CLI - cancelling a running workflow | wrun_01KSYDQ9NDNMBCEFEKNFX25J4N
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep | wrun_01KSYDQQKEPFKC9KCTS72EMYQ0
  • sleepInLoopWorkflow - sleep inside loop with steps actually delays each iteration | wrun_01KSYDR82BM72DJZJ0MGHFNYMX
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control) | wrun_01KSYDRJHMKQ5RBCERP1X5Y9FM
  • importMetaUrlWorkflow - import.meta.url is available in step bundles | wrun_01KSYDRSA0ZZC5D3PCSZ55W1YT
  • metadataFromHelperWorkflow - getWorkflowMetadata/getStepMetadata work from module-level helper (#1577) | wrun_01KSYDRWX628907RN2GMZEVM3W
  • resilient start: addTenWorkflow completes when run_created returns 500 | wrun_01KSYDRZ7W6G0FJKS9X235R8TX

Details by Category

❌ ▲ Vercel Production
App Passed Failed Skipped
✅ astro 81 0 7
✅ example 81 0 7
✅ express 81 0 7
✅ fastify 81 0 7
❌ hono 80 1 7
✅ nextjs-turbopack 86 0 2
✅ nextjs-webpack 86 0 2
✅ nitro 81 0 7
✅ nuxt 81 0 7
✅ sveltekit 81 0 7
✅ vite 81 0 7
✅ 💻 Local Development
App Passed Failed Skipped
✅ astro-stable 82 0 6
✅ express-stable 82 0 6
✅ fastify-stable 82 0 6
✅ hono-stable 82 0 6
✅ nextjs-turbopack-canary 69 0 19
✅ nextjs-turbopack-stable 88 0 0
✅ nextjs-webpack-canary 69 0 19
✅ nextjs-webpack-stable 88 0 0
✅ nitro-stable 82 0 6
✅ nuxt-stable 82 0 6
✅ sveltekit-stable 82 0 6
✅ vite-stable 82 0 6
✅ 📦 Local Production
App Passed Failed Skipped
✅ astro-stable 82 0 6
✅ express-stable 82 0 6
✅ fastify-stable 82 0 6
✅ hono-stable 82 0 6
✅ nextjs-turbopack-canary 69 0 19
✅ nextjs-turbopack-stable 88 0 0
✅ nextjs-webpack-canary 69 0 19
✅ nextjs-webpack-stable 88 0 0
✅ nitro-stable 82 0 6
✅ nuxt-stable 82 0 6
✅ sveltekit-stable 82 0 6
✅ vite-stable 82 0 6
✅ 🐘 Local Postgres
App Passed Failed Skipped
✅ astro-stable 82 0 6
✅ express-stable 82 0 6
✅ fastify-stable 82 0 6
✅ hono-stable 82 0 6
✅ nextjs-turbopack-canary 69 0 19
✅ nextjs-turbopack-stable 88 0 0
✅ nextjs-webpack-canary 69 0 19
✅ nextjs-webpack-stable 88 0 0
✅ nitro-stable 82 0 6
✅ nuxt-stable 82 0 6
✅ sveltekit-stable 82 0 6
✅ vite-stable 82 0 6
✅ 🪟 Windows
App Passed Failed Skipped
✅ nextjs-turbopack 88 0 0
❌ 🌍 Community Worlds
App Passed Failed Skipped
❌ mongodb-dev 4 1 0
❌ redis-dev 4 1 0
❌ turso-dev 4 1 0
❌ turso 3 66 0
✅ 📋 Other
App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 82 0 6
✅ e2e-local-dev-tanstack-start-stable 82 0 6
✅ e2e-local-postgres-nest-stable 82 0 6
✅ e2e-local-postgres-tanstack-start-stable 82 0 6
✅ e2e-local-prod-nest-stable 82 0 6
✅ e2e-local-prod-tanstack-start-stable 82 0 6

📋 View full workflow run


Some E2E test jobs failed:

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

Check the workflow run for details.

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

One blocking replay-ordering case remains; details inline.

Comment thread packages/core/src/workflow/sleep.ts Outdated

@pranaygp pranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Additional blocking findings from local replay and release verification; details inline.

Comment thread packages/core/src/workflow/hook.ts Outdated
Comment thread packages/core/src/workflow/hook.ts Outdated
Comment thread .changeset/salty-ears-act.md
A buffered hook payload (a `hook_received` consumed before the workflow
awaited the hook) was delivered to its consumer only at claim time
(`iterator.next()`/`await`), and a concurrent `wait_completed` resolved
synchronously with fewer microtask hops. When the workflow did
`Promise.race([hook, sleep])`, sleep could win a race the committed event
log says the hook won, surfacing as `CorruptedEventLogError` on replay
(observed in production: a `step_created` for one step consumed by a
different step's consumer).

Fix, in three timing-independent parts:

1. Resolve a buffered hook payload through a `promiseQueue` slot chained
   at its log position (not at the later claim site), so resolution order
   stays anchored to the event log regardless of hydration/decryption
   time.

2. Register a per-delivery ordering barrier keyed by the source
   `hook_received` eventId (`ctx.pendingHookDeliveries`). A later-in-log
   entity (sleep's `wait_completed`) defers behind any earlier-in-log
   in-flight hook delivery via `awaitEarlierHookDeliveries`.

3. Release the barrier on a MACROTASK (`setTimeout(0)`) after the payload
   is claimed, so the consumer's branch decision — however many await
   hops deep through the async hook iterator — always commits before the
   deferring entity proceeds. This reuses the macrotask-boundary technique
   `scheduleWhenIdle` already relies on and is fully hop-count- and
   decryption-time independent (no microtask-hop heuristic).
   `awaitEarlierHookDeliveries` bounds its wait with a one-macrotask
   fallback so an unclaimed payload cannot deadlock a deferring entity.

Adds characterization tests covering hook-vs-sleep (the repro),
hook-vs-step, and a two-hook slow-decrypt ordering case. Pre-existing
runtime bug on `stable`, independent of the OCC/fence work.

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

@vercel vercel Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Additional Suggestion:

The changeset file .changeset/salty-ears-act.md is empty (only frontmatter delimiters), so it bumps no packages despite the PR changing runtime behavior in @workflow/core.

Fix on Vercel

Comment thread packages/core/src/workflow/hook.ts Outdated
Comment thread packages/core/src/workflow/hook.ts Outdated
Address review feedback on the hook-vs-sleep replay fix:

- Order branch-deciding deliveries (buffered hook payloads + wait
  completions) by their event-log INDEX, not by `eventId` string compare.
  `eventId` is world-assigned and not guaranteed to sort in creation order
  (only the bundled ULID worlds do), so the prior comparison mis-ordered
  against worlds like the Vercel world. (VaguelySerious/vercel-bot: blocking)

- Make ordering bidirectional and type-aware: a hook delivery is handed to
  the workflow only after every earlier-in-log wait, and a wait only after
  every earlier-in-log hook. Same-kind deliveries do not block one another
  (sequential hook payloads stay unblocked). The gate is "the earlier
  delivery resolved", not "won a timing race", so the outcome is independent
  of microtask hops, hydration time, and `Promise.race` argument order.

- Do not reject a buffered payload promise before a consumer attaches a
  handler. Capture the hydration outcome and build the consumer-facing
  promise at claim time, so an unclaimed encrypted payload with no key no
  longer crashes the process with an unhandled rejection. (pranaygp: P1)

- Retire delivery barriers when delivered, and at idle if never delivered,
  so `pendingDeliveryBarriers` cannot retain an entry per abandoned payload.
  (pranaygp: P2)

- Make `pendingDeliveryBarriers` optional and route all access through
  guarded helpers, removing the required-vs-optional mismatch. (Copilot)

- Add a patch changeset for `@workflow/core`. (pranaygp/VaguelySerious)

Turns the forward-direction reproducers green (removes `.fails`). Adds
Pranay's two regression tests (unhandled-rejection, barrier leak). The
remaining `it.fails` ("...when wait completion wins") asserts an
unproducible replay — its reused sleep is already resolved by the time the
loop's `Promise.race` runs, so the resolved hook always wins the array-order
tie; documented inline and flagged for rework/removal.

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

Copy link
Copy Markdown
Member Author

Sit-rep: review feedback addressed (pushed in 9fe8530)

Rebased onto latest stable and reworked the ordering mechanism to address all review threads. Full @workflow/core suite is green (636/636), typecheck clean.

What changed

  • Event-log index ordering, not eventId (blocking — @VaguelySerious / vercel-bot). Branch-deciding deliveries are ordered by their position in the consumed event log, not a lexicographic eventId compare, so it's correct for worlds whose IDs aren't creation-sortable (e.g. the Vercel world).
  • Bidirectional + type-aware ordering (@pranaygp P1). A hook is delivered only after every earlier-in-log wait, and a wait only after every earlier-in-log hook. Same-kind deliveries don't block one another (sequential hook payloads stay unblocked). The gate is "the earlier delivery resolved," so it's independent of microtask hops, hydration time, and Promise.race argument order.
  • No unhandled rejection before claim (@pranaygp P1). Buffered payloads capture their hydration outcome and build the consumer promise at claim time. Pranay's regression test included and passing.
  • No barrier leak (@pranaygp P2). Barriers retire on delivery, or at idle if never delivered. Pranay's regression test included and passing.
  • Optional field + guarded access (Copilot), and a patch changeset for @workflow/core (@pranaygp / @VaguelySerious).

Reproducers

The forward-direction it.fails cases now pass and .fails is removed.

⚠️ One reproducer is an invalid test — kept as it.fails, please rework/remove

should preserve the early waiter with a reused sleep when wait completion wins asserts an outcome the workflow under test cannot produce:

  • In loop 1, the reused pendingSleep has already resolved — its wait_completed is consumed before loop 1 begins — so it's an already-settled promise at the race.
  • Loop 1's iterator.next() claims the remaining buffered hook_received, which also resolves.
  • Promise.race([pendingRead, pendingSleep]) with both inputs resolved picks the first array element (pendingRead, the hook) by JS semantics. That tie-break is in the workflow author's race-argument order; the runtime can't change it.

So a real producer run of this exact workflow would take the hook branch in loop 1 (recording drainStep), never the progressStep branch the hand-built log encodes. I left it it.fails with an inline explanation. Suggest reworking it so the sleep isn't pre-resolved at the race (e.g. order wait_completed after the second hook_received, or have the sleep not yet fired), or removing it. @pranaygp — since it's your reproducer, would value your gut-check.

All 13 review threads replied to and resolved.

@VaguelySerious VaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI review: no blocking issues

// hydration/ordering work runs in event-log order.
const queueAtCompletion = ctx.promiseQueue;
void queueAtCompletion
.then(() => awaitEarlierDeliveries(ctx, eventIndex, ['hook']))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

The ordering here is happens-before correct, and worth recording why it's robust: a wait_completed resolves only after awaitEarlierDeliveries(['hook']) has settled every earlier-in-log hook barrier, and a buffered hook resolves only after every earlier-in-log wait barrier. Because the gate is "the earlier delivery resolved" (a causal edge) rather than "won a microtask race", the winner of Promise.race([hook, sleep]) no longer depends on hydration time, await-hop depth, or race-argument order. I traced hook-vs-step (unchanged — steps register no barrier, and the hook's added hop still schedules its resolve ahead of an adjacent step slot) and wait-vs-wait (preserved via registration order on the shared/earlier queue tail); neither regresses. Detaching the resolve from the serial promiseQueue is necessary here (gating a queue slot on an earlier delivery whose own resolution is queue-driven would deadlock the queue) and the reasoning holds. No change requested.

// branch was not taken / the run is suspending), resolve at idle so a
// later opposite-kind delivery gated on it cannot deadlock and the
// registry cannot leak an entry per abandoned delivery.
scheduleWhenIdle(ctx, finish);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Nit

Every hook_received and wait_completed now registers an idle safety-net poll via scheduleWhenIdle. This is correct — it prevents a later opposite-kind delivery from deadlocking behind an abandoned one, and the barrier-leak regression test confirms entries are reclaimed. Minor cost only: a long-lived hook loop consuming thousands of payloads registers O(n) setTimeout(0) polls over the run's lifetime (one per payload, each firing once). Not a leak and almost certainly negligible, but worth a glance if a high-throughput hook workflow ever shows up in a timing-sensitive benchmark.

// hand-built log encodes (`progressStep` at evnt_14). The fix for the real
// hook-vs-sleep ordering bug is verified by the sibling tests; this case
// should be re-worked (e.g. so the sleep is not pre-resolved at the race)
// or removed. See PR discussion.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Nit

Agreed with the inline rationale that this it.fails asserts an outcome the workflow can't actually produce (the reused sleep is already resolved at the race, so the array-order tie-break picks the hook). Beyond being dead weight it's a fragile guard: if a future change ever makes the sleep branch reachable in this exact log, the body's assertions would pass and it.fails would itself start failing CI for what is arguably a correct change. Recommend reworking it so the sleep isn't pre-resolved at the race (e.g. order wait_completed after the second hook_received) or removing it. For what it's worth I wrote a clean, reproducible "sleep wins" case (a fresh sleep whose wait_completed is earlier in the log than the hook_received) — it passes on this branch in both sync and async-deserialization modes, and also passes on the base, which corroborates that the sleep-wins direction was already correct and that the real defect is the hook-should-win-but-loses direction the sibling repros cover.

@VaguelySerious

Copy link
Copy Markdown
Member

AI Review: Note

Context on the red Event Log Race Repro check, since it's the headline gate for this change — it is not blocking:

  • CORRUPTED_EVENT_LOG is 0 across every run in the history (the exact defect this PR fixes).
  • The non-completed runs are all NO_WAKE_BRANCH / HOOK_RESUME_FAILED plus a single stuck that is still running — i.e. harness timing, not replay divergence.
  • The hook-sleep scenario loops iterations times racing sleep(sleepMs) against iterator.next(), while the harness resumes the hook only after resumeDelayMs + jitter (15000–25000ms). With 5 iterations × ~5000ms plus per-iteration step/infra overhead, the loop can finish before a late resume lands → all-sleepNO_WAKE_BRANCH; HOOK_RESUME_FAILED is that resume's own 30s HTTP timeout firing.
  • The 0 → 55 → 8 → 18 non-completed swing on identical config confirms infra/timing flakiness, and the gate fails on any non-completed run.

Suggestion (follow-up, not for this PR): make the resume land deterministically inside the loop window — e.g. bind it to elapsed iterations rather than a wall-clock delay that sits right at the total loop duration — so the gate stops flaking and a future real regression isn't masked by ambient noise.

The other two red checks are unrelated: E2E Vercel Prod Tests (hono) failed on an output-stream test (outputStreamWorkflow / getStreamChunks) with a uniform 60s timeout — a known hono Vercel-Prod flake, all other frameworks passed — and E2E Required Check is red only because it aggregates that hono job.

Validation done locally on 9fe8530: full @workflow/core unit suite 636/636 (stable across repeated runs), typecheck clean, plus an added reproducible "sleep wins" case passing in both sync and async-deserialization modes.

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

Stamp

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

Labels

event-log-race-repro Run the event log race reproduction job

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants