Skip to content

Fix duplicate inline step execution on mid-step wake via message ownership#2848

Merged
VaguelySerious merged 11 commits into
mainfrom
peter/inline-step-ownership
Jul 10, 2026
Merged

Fix duplicate inline step execution on mid-step wake via message ownership#2848
VaguelySerious merged 11 commits into
mainfrom
peter/inline-step-ownership

Conversation

@VaguelySerious

@VaguelySerious VaguelySerious commented Jul 9, 2026

Copy link
Copy Markdown
Member

Fixes #2780

TLDR: tie steps to a queue message ID which needs to execute them, preventing all cases of competing/concurrent invocations trying to execute the same step.

Note: fixing local world still. Working in vercel/postgres

Problem

An inline step has no queue message of its own — that is the point of inline execution. But the pending-step dispatch on every wake replay queued every created, non-terminal step unconditionally, relying on idempotencyKey = correlationId to dedupe. For an inline step there is no prior message to dedupe against, so any wake mid-step (hook_received, an elapsed wait continuation, cancellation) enqueued a first message for that correlation id. Its consumer bare-step_started the running step (allowed — retries legitimately restart non-terminal steps) and ran the body a second time, concurrently. One step_completed wins; every side effect happened twice.

Fix: message-ID ownership

Rather than inventing a liveness mechanism, this reuses the queue's: a delivery of the owning message is the liveness lease.

  • The lazy step_started that creates an inline step stamps eventData.ownerMessageId = the handler's queue message ID (meta.messageId, stable across redeliveries — now documented in the Queue contract).
  • Ownership is derived from the step's latest step_started during replay: an unstamped bare start (a retry attempt) clears it; an owner-recovery start re-stamps it; step_retrying lapses it permanently (queue-owned from there).
  • The dispatch loop becomes a decision table:
Pending step state Action
Owned, owner is this message Re-execute in this invocation (crash recovery via queue redelivery)
Owned, owner is another message Delayed backstop enqueue: delaySeconds = lease remaining, idempotencyKey = correlationId
Unowned / lapsed / lease expired Immediate enqueue — unchanged

The delayed backstop folds the expiry escape hatch into the existing mechanism: a lost or exhausted owner message is bailed out after the lease, with step-level (catchable) failure semantics for poison steps, and the idempotency key caps it at one pending backstop regardless of wake count.

Lease + single-flight

  • Lease = fixed 860s (WORKFLOW_INLINE_OWNERSHIP_LEASE_SECONDS, clamped ≤900 = queue max delay). No runtime/build-time signal for the resolved maxDuration exists (builders emit 'max', platform-resolved per plan); the bound comes from the platform rule that 'max' resolves ≤800s. The rationale + revisit caveat live in a comment on the constant.
  • In-process single-flight (step-single-flight.ts) around step execution for the background/backstop/owned-recovery paths: the loser awaits the winner's settlement, then acks without executing (never early). This is what makes a mid-step backstop harmless on worlds with no invocation kill bound (world-local, self-hosted single process).
  • Invariant guard: a dev-mode check logs an error if any ack path (reinvoke) runs while an owned step is mid-body — load-bearing for crash recovery, guards future refactors.

Kill switch

WORKFLOW_INLINE_OWNERSHIP=0 reverts dispatch to the unconditional immediate requeue (stamping stays — inert data).

