Skip to content

Backport #2266 + #2946: precondition guard for event creation - #3079

Merged
VaguelySerious merged 1 commit into
stablefrom
peter/backport-precondition-guard
Jul 24, 2026
Merged

Backport #2266 + #2946: precondition guard for event creation#3079
VaguelySerious merged 1 commit into
stablefrom
peter/backport-precondition-guard

Conversation

@VaguelySerious

Copy link
Copy Markdown
Member

Backports the stateUpdatedAt optimistic-concurrency guard for event creation to stable:

Combined into one PR (the two upstream PRs are tightly coupled — #2946 flips on the guard #2266 added), ending with the guard on by default (opt out with WORKFLOW_PRECONDITION_GUARD=0).

What it does

Replay-context event creations send a stateUpdatedAt snapshot (the ULID time of the latest event the runtime has loaded). A supporting backend records the per-run latest out-of-band event time and rejects a stale create with 412 PreconditionFailedError. On rejection the runtime reloads the event log from its cursor and retries (up to twice); if still stale it schedules an immediate re-invocation for a fresh replay. run_completed is never retried in place — a stale rejection forces a fresh replay rather than committing a result computed from the stale snapshot.

Backward-compatible and fails open: backends without guard support ignore stateUpdatedAt; the guard is best-effort.

Changes

  • @workflow/errorsPreconditionFailedError (HTTP 412)
  • @workflow/worldCreateEventParams.stateUpdatedAt + backend-contract JSDoc
  • @workflow/core — guard helpers (latestEventStateUpdatedAt, withPreconditionRetry), on by default; wired into the wait_completed / run_completed replay paths and every create inside handleSuspension; exhausted 412 escalates to an immediate re-invocation
  • @workflow/world-vercel — sends stateUpdatedAt on the wire; maps 412 → PreconditionFailedError (v4 frame path + makeRequest)

Adaptation notes (vs. the source PRs on main)

stable has diverged, so this is a manual adaptation rather than a cherry-pick:

  • Uses stable's getWorkflowRunEvents (main renamed it to loadWorkflowRunEvents and added MutableEventLog).
  • Stable has no turbo mode / reinvoke(); the exhausted-412 escalation returns { timeoutSeconds: 0 } (stable's existing re-invocation signal).
  • Stable's v4 status→error mapping is inline (throwForErrorResponse + makeRequest), predating main's shared errorForResponse; the 412 branch was added in both.
  • Omitted from the backport (not present on stable): the WORKFLOW_SAFE_MODE removal, the WORKFLOW_SEQUENTIAL_REPLAYS cleanup, and the docs pages from the source PRs (stable has no runtime-tuning/config docs page to host the flag).

Tests

  • helpers.test.tslatestEventStateUpdatedAt + withPreconditionRetry (opt-in gating, default-on, reload+retry, exhaustion rethrow, non-precondition passthrough).
  • precondition-guard-replay.test.ts — drives the real workflowEntrypoint replay loop with real ULID event ids: a 412-rejected wait_completed reloads from the held cursor and retries with the newer snapshot; a 412-rejected run_completed is attempted exactly once, resolves into an immediate re-invocation, and never becomes run_failed. (The wait scenario asserts the guard's reload/retry contract; the downstream replay-branch assertion from the source PR was adapted — stable's replay engine handles post-reload continuation differently.)
  • world-vercel — 412 → PreconditionFailedError mapping; stateUpdatedAt present/absent in the wire body.

All core (790) and world-vercel (173) unit tests pass locally; typecheck and lint clean.

🤖 Generated with Claude Code

Backports the stateUpdatedAt optimistic-concurrency guard for event
creation (#2266) together with its default-on follow-up (#2946), adapted
to the stable runtime.

- @workflow/errors: PreconditionFailedError (HTTP 412)
- @workflow/world: CreateEventParams.stateUpdatedAt + backend contract
- @workflow/core: guard helpers (latestEventStateUpdatedAt,
  withPreconditionRetry), on by default (opt out WORKFLOW_PRECONDITION_GUARD=0);
  wired into the wait_completed / run_completed replay paths and every
  create in handleSuspension; exhausted 412 schedules an immediate
  re-invocation ({ timeoutSeconds: 0 }, stable has no turbo/reinvoke)
- @workflow/world-vercel: send stateUpdatedAt on the wire; map 412 to
  PreconditionFailedError (events-v4 + makeRequest)

Adaptation notes vs main: uses stable's getWorkflowRunEvents (no
MutableEventLog/loadWorkflowRunEvents rename); run_completed does a single
guarded create (never retried in place) and escalates on 412; SAFE_MODE /
sequential-replays cleanup and docs from the source PRs are omitted (not
present on stable).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@VaguelySerious
VaguelySerious requested review from a team and ijjk as code owners July 23, 2026 23:59
@changeset-bot

changeset-bot Bot commented Jul 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 49ad6e2

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

This PR includes changesets to release 21 packages
Name Type
workflow Minor
@workflow/core Minor
@workflow/world-vercel Minor
@workflow/world Minor
@workflow/errors Minor
@workflow/world-testing Patch
@workflow/builders Patch
@workflow/cli Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/vitest Patch
@workflow/web-shared Patch
@workflow/web Patch
@workflow/world-local Patch
@workflow/world-postgres 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 Jul 23, 2026

Copy link
Copy Markdown
Contributor

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

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

@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

Passed Failed Skipped Total
❌ ▲ Vercel Production 1076 1 78 1155
✅ 💻 Local Development 1174 0 86 1260
✅ 📦 Local Production 1174 0 86 1260
✅ 🐘 Local Postgres 1174 0 86 1260
✅ 🪟 Windows 105 0 0 105
❌ 🌍 Community Worlds 82 102 9 193
✅ 📋 Other 594 0 36 630
Total 5379 103 381 5863

❌ Failed Tests

▲ Vercel Production (1 failed)

vite (1 failed):

  • outputStreamWorkflow no startIndex (reads all chunks)
🌍 Community Worlds (102 failed)

redis (19 failed):

  • hookWorkflow | wrun_01KY8PV7T4KS5R31VW3V8KGGJ2
  • hookWorkflow is not resumable via public webhook endpoint | wrun_01KY8PVDXYV5FZJRSTASGW9X0W
  • parallelStepsThenWebhookWorkflow - no hook_conflict from same-tick replay race | wrun_01KY8PVRK5M5MMF8GW8EQR8PRQ
  • sleepingWorkflow | wrun_01KY8PWR3K1D06SZSX0Q5KMXX6
  • 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
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KY8Q3S7NXCZQYZV653CC9DYN
  • hookGetConflictWorkflow - awaiting hook.getConflict() registers hook without payload | wrun_01KY8Q46RCAQQHY5F2HXWV0HMJ
  • hookGetConflictThenStepParallelWorkflow - hook.getConflict() continuation step runs alongside other steps | wrun_01KY8Q4ESY66E6NZCYAC3R3K53
  • hookGetConflictWorkflow - hook.getConflict() resolves with the conflicting run when token is already registered | wrun_01KY8Q4WYQQKMTTXY08Q37G3H6
  • hookClaimOnlyMutexWorkflow - hook works as a pure run mutex without payload data | wrun_01KY8Q5QVSVQVBA9YFYN02G1YV
  • hookAdoptOwnerResultWorkflow - duplicate adopts the owner result via conflict.returnValue | wrun_01KY8Q5WZJFFN0C58AQKRWSP4Y
  • hookSignalOwnerWorkflow - duplicate forwards its payload to the owner via resumeHook | wrun_01KY8Q625SZT3C5Y5MM2DTJPQ9
  • hookSupersedeOwnerWorkflow - duplicate cancels the owner and claims the released token | wrun_01KY8Q66VX0SDJE25XJE9VVAX6
  • resume-or-start route pattern - resumeHook retried after start() reaches the new run | wrun_01KY8Q6JKYWKEKM71TVER8ADCB
  • pages router sleepingWorkflow via pages router
  • resilient start: addTenWorkflow completes when run_created returns 500 | wrun_01KY8QBTHJT9XPM0W83R2X08CP

turso (83 failed):

  • addTenWorkflow | wrun_01KY8PT9Q6KQ6RDH5TQ9K1Y957
  • addTenWorkflow | wrun_01KY8PT9Q6KQ6RDH5TQ9K1Y957
  • deploymentId: 'latest' is a no-op in non-Vercel worlds
  • wellKnownAgentWorkflow (.well-known/agent) | wrun_01KY8PVTV9KP68YBGNE3RC55DR
  • should work with react rendering in step
  • promiseAllWorkflow | wrun_01KY8PTG7KP1BYZ869X1SV3HJA
  • promiseRaceWorkflow | wrun_01KY8PTN43C4E82590HQ9EDGR6
  • promiseAnyWorkflow | wrun_01KY8PTQ42XQJSPBR1T5RQKAWH
  • importedStepOnlyWorkflow | wrun_01KY8PW70S57P9PCYTMPZK3RTS
  • readableStreamWorkflow | wrun_01KY8PTS6D0S332HRSSS8PMECH
  • hookWorkflow | wrun_01KY8PV7T4KS5R31VW3V8KGGJ2
  • hookWorkflow is not resumable via public webhook endpoint | wrun_01KY8PVDXYV5FZJRSTASGW9X0W
  • webhookWorkflow | wrun_01KY8PVJEEB485M9JX24279BCZ
  • parallelStepsThenWebhookWorkflow - no hook_conflict from same-tick replay race | wrun_01KY8PVRK5M5MMF8GW8EQR8PRQ
  • sleepingWorkflow | wrun_01KY8PWR3K1D06SZSX0Q5KMXX6
  • parallelSleepWorkflow | wrun_01KY8PX7DPYK15AQX170JYZ57F
  • sleepWinsRaceWorkflow | wrun_01KY8PXAK00MGH9SF5ZA6PHD9P
  • stepWinsRaceWorkflow | wrun_01KY8PXDKT6X9K6CXPTCT0839N
  • nullByteWorkflow | wrun_01KY8PXGS9FAFYHW2D07KVWF1X
  • workflowAndStepMetadataWorkflow | wrun_01KY8PXJV6EDJ8037GK5F8DC5H
  • 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_01KY8PZF1YZZ9Y4EJGRJ6Y9JRN
  • writableForwardedFromWorkflowWorkflow | wrun_01KY8PZW6P9B96RK0Q4QR463PC
  • writableForwardedFromStepWorkflow | wrun_01KY8PZZCSKQVTGQVPHGPN2YA3
  • fetchWorkflow | wrun_01KY8Q01Z8W0W614AT4D75AM25
  • promiseRaceStressTestWorkflow | wrun_01KY8Q0578YYQMBJR5JACD96BZ
  • 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_01KY8Q3DB0YXMSY6ZZ2YEMFTCT
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KY8Q3S7NXCZQYZV653CC9DYN
  • hookGetConflictWorkflow - awaiting hook.getConflict() registers hook without payload | wrun_01KY8Q46RCAQQHY5F2HXWV0HMJ
  • 'hookGetConflictWithPriorStepWorkflow' - hook.getConflict() does not block step execution | wrun_01KY8Q4A7XKS20NQDC0Z621TM7
  • 'hookGetConflictWithParallelStepWorkfl…' - hook.getConflict() does not block step execution | wrun_01KY8Q4CCZGM8FS1PC6VSJWW8R
  • hookGetConflictThenStepParallelWorkflow - hook.getConflict() continuation step runs alongside other steps | wrun_01KY8Q4ESY66E6NZCYAC3R3K53
  • hookGetConflictWorkflow - hook.getConflict() resolves with the conflicting run when token is already registered | wrun_01KY8Q4WYQQKMTTXY08Q37G3H6
  • hookClaimOnlyMutexWorkflow - hook works as a pure run mutex without payload data | wrun_01KY8Q5QVSVQVBA9YFYN02G1YV
  • hookAdoptOwnerResultWorkflow - duplicate adopts the owner result via conflict.returnValue | wrun_01KY8Q5WZJFFN0C58AQKRWSP4Y
  • hookSignalOwnerWorkflow - duplicate forwards its payload to the owner via resumeHook | wrun_01KY8Q625SZT3C5Y5MM2DTJPQ9
  • hookSupersedeOwnerWorkflow - duplicate cancels the owner and claims the released token | wrun_01KY8Q66VX0SDJE25XJE9VVAX6
  • resume-or-start route pattern - resumeHook retried after start() reaches the new run | wrun_01KY8Q6JKYWKEKM71TVER8ADCB
  • hookDisposeTestWorkflow - hook token reuse after explicit disposal while workflow still running | wrun_01KY8Q6SRFMSJ5C0RN5DGJYVS7
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars) | wrun_01KY8Q795K0V4XXCYJP8598J0T
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument | wrun_01KY8Q7HWHQN1A4HT0STTKX7NS
  • closureVariableWorkflow - nested step functions with closure variables | wrun_01KY8Q7Q3GCN496VQ2B6YWANTG
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step | wrun_01KY8Q7S862KGMSZFESQDYZSRE
  • 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_01KY8Q88KN07A15XZ42R32TYTR
  • Calculator.calculate - static workflow method using static step methods from another class | wrun_01KY8Q8DWTCJ987PCAPK2S2N7C
  • AllInOneService.processNumber - static workflow method using sibling static step methods | wrun_01KY8Q8M3W9VQ09X2XWK10X7DC
  • ChainableService.processWithThis - static step methods using this to reference the class | wrun_01KY8Q8S957222041VC0K0Z0FR
  • thisSerializationWorkflow - step function invoked with .call() and .apply() | wrun_01KY8Q8ZS1D8GA9N65N07635YF
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE | wrun_01KY8Q974Y045GX2G8Q6BJ1YQ3
  • instanceMethodStepWorkflow - instance methods with "use step" directive | wrun_01KY8Q9DB9HJN2GMC063C40T3V
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context | wrun_01KY8Q9RZDSQTN0MRT51X2FT67
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument | wrun_01KY8QA0D5V172BJBCGB8XG25P
  • cancelRun - cancelling a running workflow | wrun_01KY8QA717S92WPPSQW36Y1YRB
  • cancelRun via CLI - cancelling a running workflow | wrun_01KY8QABQ71P6PGXBGZVPYBCB2
  • 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_01KY8QAK8YXPPX535AZVYVQA32
  • hookWithSleepFinalStepWorkflow - step only on final payload | wrun_01KY8QAY21GJJ7AXKGTNDX7F95
  • sleepInLoopWorkflow - sleep inside loop with steps actually delays each iteration | wrun_01KY8QB638GVT6A9VE2GB1V3CQ
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control) | wrun_01KY8QBG16D0MH1V5XFHVH38MZ
  • importMetaUrlWorkflow - import.meta.url is available in step bundles | wrun_01KY8QBPD7XYT1P3YC7C9AM8E8
  • metadataFromHelperWorkflow - getWorkflowMetadata/getStepMetadata work from module-level helper (#1577) | wrun_01KY8QBRD15MHX17DJYP3JXVCY
  • resilient start: addTenWorkflow completes when run_created returns 500 | wrun_01KY8QBTHJT9XPM0W83R2X08CP

Details by Category

❌ ▲ Vercel Production
App Passed Failed Skipped
✅ astro 97 0 8
✅ example 97 0 8
✅ express 97 0 8
✅ fastify 97 0 8
✅ hono 97 0 8
✅ nextjs-turbopack 102 0 3
✅ nextjs-webpack 102 0 3
✅ nitro 97 0 8
✅ nuxt 97 0 8
✅ sveltekit 97 0 8
❌ vite 96 1 8
✅ 💻 Local Development
App Passed Failed Skipped
✅ astro-stable 99 0 6
✅ express-stable 99 0 6
✅ fastify-stable 99 0 6
✅ hono-stable 99 0 6
✅ nextjs-turbopack-canary 86 0 19
✅ nextjs-turbopack-stable 105 0 0
✅ nextjs-webpack-canary 86 0 19
✅ nextjs-webpack-stable 105 0 0
✅ nitro-stable 99 0 6
✅ nuxt-stable 99 0 6
✅ sveltekit-stable 99 0 6
✅ vite-stable 99 0 6
✅ 📦 Local Production
App Passed Failed Skipped
✅ astro-stable 99 0 6
✅ express-stable 99 0 6
✅ fastify-stable 99 0 6
✅ hono-stable 99 0 6
✅ nextjs-turbopack-canary 86 0 19
✅ nextjs-turbopack-stable 105 0 0
✅ nextjs-webpack-canary 86 0 19
✅ nextjs-webpack-stable 105 0 0
✅ nitro-stable 99 0 6
✅ nuxt-stable 99 0 6
✅ sveltekit-stable 99 0 6
✅ vite-stable 99 0 6
✅ 🐘 Local Postgres
App Passed Failed Skipped
✅ astro-stable 99 0 6
✅ express-stable 99 0 6
✅ fastify-stable 99 0 6
✅ hono-stable 99 0 6
✅ nextjs-turbopack-canary 86 0 19
✅ nextjs-turbopack-stable 105 0 0
✅ nextjs-webpack-canary 86 0 19
✅ nextjs-webpack-stable 105 0 0
✅ nitro-stable 99 0 6
✅ nuxt-stable 99 0 6
✅ sveltekit-stable 99 0 6
✅ vite-stable 99 0 6
✅ 🪟 Windows
App Passed Failed Skipped
✅ nextjs-turbopack 105 0 0
❌ 🌍 Community Worlds
App Passed Failed Skipped
✅ mongodb-dev 4 0 3
✅ redis-dev 4 0 3
❌ redis 67 19 0
✅ turso-dev 4 0 3
❌ turso 3 83 0
✅ 📋 Other
App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 99 0 6
✅ e2e-local-dev-tanstack-start-stable 99 0 6
✅ e2e-local-postgres-nest-stable 99 0 6
✅ e2e-local-postgres-tanstack-start-stable 99 0 6
✅ e2e-local-prod-nest-stable 99 0 6
✅ e2e-local-prod-tanstack-start-stable 99 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.

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

Reviewed against both upstream PRs (#2266 and #2946, which I reviewed on main), focusing on the guard's semantics surviving the port into stable's older runtime. Approving.

Function-level parity verified rather than eyeballed: latestEventStateUpdatedAt (including the non-decodable-ULID debug log), isPreconditionGuardEnabled (default-on !== '0'), and PRECONDITION_MAX_RELOAD_RETRIES = 2 are byte-identical to the post-#2946 state on main. withPreconditionRetry differs in exactly one line — getWorkflowRunEvents instead of main's renamed loadWorkflowRunEvents — and I checked stable's helper has the same contract (paginates to completion from the cursor, returns {events, cursor}), so the reload-and-merge behavior is unchanged.

The one substantive adaptation is correct, and it's the interesting one. Main's exhausted-412 path calls reinvoke(0); the backport returns { timeoutSeconds: 0 }. I traced why that's right rather than a downgrade:

  • Main needed ack-and-reenqueue specifically because turbo mode has already acked the message by that point — rethrowing stalled runs for the queue's ~300s visibility timeout (the bug the forced-ON e2e round caught).
  • Stable has no turbo mode at all (zero isTurboEnabled references), so that hazard doesn't exist here. And { timeoutSeconds: 0 } isn't a rethrow: world-vercel's handler sends a fresh no-delay message before acking the current one — the same crash-safe ack+enqueue shape reinvoke(0) provides. So both branches get a fresh replay through the same mechanism, expressed in each branch's idiom.

Also verified: run_failed remains unguarded (sends no snapshot) on both branches — behavior parity with main's deliberate fail-open asymmetry; 412 maps to PreconditionFailedError on both of stable's request paths (the v3 utils.ts status ladder and events-v4.ts), so the guard can't silently no-op depending on which wire a deployment uses; the CreateEventParams.stateUpdatedAt backend-contract JSDoc came across intact (the externally-originated-vs-replay-origin marker rule — the piece custom-World authors need); and the pre-merge blocker classes are clean (WORKFLOW_SERVER_URL_OVERRIDE is '', no TEMP(/REVERT BEFORE MERGE markers).

Verified locally: core 787 (+3 pre-existing expected-fails) and world-vercel 173 green on the branch, including the ported precondition-guard-replay tests. Combining the two upstream PRs into one backport is the right call — #2946 flips on what #2266 adds, so splitting them would land a dead-code intermediate state on stable.

CI: the 4 failures are the three Community World lanes (Redis/Turso/MongoDB) and their summary — the known stable-branch baseline set (they've failed on every stable backport I've reviewed, and per the repo's own CI notes community worlds target an older spec version). Nothing touching event creation.

One rollout note, not blocking: default-ON means stable's 412-recovery path goes production-hot on the next 4.x release. Same ask I made on #2946 — confirm the backend guard support is fully rolled out before this ships in a published 4.x, since 4.x users won't have opted in. The failure mode on an unsupporting backend is a no-op (stateUpdatedAt ignored), so the risk is bounded either way.

@VaguelySerious
VaguelySerious merged commit 12e4ef7 into stable Jul 24, 2026
156 of 165 checks passed
@VaguelySerious
VaguelySerious deleted the peter/backport-precondition-guard branch July 24, 2026 23:18
@github-actions github-actions Bot mentioned this pull request Jul 24, 2026
VaguelySerious added a commit that referenced this pull request Jul 27, 2026
Resolves conflicts with the precondition-guard backport (#3079) and the
world-testing per-server data-dir isolation fix (#3055):

- packages/core/src/runtime.ts: keep both error imports
  (MaxEventsExceededError + PreconditionFailedError); both are used.
- packages/world-testing/src/util.mts: keep both spawn env entries;
  WORKFLOW_LOCAL_DATA_DIR first so opts.env can still override it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants