') + ')', '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('^' + ".*" + ', '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" + ', '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('^' + ".*" + ', '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); } })(); })(); Backport #2266 + #2946: precondition guard for event creation by VaguelySerious · Pull Request #3079 · vercel/workflow · GitHub
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 ownersJuly 23, 2026 23:59
@changeset-bot

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
NameType
workflowMinor
@workflow/coreMinor
@workflow/world-vercelMinor
@workflow/worldMinor
@workflow/errorsMinor
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@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

@vercel

vercelBot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
❌ ▲ Vercel Production10761781155
✅ 💻 Local Development11740861260
✅ 📦 Local Production11740861260
✅ 🐘 Local Postgres11740861260
✅ 🪟 Windows10500105
❌ 🌍 Community Worlds821029193
✅ 📋 Other594036630
Total53791033815863

❌ 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
AppPassedFailedSkipped
✅ astro9708
✅ example9708
✅ express9708
✅ fastify9708
✅ hono9708
✅ nextjs-turbopack10203
✅ nextjs-webpack10203
✅ nitro9708
✅ nuxt9708
✅ sveltekit9708
❌ vite9618
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable9906
✅ express-stable9906
✅ fastify-stable9906
✅ hono-stable9906
✅ nextjs-turbopack-canary86019
✅ nextjs-turbopack-stable10500
✅ nextjs-webpack-canary86019
✅ nextjs-webpack-stable10500
✅ nitro-stable9906
✅ nuxt-stable9906
✅ sveltekit-stable9906
✅ vite-stable9906
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable9906
✅ express-stable9906
✅ fastify-stable9906
✅ hono-stable9906
✅ nextjs-turbopack-canary86019
✅ nextjs-turbopack-stable10500
✅ nextjs-webpack-canary86019
✅ nextjs-webpack-stable10500
✅ nitro-stable9906
✅ nuxt-stable9906
✅ sveltekit-stable9906
✅ vite-stable9906
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable9906
✅ express-stable9906
✅ fastify-stable9906
✅ hono-stable9906
✅ nextjs-turbopack-canary86019
✅ nextjs-turbopack-stable10500
✅ nextjs-webpack-canary86019
✅ nextjs-webpack-stable10500
✅ nitro-stable9906
✅ nuxt-stable9906
✅ sveltekit-stable9906
✅ vite-stable9906
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack10500
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev403
✅ redis-dev403
❌ redis67190
✅ turso-dev403
❌ turso3830
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable9906
✅ e2e-local-dev-tanstack-start-stable9906
✅ e2e-local-postgres-nest-stable9906
✅ e2e-local-postgres-tanstack-start-stable9906
✅ e2e-local-prod-nest-stable9906
✅ e2e-local-prod-tanstack-start-stable9906

📋 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.

@TooTallNateTooTallNate 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 stableJul 24, 2026
156 of 165 checks passed
@VaguelySerious
VaguelySerious deleted the peter/backport-precondition-guard branch July 24, 2026 23:18
@github-actionsgithub-actionsBot 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 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@TooTallNate