Tests

  • A hook_received while a step attempt is in flight re-dispatches the step; both attempts run concurrently in-process #2780 repro (workbench/vitest/test/inline-step-ownership.test.ts): open hook + 1.5s inline step + resume at ~400ms → side-effect marker fires exactly once. Verified the test faithfully reproduces the bug: with WORKFLOW_INLINE_OWNERSHIP=0 it fails with the marker fired twice.
  • Unit: single-flight semantics (loser never executes, acks only after winner settles, winner rejection doesn't propagate), lease/kill-switch env parsing, step_started schema round-trip, v4 wire split (splitEventDataForV4) for stamped lazy + bare re-stamp + unstamped starts.
  • Regression sweep: full @workflow/core suite (1408), world packages (incl. world-testing inline batches), full workbench/vitest suite — all green.

Backend dependency & rollout

Known residual (documented in code + design doc): a redelivery-while-alive of the owning message passes the owner check → duplicate. Equals the queue's at-least-once envelope, strictly rarer than today's every-wake duplication. Cross-instance long-step duplicates on multi-instance self-hosted worlds remain post-lease (raise the lease env to mitigate).

Docs

  • Design doc: docs/content/docs/v5/changelog/step-message-ownership.mdx (motivation, decision table, phases, edge cases)
  • WORKFLOW_INLINE_OWNERSHIP + WORKFLOW_INLINE_OWNERSHIP_LEASE_SECONDS documented in configuration/runtime-tuning.mdx
  • Queue contract: meta.messageId redelivery-stability expectation documented on createQueueHandler (worlds with unstable IDs degrade to backstop-lease behavior, never wedge)

🤖 Generated with Claude Code

Docs Preview

Page Preview
Inline step message ownership (design doc) /v5/docs/changelog/step-message-ownership
Runtime tuning — WORKFLOW_INLINE_OWNERSHIP / WORKFLOW_INLINE_OWNERSHIP_LEASE_SECONDS /v5/docs/configuration/runtime-tuning#workflow_inline_ownership
World queue reference — messageId stability note /v5/docs/api-reference/workflow-runtime/world/queue#createqueuehandler

…rship (#2780)

An inline step has no queue message of its own, so any wake (hook resume,
elapsed wait) that replayed the run mid-step enqueued a first message for
its correlation id; that message's handler bare-started the still-running
step and executed the body a second time, concurrently.

The lazy step_started now stamps eventData.ownerMessageId with the queue
message ID of the invocation running the body (re-stamped on owner-recovery
bare starts; cleared by unstamped retry starts; permanently lapsed at
step_retrying). The pending-step dispatch loop replaces the unconditional
immediate requeue with a decision table:

- owner == this message  -> re-execute in this invocation (crash recovery
  via queue redelivery of the owning message)
- ownership active, other message -> ensure a DELAYED backstop message
  (delaySeconds = lease remaining, idempotencyKey = correlationId), which
  doubles as the escape hatch for lost/exhausted owner messages
- unowned / lapsed / expired -> immediate enqueue, unchanged

The lease is a fixed 860s (WORKFLOW_INLINE_OWNERSHIP_LEASE_SECONDS): no
runtime/build-time signal for the resolved maxDuration exists, and 'max'
resolves to at most 800s on the platform. An in-process single-flight map
around step execution makes a backstop firing mid-step harmless on worlds
with no invocation kill bound (world-local). Kill switch:
WORKFLOW_INLINE_OWNERSHIP=0 reverts dispatch to the previous behavior.

The workbench repro (hook + 1.5s inline step + resume at ~400ms) executes
the side effect exactly once with ownership on, and twice with the kill
switch — reproducing the bug.

Requires the world-vercel backend to persist and re-emit the new
step_started meta field (deployed ahead of this change; older backends
safely degrade to today's behavior).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@VaguelySerious VaguelySerious requested review from a team and ijjk as code owners July 9, 2026 00:53
@vercel

vercel Bot commented Jul 9, 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 10, 2026 1:42am
example-nextjs-workflow-webpack Ready Ready Preview, Comment Jul 10, 2026 1:42am
example-workflow Ready Ready Preview, Comment Jul 10, 2026 1:42am
workbench-astro-workflow Ready Ready Preview, Comment Jul 10, 2026 1:42am
workbench-express-workflow Ready Ready Preview, Comment Jul 10, 2026 1:42am
workbench-fastify-workflow Ready Ready Preview, Comment Jul 10, 2026 1:42am
workbench-hono-workflow Ready Ready Preview, Comment Jul 10, 2026 1:42am
workbench-nitro-workflow Ready Ready Preview, Comment Jul 10, 2026 1:42am
workbench-nuxt-workflow Ready Ready Preview, Comment Jul 10, 2026 1:42am
workbench-sveltekit-workflow Ready Ready Preview, Comment Jul 10, 2026 1:42am
workbench-tanstack-start-workflow Ready Ready Preview, Comment Jul 10, 2026 1:42am
workbench-vite-workflow Ready Ready Preview, Comment Jul 10, 2026 1:42am
workflow-docs Ready Ready Preview, Comment, Open in v0 Jul 10, 2026 1:42am
workflow-swc-playground Ready Ready Preview, Comment Jul 10, 2026 1:42am
workflow-tarballs Ready Ready Preview, Comment Jul 10, 2026 1:42am
workflow-web Ready Ready Preview, Comment Jul 10, 2026 1:42am

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit c01b3da · Fri, 10 Jul 2026 01:57:06 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Avg (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS stream 1263 (+8.1%) 1661 🔴 1683 🔴 1827 🔴 30
TTFS hook + stream 1564 (+14%) 1901 🔴 1974 🔴 2158 🔴 30
STSO 1020 steps (1-20) 266 (-2.0%) 290 🔴 352 🔴 533 🔴 19
STSO 1020 steps (101-120) 391 (-6.1%) 379 🔴 504 🔴 761 🔴 19
STSO 1020 steps (1001-1020) 808 (-25%) 844 🔴 945 🔴 953 🔴 19
WO stream 1263 (+8.1%) 1661 1683 1827 30
WO hook + stream 1564 (+14%) 1901 1974 2158 30
SL stream 4056 (-18%) 5042 🔴 5591 🔴 6161 🔴 30
SL hook + stream 5064 (+0.8%) 5602 🔴 5699 🔴 5846 🔴 30
📜 Previous results (5)

0943aa5

Thu, 09 Jul 2026 23:31:30 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Avg (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS stream 1034 (-7.0%) 1558 🔴 1600 🔴 1663 🔴 30
TTFS hook + stream 1455 (+13%) 1817 🔴 1892 🔴 2112 🔴 30
STSO 1020 steps (1-20) 274 (+0.6%) 310 🔴 341 🔴 471 🔴 19
STSO 1020 steps (101-120) 422 (+2.7%) 478 🔴 555 🔴 559 🔴 19
STSO 1020 steps (1001-1020) 903 (+3.3%) 960 🔴 1171 🔴 1665 🔴 19
WO stream 1034 (-7.0%) 1558 1600 1663 30
WO hook + stream 1455 (+13%) 1817 1892 2112 30
SL stream 5048 (+8.0%) 5630 🔴 5773 🔴 5893 🔴 30
SL hook + stream 4796 (-4.7%) 5397 🔴 5473 🔴 5694 🔴 30

fe0c1de

Thu, 09 Jul 2026 19:43:34 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Avg (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS stream 1130 (+6.5%) 1582 🔴 1617 🔴 1928 🔴 30
TTFS hook + stream 1595 (+22%) 2013 🔴 2121 🔴 2478 🔴 30
STSO 1020 steps (1-20) 279 (+2.6%) 297 🔴 409 🔴 526 🔴 19
STSO 1020 steps (101-120) 454 (+13%) 550 🔴 616 🔴 662 🔴 19
STSO 1020 steps (1001-1020) 836 (±0%) 849 🔴 956 🔴 1090 🔴 19
WO stream 1130 (+6.5%) 1582 1617 1928 30
WO hook + stream 1595 (+22%) 2013 2121 2478 30
SL stream 4693 (-1.5%) 5598 🔴 5790 🔴 6107 🔴 30
SL hook + stream 4666 (-5.2%) 5367 🔴 5604 🔴 5649 🔴 30

da20e17

Thu, 09 Jul 2026 17:01:41 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Avg (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS stream 1273 (+0.5%) 1643 🔴 1760 🔴 1880 🔴 30
TTFS hook + stream 1476 (+8.6%) 1850 🔴 1938 🔴 2023 🔴 30
STSO 1020 steps (1-20) 276 (+7.0%) 323 🔴 379 🔴 409 🔴 19
STSO 1020 steps (101-120) 396 (-1.7%) 414 🔴 581 🔴 623 🔴 19
STSO 1020 steps (1001-1020) 912 (+10%) 999 🔴 1202 🔴 1325 🔴 19
WO stream 1273 (+0.5%) 1643 1760 1880 30
WO hook + stream 1476 (+8.6%) 1850 1938 2023 30
SL stream 4953 (+1.7%) 5527 🔴 5679 🔴 5902 🔴 30
SL hook + stream 4879 (+2.2%) 5430 🔴 5526 🔴 5724 🔴 30

1ec5e6e

Thu, 09 Jul 2026 01:47:45 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Avg (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS stream 1458 1728 🔴 1941 🔴 2234 🔴 30
TTFS hook + stream 1443 1945 🔴 2039 🔴 2782 🔴 30
STSO 1020 steps (1-20) 297 278 🔴 555 🔴 914 🔴 19
STSO 1020 steps (101-120) 379 415 🔴 512 🔴 516 🔴 19
STSO 1020 steps (1001-1020) 958 971 🔴 1384 🔴 1645 🔴 19
WO stream 1458 1728 1941 2234 30
WO hook + stream 1443 1945 2039 2782 30
SL stream 4983 5481 🔴 5823 🔴 5969 🔴 30
SL hook + stream 5294 5745 🔴 5923 🔴 6200 🔴 30

63c46e4

Thu, 09 Jul 2026 01:17:01 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Avg (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS stream 1247 1697 🔴 1717 🔴 1844 🔴 30
TTFS hook + stream 1516 1941 🔴 1987 🔴 2096 🔴 30
STSO 1020 steps (1-20) 262 282 🔴 331 🔴 480 🔴 19
STSO 1020 steps (101-120) 422 421 🔴 561 🔴 1016 🔴 19
STSO 1020 steps (1001-1020) 848 899 🔴 938 🔴 1031 🔴 19
WO stream 1247 1697 1717 1844 30
WO hook + stream 1516 1941 1987 2096 30
SL stream 4307 5373 🔴 5741 🔴 5972 🔴 30
SL hook + stream 5078 5650 🔴 5763 🔴 6103 🔴 30

Avg deltas compare against the most recent benchmark run on main at the time of this run.

Metrics — TTFS: time to first step body execution · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (time outside step bodies, client start → last step body exit) · SL: stream latency (first chunk write → visible to the reader)

Scenarios — stream: one step that streams chunks back to the client; no hooks, so the run stays in turbo mode · hook + stream: registers a hook before the same streaming step, which exits turbo mode · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges

🟢/🔴 mark percentiles within/above target. Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

TTFS/WO compare client vs deployment clocks and SL compares the step runner’s clock vs the client’s (NTP-synced in CI). WO ends at the last step body exit, the closest observable proxy for the final step-completion request.

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

Passed Failed Skipped Total
✅ ▲ Vercel Production 1453 0 230 1683
✅ 💻 Local Development 1617 0 219 1836
✅ 📦 Local Production 1617 0 219 1836
✅ 🐘 Local Postgres 1617 0 219 1836
✅ 🪟 Windows 153 0 0 153
✅ 📋 Other 894 0 177 1071
Total 7351 0 1064 8415

Details by Category

✅ ▲ Vercel Production
App Passed Failed Skipped
✅ astro 126 0 27
✅ example 126 0 27
✅ express 126 0 27
✅ fastify 126 0 27
✅ hono 126 0 27
✅ nextjs-turbopack 150 0 3
✅ nextjs-webpack 150 0 3
✅ nitro 126 0 27
✅ nuxt 126 0 27
✅ sveltekit 145 0 8
✅ vite 126 0 27
✅ 💻 Local Development
App Passed Failed Skipped
✅ astro-stable 128 0 25
✅ express-stable 128 0 25
✅ fastify-stable 128 0 25
✅ hono-stable 128 0 25
✅ nextjs-turbopack-canary 134 0 19
✅ nextjs-turbopack-stable 153 0 0
✅ nextjs-webpack-canary 134 0 19
✅ nextjs-webpack-stable 153 0 0
✅ nitro-stable 128 0 25
✅ nuxt-stable 128 0 25
✅ sveltekit-stable 147 0 6
✅ vite-stable 128 0 25
✅ 📦 Local Production
App Passed Failed Skipped
✅ astro-stable 128 0 25
✅ express-stable 128 0 25
✅ fastify-stable 128 0 25
✅ hono-stable 128 0 25
✅ nextjs-turbopack-canary 134 0 19
✅ nextjs-turbopack-stable 153 0 0
✅ nextjs-webpack-canary 134 0 19
✅ nextjs-webpack-stable 153 0 0
✅ nitro-stable 128 0 25
✅ nuxt-stable 128 0 25
✅ sveltekit-stable 147 0 6
✅ vite-stable 128 0 25
✅ 🐘 Local Postgres
App Passed Failed Skipped
✅ astro-stable 128 0 25
✅ express-stable 128 0 25
✅ fastify-stable 128 0 25
✅ hono-stable 128 0 25
✅ nextjs-turbopack-canary 134 0 19
✅ nextjs-turbopack-stable 153 0 0
✅ nextjs-webpack-canary 134 0 19
✅ nextjs-webpack-stable 153 0 0
✅ nitro-stable 128 0 25
✅ nuxt-stable 128 0 25
✅ sveltekit-stable 147 0 6
✅ vite-stable 128 0 25
✅ 🪟 Windows
App Passed Failed Skipped
✅ nextjs-turbopack 153 0 0
✅ 📋 Other
App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 128 0 25
✅ e2e-local-dev-tanstack-start- 128 0 25
✅ e2e-local-postgres-nest-stable 128 0 25
✅ e2e-local-postgres-tanstack-start- 128 0 25
✅ e2e-local-prod-nest-stable 128 0 25
✅ e2e-local-prod-tanstack-start- 128 0 25
✅ e2e-vercel-prod-tanstack-start 126 0 27

📋 View full workflow run

@changeset-bot

changeset-bot Bot commented Jul 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c01b3da

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

This PR includes changesets to release 20 packages
Name Type
@workflow/core Patch
@workflow/world Patch
@workflow/world-vercel 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/world-local Patch
@workflow/world-postgres Patch
@workflow/astro Patch
@workflow/nest Patch
@workflow/nuxt Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite 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 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:

Reporter-generated e2e diagnostics sidecar files (e2e-diagnostics-*.json) written to the repo root are not gitignored, so they can be accidentally committed again.

Fix on Vercel

@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 the ownership model, the dispatch decision table, the single-flight layer, and the wire changes; ran all the suites plus the workbench regression test. Approving — this is a well-engineered fix for a nasty class of bug, and the design writeups in the code are exemplary.

The core idea holds up under adversarial reading. Reusing the queue's own delivery semantics as the liveness lease avoids inventing a heartbeat mechanism, and the three safety layers compose correctly:

  1. Ownership lease — the 860s constant's justification chain is solid: builders emit maxDuration: 'max' which resolves to ≤800s by platform rule, so the lease dominates any invocation lifetime, while staying under the 900s queue delay cap so one delayed message always suffices. The doc comment flags exactly the condition that would invalidate it (the 30-minute beta becoming reachable via 'max').
  2. Single-flight — the subtle part is the loser awaiting settlement rather than ack-and-skip, and the reasoning is right: an early ack followed by a crash could consume the only message left to drive the step. Awaiting keeps the at-least-once envelope intact and degrades to polling if the loser times out.
  3. Delayed backstop as a run continuation (not a step message) — this is the right shape: when it fires, the replay re-derives ownership from the log instead of blindly bare-starting, so a step that completed in the meantime costs nothing, and the :backstop idempotency suffix keeps it from colliding with the step's own message key.

Ownership derivation is deterministic-safe: it's computed from replayed events but only feeds dispatch decisions (operational, never recorded), and the latest-start-wins / step_retrying-lapses-permanently semantics are implemented identically in both places that derive it (the replay consumer in step.ts and the raw-event fast-path scan), with the invariant guard on ack paths as a nice canary against future ordering refactors.

Graceful degradation is covered in every direction I checked: kill switch reverts to today's dispatch while stamping stays inert; worlds with unstable message IDs never match the owner check and fall to the backstop path (documented in the Queue contract as SHOULD with exactly this analysis); backends that don't persist the field yield unowned steps = today's behavior; missing timestamps zero the lease = immediate enqueue; old SDKs never send the field and new SDKs tolerate its absence.

Verified locally: core 1408/1408, world 71/71, world-vercel 231/231, and the workbench regression test (hook resume landing mid-step-body, asserting the marker side effect executes exactly once) passes against the local world — which also exercises the single-flight layer, since that's what protects the no-kill-bound single-process world. CI is fully green including all e2e lanes.

Two non-blocking questions/notes:

  1. Backstop idempotency dedupe window: a double-orphan sequence (owner crashes → backstop fires → recovery re-owns under a new message → that crashes too) re-enqueues ${correlationId}:backstop with the same key. If that lands inside the queue's dedupe window, the second backstop is dropped — recovery then rests on the owner message's own redelivery (the primary mechanism) or any later wake. That layering looks sufficient to me, but it'd be worth a sentence in the changelog doc confirming the dedupe-window interplay was considered, since the backstop is billed as the escape hatch for exhausted owner messages.
  2. The assertNoInFlightOwnedSteps guard logs an error but doesn't throw — right call for production, just noting that a CI-only hard-fail mode could make regressions louder in e2e lanes.

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

Requesting changes for one liveness hole in the backstop path. I checked the ownership state derivation, dispatch decision table, owner recovery, retry handoff, single-flight behavior, world queue deduplication semantics, CI/E2E coverage, and the latency report. The overall ownership approach looks sound, but the fixed backstop idempotency key means the documented refreshed-lease re-arm cannot actually create a replacement message. Please address the inline finding and add a regression test for the full re-arm sequence.

Comment thread packages/core/src/runtime.ts Outdated
Review finding (liveness hole): queues dedupe an idempotency key for the
original message's lifetime — world-local retains it while a delivery is
in flight, and Vercel Queues behaves the same — so a fixed
`${correlationId}:backstop` key meant a backstop firing during a
refreshed lease (owner recovery re-stamped after the backstop was armed)
could never publish its own replacement: the re-arm deduped against the
in-flight backstop message and was dropped. If the recovered owner then
died with its redelivery budget exhausted, no escape hatch remained and
the run wedged until an unrelated external wake.

The key is now `${correlationId}:backstop:${lastStartedAt}` — scoped to
the ownership epoch (the latest step_started's persisted timestamp, so
every replayer derives the same key). Wakes within one epoch still
dedupe to a single pending backstop; an owner-recovery re-stamp changes
the epoch and gives the re-arm a fresh key. Pending backstops stay
bounded by the queue's redelivery budget for the owning message.

The ownership helpers move from runtime.ts into
runtime/step-ownership.ts so the key derivation and dispatch predicates
are unit-testable; step-ownership.test.ts covers the state machine, the
lease math, and the full re-arm sequence from the review (wake arms →
re-stamp → backstop fires mid-lease → replacement accepted despite
in-flight key retention) against a dedupe model matching world-local's
inflightMessages behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

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

Re-reviewed at da20e17. My previous blocking finding — the fixed backstop idempotency key that couldn't re-arm after an owner-recovery lease refresh — is resolved by the epoch-scoped key (correlationId:backstop:lastStartedAt), and the new regression test in step-ownership.test.ts models the in-flight key-retention behavior and walks the full re-arm sequence. Approving.

What I checked on this pass:

  • Epoch-key fix: the key changes on every owner-recovery re-stamp (new lastStartedAt), stays stable within an epoch (fan-out capped at one pending backstop), and never collides with the step message's own correlationId key, so the retry handoff can't be absorbed. Verified world-local retains idempotency keys while a delivery is in flight (inflightMessages released post-loop), which is exactly the hazard the epoch suffix escapes.
  • Ownership state derivation: latest-start-wins in both the replay consumer (step.ts) and the raw-event scan (hasPendingStepOwnedByMessage), with step_retrying as a permanent lapse in both — the two derivations agree. The queue items carrying the new fields are the same objects surfaced as pendingSteps, so state flows through the suspension handler untouched.
  • Dispatch decision table: owner match → owned recovery (in-invocation re-execution); live foreign lease → delayed backstop; lease expired / unstamped / retrying / kill-switched → the exact pre-PR immediate enqueue. Kill switch reverts dispatch while stamping stays inert.
  • Type-level hazards: meta.messageId is required (non-optional) in the Queue contract, so no undefined === undefined false ownership match; Event.createdAt is z.coerce.date() and world-vercel's read path explicitly coerces before EventSchema.safeParse, so +event.createdAt in the consumer is a real ms timestamp on all worlds; {type:'skipped'} is a pre-existing StepExecutionResult member and lands in the terminal bucket (loop back to replay), which is correct for a single-flight loser.
  • Interactions: inline-delta fast path and turbo forced-optimistic-start both exclude owned recovery (ownedRecoverySteps.length === 0 guards); latency tracking restricted to lazy first steps; retry results from owned-recovery steps flow into the existing keyed delayed-retry handoff after step_retrying lapses ownership; the background fast-path fall-through only fires when this message owns a pending step, otherwise the early return is unchanged.
  • world-local messageId stability: one ID minted per enqueued message, reused across the delivery retry loop (x-vqs-message-id constant, attempt counter separate) — the lease's redelivery-recovery assumption holds there.
  • Test verification: ran the new unit suites (66 tests), @workflow/world (71), @workflow/world-vercel (231), and the full workbench/vitest suite (39) — all green (core e2e/* failures in my sandbox are missing-build-artifact environmental, not PR-related). Confirmed the #2780 repro is faithful: with WORKFLOW_INLINE_OWNERSHIP=0 it fails with the marker written exactly twice; with the fix it passes.

Noted, not blocking: the deploy-order dependency (backend must persist/re-emit meta.ownerMessageId before this SDK ships) is documented in the rollout section; the benchmark deltas (hook+stream TTFS +8.6% avg) are within the run-to-run spread of the three posted runs. One non-blocking robustness nit inline.

Comment thread packages/core/src/runtime/step-ownership.ts Outdated
VaguelySerious and others added 5 commits July 9, 2026 12:22
lastStartedAt is the server-stamped event createdAt while nowMs is the
local clock, so a client running behind the server computed a lease
remainder longer than the lease itself. With the lease tuned to the
900s cap that yields a delaySeconds above the queue per-message maximum,
which SQS-backed community worlds reject — the wake replay's backstop
enqueue would throw and ride the redelivery loop until the lease
drained. Clamp the remainder to the configured lease so skew is
strictly harmless.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Observability (design doc Phase 7, previously unimplemented): span
attributes workflow.inline_ownership.owned_recovery_steps and
workflow.inline_ownership.backstop_wakes_armed on the workflow.execute
span, and warn-level (always printed) logs for owned recovery and
single-flight absorption — debug/info logs are invisible without DEBUG,
so an operator triaging a duplicate-step or wedged-run report previously
could not tell whether recovery ran or a backstop was armed.

Also documents two load-bearing subtleties found in re-review:
- turbo's attempt-1 gate is what makes its reinvoke() paths (hook
  conflict, throttle) safe with inline ownership: owned-recovery steps
  only exist on redeliveries, so turbo can never ack a message that
  inline-owns a non-terminal step. Comment added so a future change to
  turbo's engagement conditions doesn't silently break this.
- clock-skew clamp rationale in the design doc's lease formula.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the phase-by-phase implementation plan (scaffolding that served
its purpose) with a rationale-first document: the liveness problem
underneath #2780, why the queue messageId is the ownership identity, why
ownership must be lease-bounded and why the lease is a fixed 860s
constant, why the non-owner action is an epoch-keyed delayed backstop
wake (including both rejected backstop shapes and the evidence that
killed them), why in-process single-flight is a required layer, and why
the alternatives — queue serialization, inline-eligibility latch, inline
retries, heartbeat/lock stores, lease derivation, a backend-side fix —
were rejected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The previous framing led with redelivery exhaustion, but that path
fails the run via the SDK's max-deliveries check even without a lease.
Replace it with what the lease actually protects against: owner-message
loss the SDK never observes (queue-side DLQ below the SDK budget,
retention expiry, operator purge), unstable-message-ID worlds (where
the lease is the entire recovery mechanism), insurance on the
ack-while-owned invariant, and step-level vs run-level failure
granularity for poison steps.

Co-Authored-By: Claude Fable 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.

A hook_received while a step attempt is in flight re-dispatches the step; both attempts run concurrently in-process

3 participants