Skip to content

[core] Don't count racing invocations' duplicate step_started events toward the maxRetries ceiling - #3069

Merged
TooTallNate merged 4 commits into
mainfrom
fix/inline-maxretries-race
Jul 24, 2026
Merged

[core] Don't count racing invocations' duplicate step_started events toward the maxRetries ceiling#3069
TooTallNate merged 4 commits into
mainfrom
fix/inline-maxretries-race

Conversation

@TooTallNate

Copy link
Copy Markdown
Member

Problem

The retry-ceiling guard added in #3035 derives a step's attempt number from the number of step_started events recorded for it. That count is not a reliable attempt number: invocations racing on the same pending batch (stale replays, wake replays, a step message dispatched off a lost create-claim) can each write a step_started for the same logical attempt. Worlds without an atomic start guard (world-local) let all of them through, so a healthy step's count can cross maxRetries + 1 and the guard fails the run with a false exceeded max retries FatalError.

This is the root cause of the world-testing > inline-batches-debug unit-test flake on CI (e.g. this run on main and this one on #3048): the test deliberately provokes concurrent replays racing on parallel step batches; on slow runners enough duplicate starts accumulate that a recovery re-run computes an attempt above the ceiling → run_failed → the test polls for completed until its 60s timeout. (The guard's error log was invisible in the test output because stepLogger uses the workflow:step:* namespace while the test only enables DEBUG=workflow:runtime:*.)

Fix

Scope the step_started count to the starts each ceiling is actually about, using the ownerMessageId stamp that inline starts already carry:

  • Inline owned-recovery ceiling: count only starts stamped with this message'sownerMessageId. Each real (re)delivery of the owning message re-stamps its ID on the start it writes, while racing invocations stamp their own IDs (or none) — so they no longer inflate this owner's attempt number.
  • Background-step ceiling (the delivery-count-gated verification): count only bare/unstamped starts. The background path never stamps ownership, so inline racers' stamped starts are excluded; throttle/too-early redeliveries (which write no start at all) stay excluded as before.

Real timeout retries are still bounded — the #3035 scenario is unchanged: each genuine recovery re-run writes another owner-stamped (or bare) start, so genuine exhaustion still trips the guard at the same point.

Testing

  • New unit tests for countStepStartedEvents covering both filters, including a regression case shaped like the CI flake (owner start + racer starts must not exhaust the ceiling) and a counter-case (4 genuine owner re-runs still exceed it).
  • Full @workflow/core suite: 1516 passed | 3 expected fail.
  • @workflow/world-testing suite (contains the flaky test): 15/15 × 4 consecutive runs. (The flake needs CI-slow timing to reproduce, so local runs can't fully prove it — but the new unit tests encode the failure mechanism directly.)

…toward the maxRetries ceiling
The retry-ceiling guard added in #3035 derives a step's attempt number from
the number of step_started events in the log. But invocations racing on the
same pending batch (stale replays, wake replays, step messages dispatched
off a lost create-claim) can each write a step_started for the same logical
attempt — worlds without an atomic start guard (world-local) let them all
through — so a healthy step's count could cross the ceiling and fail the
run with a false "exceeded max retries" (the world-testing
inline-batches-debug CI flake).
Scope the count to the starts each ceiling is actually about:
- inline owned-recovery: only starts stamped with THIS message's
ownerMessageId (each real (re)delivery of the owner re-stamps it;
racers stamp their own IDs or none)
- background steps: only bare/unstamped starts (that path never stamps
ownership, and throttle/too-early redeliveries still write no start)
Real timeout retries still bound: each recovery re-run writes another
owner-stamped (or bare) start, so genuine exhaustion still trips the guard.
@TooTallNate
TooTallNate requested review from a team and ijjk as code ownersJuly 23, 2026 20:17
CopilotAI review requested due to automatic review settings July 23, 2026 20:17
@changeset-bot

changeset-botBot commented Jul 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3a31e52

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

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

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

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
✅ 📦 Local Production162102271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7519011328651
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro126028
✅ example126028
✅ express126028
✅ fastify126028
✅ hono126028
✅ nextjs-turbopack15103
✅ nextjs-webpack15103
✅ nitro126028
✅ nuxt126028
✅ sveltekit14509
✅ vite126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128026
✅ e2e-local-dev-tanstack-start-128026
✅ e2e-local-postgres-nest-stable128026
✅ e2e-local-postgres-tanstack-start-128026
✅ e2e-local-prod-nest-stable128026
✅ e2e-local-prod-tanstack-start-128026
✅ e2e-vercel-prod-nest126028
✅ e2e-vercel-prod-tanstack-start126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@github-actions

github-actionsBot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 3a31e52 · Fri, 24 Jul 2026 22:10:47 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1229 (+58%) 🔻1365 🔴 (+25%) 🔻1406 🔴 (+26%) 🔻1548 🔴 (-1.0%)30
TTFSstream217 (+7.4%)1391 🔴 (+33%) 🔻1439 🔴 (+34%) 🔻1503 🔴 (+34%) 🔻30
TTFShook + stream1561 (+28%) 🔻1715 🔴 (+29%) 🔻1786 🔴 (+20%) 🔻1813 🔴 (-7.2%)30
STSO1020 steps (1-20)207 (+25%) 🔻285 🔴 (+8.0%)325 🔴 (-18%) 💚390 🔴 (-22%) 💚19
STSO1020 steps (101-120)216 (+32%) 🔻279 🔴 (+12%)321 🔴 (-8.8%)468 🔴 (+33%) 🔻19
STSO1020 steps (1001-1020)499 (+4.4%)616 🔴 (+14%)631 🔴 (+9.7%)651 🔴 (+13%)19
WO1020 steps417693 (+8.7%)417693 (+8.7%)417693 (+8.7%)417693 (+8.7%)1
SLstream latency125 (+67%) 🔻160 🔴 (+31%) 🔻178 🔴 (+40%) 🔻182 🔴 (-78%) 💚30
SOstream overhead (text)128 (+39%) 🔻198 (+29%) 🔻218 (+25%) 🔻332 (-7.0%)30
SOstream overhead (structured)129 (+25%) 🔻204 (+30%) 🔻230 (+28%) 🔻295 (+31%) 🔻30
📜 Previous results (3)

034e9a6

Fri, 24 Jul 2026 21:18:29 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1212 (+69%) 🔻1273 🔴 (+21%) 🔻1303 🔴 (+21%) 🔻1635 🔴 (+14%)30
TTFSstream358 (-62%) 💚1296 🔴 (+27%) 🔻1306 🔴 (+26%) 🔻1424 🔴 (+36%) 🔻30
TTFShook + stream464 (-61%) 💚1533 🔴 (+16%) 🔻1553 🔴 (+12%)1657 🔴 (-0.8%)30
STSO1020 steps (1-20)166 (+2.5%)270 🔴 (+8.4%)423 🔴 (+41%) 🔻433 🔴 (+42%) 🔻19
STSO1020 steps (101-120)174 (-5.9%)274 🔴 (-1.1%)356 🔴 (+9.2%)1718 🔴 (+384%) 🔻19
STSO1020 steps (1001-1020)469 (+2.9%)576 🔴 (+4.0%)709 🔴 (+19%) 🔻2977 🔴 (+262%) 🔻19
WO1020 steps375294 (-1.9%)375294 (-1.9%)375294 (-1.9%)375294 (-1.9%)1
SLstream latency86 (+15%)135 🔴 (+12%)150 🔴 (+8.7%)298 🔴 (-65%) 💚30
SOstream overhead (structured)90 (-8.2%)185 (+7.6%)308 (+24%) 🔻581 (+108%) 🔻30

95f6d0f

Fri, 24 Jul 2026 01:01:52 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1216 (+27%) 🔻1333 🔴 (+24%) 🔻1337 🔴 (+18%) 🔻1656 🔴 (+16%) 🔻30
TTFSstream1131 (+17%) 🔻1217 🔴 (+16%) 🔻1234 🔴 (+16%) 🔻1325 🔴 (+17%) 🔻30
TTFShook + stream1339 (+10%)1461 🔴 (+12%)1572 🔴 (+16%) 🔻1658 🔴 (+17%) 🔻30
STSO1020 steps (1-20)163 (-6.3%)277 🔴 (+12%)394 🔴 (+41%) 🔻482 🔴 (+40%) 🔻19
STSO1020 steps (101-120)185 (-2.1%)272 🔴 (-1.4%)385 🔴 (+17%) 🔻431 🔴 (+14%)19
STSO1020 steps (1001-1020)482 (+5.5%)568 🔴 (+10%)696 🔴 (+31%) 🔻698 🔴 (+12%)19
WO1020 steps398314 (+3.1%)398314 (+3.1%)398314 (+3.1%)398314 (+3.1%)1
SLstream latency89 (+11%)126 🔴 (-4.5%)153 🔴 (-15%)342 🔴 (-8.8%)30

a0eed2f

Thu, 23 Jul 2026 20:34:21 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1251 (+23%) 🔻1370 🔴 (+11%)1382 🔴 (+9.3%)1693 🔴 (+26%) 🔻30
TTFSstream1244 (+216%) 🔻1330 🔴 (+17%) 🔻1380 🔴 (+14%)1414 🔴 (-59%) 💚30
TTFShook + stream1208 (+101%) 🔻1646 🔴 (+12%)1676 🔴 (+10%)1830 🔴 (+11%)30
STSO1020 steps (1-20)180 (-12%)279 🔴 (-13%)296 🔴 (-13%)308 🔴 (-18%) 💚19
STSO1020 steps (101-120)194 (-11%)291 🔴 (-1.7%)380 🔴 (-5.0%)381 🔴 (-27%) 💚19
STSO1020 steps (1001-1020)479 (-8.2%)531 🔴 (-14%)592 🔴 (-20%) 💚608 🔴 (-19%) 💚19
WO1020 steps397530 (-15%) 💚397530 (-15%) 💚397530 (-15%) 💚397530 (-15%) 💚1
SLstream latency98 (±0%)148 🔴 (-31%) 💚175 🔴 (-32%) 💚396 🔴 (+33%) 🔻30
ℹ️ Metric definitions & methodology

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

CopilotAI 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

Fixes a false-positive maxRetries exhaustion in @workflow/core caused by counting duplicate step_started events emitted by concurrent/racing invocations as separate attempts, especially in worlds without an atomic start guard (e.g. world-local). The change scopes “authoritative attempt” derivation to the specific class of starts each ceiling is intended to measure.

Changes:

  • Add an ownership-aware filter to the step_started-counting helper so inline owned-recovery counts only starts stamped with the current ownerMessageId, while background-step verification counts only bare/unstamped starts.
  • Update both the background-step retry ceiling verification and the inline owned-recovery ceiling to use the filtered counts.
  • Add focused unit tests covering both filters and the CI-flake-shaped regression case; add a changeset for @workflow/core.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

FileDescription
packages/core/src/runtime.tsAdds owner-filtered step_started counting and applies it to inline/background retry ceiling logic to avoid false maxRetries exhaustion from racing invocations.
packages/core/src/runtime.test.tsAdds unit tests validating the new counting behavior (owned/unowned filters + regression/counter-case).
.changeset/inline-maxretries-race.mdPublishes the fix as a patch changeset for @workflow/core.

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

Comment threadpackages/core/src/runtime.ts Outdated
Comment on lines 277 to 309
export type StepStartOwnerFilter =
| { type: 'ownedBy'; messageId: string }
| { type: 'unowned' };

/**
* Number of `step_started` events already recorded for a step, used as the
* authoritative attempt count for the inline retry ceiling. Each real attempt
* writes exactly one `step_started` (the atomic create-claim / single-flight
* prevents concurrent double-starts from inflating this), so the count equals
* the number of attempts that have begun.
* authoritative attempt count for the retry ceiling.
*
* IMPORTANT: the raw (unfiltered) count is NOT a reliable attempt number.
* Concurrent invocations racing on the same pending batch (stale replays,
* wake replays, a step message dispatched by a replay that lost the
* create-claim race, ...) can each write a `step_started` for the same
* logical attempt — worlds without an atomic start guard (world-local) let
* all of them through. Counting those duplicates as "attempts" made the
* maxRetries ceiling fire on healthy runs and fail them with a false
* "exceeded max retries" (see workflow#3048 CI flake).
*
* Callers must therefore scope the count to the starts their ceiling is
* actually about via `ownerFilter`:
* - the inline owned-recovery ceiling counts only THIS message's starts
* (`ownedBy`) — each real (re)delivery of the owning message stamps its
* `ownerMessageId` on the start it writes, while racing invocations stamp
* their own IDs (or none), so they no longer inflate the count;
* - the background-step ceiling counts only bare starts (`unowned`) — the
* background path never stamps ownership, so inline racers' stamped
* starts are excluded, and throttle/too-early redeliveries (which write
* no start at all) stay excluded as before.
*/
function countStepStartedEvents(
export function countStepStartedEvents(
events: Event[] | null | undefined,
stepId: string
stepId: string,
ownerFilter?: StepStartOwnerFilter
): number {

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Good catch — ./runtime is indeed a public subpath export. Moved the helper (and its filter type) to an internal module, src/runtime/count-step-started-events.ts, imported relatively from runtime.ts; the tests moved to a sibling test file and import it by relative path. No new public API surface. Done in 95f6d0f.

@VaguelySerious

Copy link
Copy Markdown
Member

Huh, I thought this should already be safe under the assumptions that we only create step_started when we're actually about to run the step, so that step_started POST should already be gated by whether we're on the invocation with the right message ID 🤔

@VaguelySerious

Copy link
Copy Markdown
Member

(AI) Dug into the write path — the premise doesn't hold, on two counts:

1. ownerMessageId is a stamp, not a write gate. It's just a field on step_started's eventData (packages/world/src/events.ts:357-369), used by wake replays to reason about the liveness lease. Nothing rejects a write because it carries the "wrong" message ID. That's precisely why this PR has to reconstruct the message-scoping post-hoc at count time — there's no gate at the write layer to lean on.

2. The only write-time exactly-once guard is the create-claim, and it covers step creation, not step starts. In world-local the atomic claim fires only on the lazy-start path (!validatedStep && lazyStepStart, events-storage.ts:1443-1468): the first step_started that creates the step. The winner runs the body; losers get EntityConflictErrorskipped. But once the step exists — eager step_created, or after the create resolved — a subsequent step_started is just a start transition on an existing non-terminal step, and those are explicitly allowed:

Duplicate step_started events for a non-terminal step are still allowed (retries legitimately re-start a step), only writes to an already-terminal step are rejected. (events-storage.ts:110-112)

So in the inline-batches scenario the steps are eagerly created and queued, then several concurrent invocations (stale replay, wake replay, a step message dispatched off a lost create-claim) each pass the not-terminal check and each append a step_started for the same logical attempt. The create-claim never fires (nothing is being created), and there's no message-ID gate — so #3035's raw count reads N starts for 1 attempt and trips the ceiling.

Why not just gate the write by message ID instead? You can't distinguish the two cases at write time: the legitimate retry path is a different message re-starting the same non-terminal step, and a genuine owned-recovery re-run re-stamps the same messageId. "Duplicate racer start for the current attempt" and "legitimate next attempt" look identical to the write layer — both are non-terminal starts. ownerMessageId is the only signal that separates them, which is why the fix applies it at count time rather than at the POST.

(world-vercel's server-side guard makes the duplicate-non-terminal-start window much narrower, which is why the flake surfaced on world-local/world-testing — but the ceiling is computed in core against the event log, so the fix has to be world-agnostic.)

@VaguelySeriousVaguelySerious 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

s.correlationId
s.correlationId,
{
type: 'ownedBy',

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

Splitting the count into two disjoint filters means neither ceiling sees the combined attempt total across a mixed owned→bare sequence — and the authoritativeAttempt guard is the only bound for a step that always times out (writes no step_failed, so the error-gated guard at step-executor.ts:661 never fires).

Concretely, with maxRetries=3 (ceiling = attempt > 4), a step that times out under owned recovery a few times and then transitions to the queued/bare path:

step_started ownerMessageId=msg_OWNER // owned attempt 1
step_started ownerMessageId=msg_OWNER // owned attempt 2 (crash recovery)
step_started ownerMessageId=msg_OWNER // owned attempt 3 (crash recovery)
step_started (bare) // attempt 4 (lease expired → queued)
step_started (bare) // attempt 5 (queued retry)

I ran this through countStepStartedEvents on this branch to confirm (a throwaway probe, not committed). So the PR-description claim "genuine exhaustion still trips the guard at the same point" holds for pure-owned and pure-bare sequences but not strictly for a mixed one. It's still bounded overall (owned side by the queue's max-receive count, bare side by metadata.attempt), so this is not an infinite-loop regression and I'm not blocking on it — but the effective ceiling for an always-timing-out step that changes ownership mode can exceed maxRetries. Worth confirming that's acceptable, and ideally a test for the mixed case since the two current tests only cover the pure sequences.


Nit (folding in a second point to keep this on a diffable line): the comments here and near line 1953 call the owned-recovery start a "bare step_started", but ownerMessageId: metadata.messageId is stamped unconditionally just below — so it is not bare; it carries the owner stamp (which is what makes the ownedBy ceiling count it). Elsewhere "bare" means "no ownerMessageId" (the unowned background start). Reusing the word for both reads as contradictory.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

You're right on both counts — thanks for probing the mixed sequence, and I decided it's worth closing rather than just documenting. Addressed in 95f6d0f:

Mixed owned→bare gap: the background ceiling's scope is now totalAttempts = bare starts + the largest single owner's starts, instead of bare-only. Your exact sequence now computes attempt 3 (owned) + 2 (bare) + 1 = 6 > 4 → trips, restoring the #3035 behavior for genuine mixed lifecycles. The max-over-owners is what keeps the false positive fixed: a genuine lifecycle has at most one inline-ownership phase (ownership is claimed atomically at creation and lapses permanently at step_retrying or on any bare start), so the genuine owner's attempts are counted in full, while racing invocations' one-off stamped duplicates contribute at most their single largest count instead of accumulating. (If a racer's stamped start steals latest-start ownership mid-race, the superseded owner's attempts get shadowed by the max — that residual undercount is in the permissive direction and still bounded by the delivery-count gate.) Your sequence is committed as the mixed owned→bare timeout sequence trips the combined background ceiling test, and the racer-regression test now also asserts totalAttempts stays inside the ceiling.

The inline owned-recovery ceiling intentionally stays ownedBy: a mixed history can't precede owned recovery (a bare start clears ownership, so the owner's redelivery would no longer own the step and it falls through to the queued path, where the combined ceiling applies).

Terminology nit: fixed — the owned-recovery start is now described as a payload-less (but owner-stamped) start at both spots in runtime.ts, plus the two matching comments in step-executor.ts; "bare" is reserved for the background path's unstamped start.

@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for fd05393 (AI decision).

This fixes the retry-ceiling guard from #3035, which is main-only code: packages/core/src/runtime/step-executor.ts does not exist on origin/stable, and origin/stable:packages/core/src/runtime.ts contains no countStepStartedEvents, authoritativeAttempt, ownerMessageId, or exceeded max retries logic at all. With neither the guard nor the inline step-ownership stamping present on stable, the false-failure bug being fixed cannot occur there and the change has nothing to apply to.

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

fd05393d2b47d1a2f347538dd3b6062d808548d2

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.

3 participants

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

[core] Don't count racing invocations' duplicate step_started events toward the maxRetries ceiling - #3069

Merged
TooTallNate merged 4 commits into
mainfrom
fix/inline-maxretries-race
Jul 24, 2026
Merged

[core] Don't count racing invocations' duplicate step_started events toward the maxRetries ceiling#3069
TooTallNate merged 4 commits into
mainfrom
fix/inline-maxretries-race

Conversation

@TooTallNate

Copy link
Copy Markdown
Member

Problem

The retry-ceiling guard added in #3035 derives a step's attempt number from the number of step_started events recorded for it. That count is not a reliable attempt number: invocations racing on the same pending batch (stale replays, wake replays, a step message dispatched off a lost create-claim) can each write a step_started for the same logical attempt. Worlds without an atomic start guard (world-local) let all of them through, so a healthy step's count can cross maxRetries + 1 and the guard fails the run with a false exceeded max retries FatalError.

This is the root cause of the world-testing > inline-batches-debug unit-test flake on CI (e.g. this run on main and this one on #3048): the test deliberately provokes concurrent replays racing on parallel step batches; on slow runners enough duplicate starts accumulate that a recovery re-run computes an attempt above the ceiling → run_failed → the test polls for completed until its 60s timeout. (The guard's error log was invisible in the test output because stepLogger uses the workflow:step:* namespace while the test only enables DEBUG=workflow:runtime:*.)

Fix

Scope the step_started count to the starts each ceiling is actually about, using the ownerMessageId stamp that inline starts already carry:

  • Inline owned-recovery ceiling: count only starts stamped with this message'sownerMessageId. Each real (re)delivery of the owning message re-stamps its ID on the start it writes, while racing invocations stamp their own IDs (or none) — so they no longer inflate this owner's attempt number.
  • Background-step ceiling (the delivery-count-gated verification): count only bare/unstamped starts. The background path never stamps ownership, so inline racers' stamped starts are excluded; throttle/too-early redeliveries (which write no start at all) stay excluded as before.

Real timeout retries are still bounded — the #3035 scenario is unchanged: each genuine recovery re-run writes another owner-stamped (or bare) start, so genuine exhaustion still trips the guard at the same point.

Testing

  • New unit tests for countStepStartedEvents covering both filters, including a regression case shaped like the CI flake (owner start + racer starts must not exhaust the ceiling) and a counter-case (4 genuine owner re-runs still exceed it).
  • Full @workflow/core suite: 1516 passed | 3 expected fail.
  • @workflow/world-testing suite (contains the flaky test): 15/15 × 4 consecutive runs. (The flake needs CI-slow timing to reproduce, so local runs can't fully prove it — but the new unit tests encode the failure mechanism directly.)

…toward the maxRetries ceiling
The retry-ceiling guard added in #3035 derives a step's attempt number from
the number of step_started events in the log. But invocations racing on the
same pending batch (stale replays, wake replays, step messages dispatched
off a lost create-claim) can each write a step_started for the same logical
attempt — worlds without an atomic start guard (world-local) let them all
through — so a healthy step's count could cross the ceiling and fail the
run with a false "exceeded max retries" (the world-testing
inline-batches-debug CI flake).
Scope the count to the starts each ceiling is actually about:
- inline owned-recovery: only starts stamped with THIS message's
ownerMessageId (each real (re)delivery of the owner re-stamps it;
racers stamp their own IDs or none)
- background steps: only bare/unstamped starts (that path never stamps
ownership, and throttle/too-early redeliveries still write no start)
Real timeout retries still bound: each recovery re-run writes another
owner-stamped (or bare) start, so genuine exhaustion still trips the guard.
@TooTallNate
TooTallNate requested review from a team and ijjk as code ownersJuly 23, 2026 20:17
CopilotAI review requested due to automatic review settings July 23, 2026 20:17
@changeset-bot

changeset-botBot commented Jul 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3a31e52

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

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

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

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
✅ 📦 Local Production162102271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7519011328651
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro126028
✅ example126028
✅ express126028
✅ fastify126028
✅ hono126028
✅ nextjs-turbopack15103
✅ nextjs-webpack15103
✅ nitro126028
✅ nuxt126028
✅ sveltekit14509
✅ vite126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128026
✅ e2e-local-dev-tanstack-start-128026
✅ e2e-local-postgres-nest-stable128026
✅ e2e-local-postgres-tanstack-start-128026
✅ e2e-local-prod-nest-stable128026
✅ e2e-local-prod-tanstack-start-128026
✅ e2e-vercel-prod-nest126028
✅ e2e-vercel-prod-tanstack-start126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@github-actions

github-actionsBot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 3a31e52 · Fri, 24 Jul 2026 22:10:47 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1229 (+58%) 🔻1365 🔴 (+25%) 🔻1406 🔴 (+26%) 🔻1548 🔴 (-1.0%)30
TTFSstream217 (+7.4%)1391 🔴 (+33%) 🔻1439 🔴 (+34%) 🔻1503 🔴 (+34%) 🔻30
TTFShook + stream1561 (+28%) 🔻1715 🔴 (+29%) 🔻1786 🔴 (+20%) 🔻1813 🔴 (-7.2%)30
STSO1020 steps (1-20)207 (+25%) 🔻285 🔴 (+8.0%)325 🔴 (-18%) 💚390 🔴 (-22%) 💚19
STSO1020 steps (101-120)216 (+32%) 🔻279 🔴 (+12%)321 🔴 (-8.8%)468 🔴 (+33%) 🔻19
STSO1020 steps (1001-1020)499 (+4.4%)616 🔴 (+14%)631 🔴 (+9.7%)651 🔴 (+13%)19
WO1020 steps417693 (+8.7%)417693 (+8.7%)417693 (+8.7%)417693 (+8.7%)1
SLstream latency125 (+67%) 🔻160 🔴 (+31%) 🔻178 🔴 (+40%) 🔻182 🔴 (-78%) 💚30
SOstream overhead (text)128 (+39%) 🔻198 (+29%) 🔻218 (+25%) 🔻332 (-7.0%)30
SOstream overhead (structured)129 (+25%) 🔻204 (+30%) 🔻230 (+28%) 🔻295 (+31%) 🔻30
📜 Previous results (3)

034e9a6

Fri, 24 Jul 2026 21:18:29 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1212 (+69%) 🔻1273 🔴 (+21%) 🔻1303 🔴 (+21%) 🔻1635 🔴 (+14%)30
TTFSstream358 (-62%) 💚1296 🔴 (+27%) 🔻1306 🔴 (+26%) 🔻1424 🔴 (+36%) 🔻30
TTFShook + stream464 (-61%) 💚1533 🔴 (+16%) 🔻1553 🔴 (+12%)1657 🔴 (-0.8%)30
STSO1020 steps (1-20)166 (+2.5%)270 🔴 (+8.4%)423 🔴 (+41%) 🔻433 🔴 (+42%) 🔻19
STSO1020 steps (101-120)174 (-5.9%)274 🔴 (-1.1%)356 🔴 (+9.2%)1718 🔴 (+384%) 🔻19
STSO1020 steps (1001-1020)469 (+2.9%)576 🔴 (+4.0%)709 🔴 (+19%) 🔻2977 🔴 (+262%) 🔻19
WO1020 steps375294 (-1.9%)375294 (-1.9%)375294 (-1.9%)375294 (-1.9%)1
SLstream latency86 (+15%)135 🔴 (+12%)150 🔴 (+8.7%)298 🔴 (-65%) 💚30
SOstream overhead (structured)90 (-8.2%)185 (+7.6%)308 (+24%) 🔻581 (+108%) 🔻30

95f6d0f

Fri, 24 Jul 2026 01:01:52 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1216 (+27%) 🔻1333 🔴 (+24%) 🔻1337 🔴 (+18%) 🔻1656 🔴 (+16%) 🔻30
TTFSstream1131 (+17%) 🔻1217 🔴 (+16%) 🔻1234 🔴 (+16%) 🔻1325 🔴 (+17%) 🔻30
TTFShook + stream1339 (+10%)1461 🔴 (+12%)1572 🔴 (+16%) 🔻1658 🔴 (+17%) 🔻30
STSO1020 steps (1-20)163 (-6.3%)277 🔴 (+12%)394 🔴 (+41%) 🔻482 🔴 (+40%) 🔻19
STSO1020 steps (101-120)185 (-2.1%)272 🔴 (-1.4%)385 🔴 (+17%) 🔻431 🔴 (+14%)19
STSO1020 steps (1001-1020)482 (+5.5%)568 🔴 (+10%)696 🔴 (+31%) 🔻698 🔴 (+12%)19
WO1020 steps398314 (+3.1%)398314 (+3.1%)398314 (+3.1%)398314 (+3.1%)1
SLstream latency89 (+11%)126 🔴 (-4.5%)153 🔴 (-15%)342 🔴 (-8.8%)30

a0eed2f

Thu, 23 Jul 2026 20:34:21 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1251 (+23%) 🔻1370 🔴 (+11%)1382 🔴 (+9.3%)1693 🔴 (+26%) 🔻30
TTFSstream1244 (+216%) 🔻1330 🔴 (+17%) 🔻1380 🔴 (+14%)1414 🔴 (-59%) 💚30
TTFShook + stream1208 (+101%) 🔻1646 🔴 (+12%)1676 🔴 (+10%)1830 🔴 (+11%)30
STSO1020 steps (1-20)180 (-12%)279 🔴 (-13%)296 🔴 (-13%)308 🔴 (-18%) 💚19
STSO1020 steps (101-120)194 (-11%)291 🔴 (-1.7%)380 🔴 (-5.0%)381 🔴 (-27%) 💚19
STSO1020 steps (1001-1020)479 (-8.2%)531 🔴 (-14%)592 🔴 (-20%) 💚608 🔴 (-19%) 💚19
WO1020 steps397530 (-15%) 💚397530 (-15%) 💚397530 (-15%) 💚397530 (-15%) 💚1
SLstream latency98 (±0%)148 🔴 (-31%) 💚175 🔴 (-32%) 💚396 🔴 (+33%) 🔻30
ℹ️ Metric definitions & methodology

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

CopilotAI 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

Fixes a false-positive maxRetries exhaustion in @workflow/core caused by counting duplicate step_started events emitted by concurrent/racing invocations as separate attempts, especially in worlds without an atomic start guard (e.g. world-local). The change scopes “authoritative attempt” derivation to the specific class of starts each ceiling is intended to measure.

Changes:

  • Add an ownership-aware filter to the step_started-counting helper so inline owned-recovery counts only starts stamped with the current ownerMessageId, while background-step verification counts only bare/unstamped starts.
  • Update both the background-step retry ceiling verification and the inline owned-recovery ceiling to use the filtered counts.
  • Add focused unit tests covering both filters and the CI-flake-shaped regression case; add a changeset for @workflow/core.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

FileDescription
packages/core/src/runtime.tsAdds owner-filtered step_started counting and applies it to inline/background retry ceiling logic to avoid false maxRetries exhaustion from racing invocations.
packages/core/src/runtime.test.tsAdds unit tests validating the new counting behavior (owned/unowned filters + regression/counter-case).
.changeset/inline-maxretries-race.mdPublishes the fix as a patch changeset for @workflow/core.

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

Comment threadpackages/core/src/runtime.ts Outdated
Comment on lines 277 to 309
export type StepStartOwnerFilter =
| { type: 'ownedBy'; messageId: string }
| { type: 'unowned' };

/**
* Number of `step_started` events already recorded for a step, used as the
* authoritative attempt count for the inline retry ceiling. Each real attempt
* writes exactly one `step_started` (the atomic create-claim / single-flight
* prevents concurrent double-starts from inflating this), so the count equals
* the number of attempts that have begun.
* authoritative attempt count for the retry ceiling.
*
* IMPORTANT: the raw (unfiltered) count is NOT a reliable attempt number.
* Concurrent invocations racing on the same pending batch (stale replays,
* wake replays, a step message dispatched by a replay that lost the
* create-claim race, ...) can each write a `step_started` for the same
* logical attempt — worlds without an atomic start guard (world-local) let
* all of them through. Counting those duplicates as "attempts" made the
* maxRetries ceiling fire on healthy runs and fail them with a false
* "exceeded max retries" (see workflow#3048 CI flake).
*
* Callers must therefore scope the count to the starts their ceiling is
* actually about via `ownerFilter`:
* - the inline owned-recovery ceiling counts only THIS message's starts
* (`ownedBy`) — each real (re)delivery of the owning message stamps its
* `ownerMessageId` on the start it writes, while racing invocations stamp
* their own IDs (or none), so they no longer inflate the count;
* - the background-step ceiling counts only bare starts (`unowned`) — the
* background path never stamps ownership, so inline racers' stamped
* starts are excluded, and throttle/too-early redeliveries (which write
* no start at all) stay excluded as before.
*/
function countStepStartedEvents(
export function countStepStartedEvents(
events: Event[] | null | undefined,
stepId: string
stepId: string,
ownerFilter?: StepStartOwnerFilter
): number {

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Good catch — ./runtime is indeed a public subpath export. Moved the helper (and its filter type) to an internal module, src/runtime/count-step-started-events.ts, imported relatively from runtime.ts; the tests moved to a sibling test file and import it by relative path. No new public API surface. Done in 95f6d0f.

@VaguelySerious

Copy link
Copy Markdown
Member

Huh, I thought this should already be safe under the assumptions that we only create step_started when we're actually about to run the step, so that step_started POST should already be gated by whether we're on the invocation with the right message ID 🤔

@VaguelySerious

Copy link
Copy Markdown
Member

(AI) Dug into the write path — the premise doesn't hold, on two counts:

1. ownerMessageId is a stamp, not a write gate. It's just a field on step_started's eventData (packages/world/src/events.ts:357-369), used by wake replays to reason about the liveness lease. Nothing rejects a write because it carries the "wrong" message ID. That's precisely why this PR has to reconstruct the message-scoping post-hoc at count time — there's no gate at the write layer to lean on.

2. The only write-time exactly-once guard is the create-claim, and it covers step creation, not step starts. In world-local the atomic claim fires only on the lazy-start path (!validatedStep && lazyStepStart, events-storage.ts:1443-1468): the first step_started that creates the step. The winner runs the body; losers get EntityConflictErrorskipped. But once the step exists — eager step_created, or after the create resolved — a subsequent step_started is just a start transition on an existing non-terminal step, and those are explicitly allowed:

Duplicate step_started events for a non-terminal step are still allowed (retries legitimately re-start a step), only writes to an already-terminal step are rejected. (events-storage.ts:110-112)

So in the inline-batches scenario the steps are eagerly created and queued, then several concurrent invocations (stale replay, wake replay, a step message dispatched off a lost create-claim) each pass the not-terminal check and each append a step_started for the same logical attempt. The create-claim never fires (nothing is being created), and there's no message-ID gate — so #3035's raw count reads N starts for 1 attempt and trips the ceiling.

Why not just gate the write by message ID instead? You can't distinguish the two cases at write time: the legitimate retry path is a different message re-starting the same non-terminal step, and a genuine owned-recovery re-run re-stamps the same messageId. "Duplicate racer start for the current attempt" and "legitimate next attempt" look identical to the write layer — both are non-terminal starts. ownerMessageId is the only signal that separates them, which is why the fix applies it at count time rather than at the POST.

(world-vercel's server-side guard makes the duplicate-non-terminal-start window much narrower, which is why the flake surfaced on world-local/world-testing — but the ceiling is computed in core against the event log, so the fix has to be world-agnostic.)

@VaguelySeriousVaguelySerious 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

s.correlationId
s.correlationId,
{
type: 'ownedBy',

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

Splitting the count into two disjoint filters means neither ceiling sees the combined attempt total across a mixed owned→bare sequence — and the authoritativeAttempt guard is the only bound for a step that always times out (writes no step_failed, so the error-gated guard at step-executor.ts:661 never fires).

Concretely, with maxRetries=3 (ceiling = attempt > 4), a step that times out under owned recovery a few times and then transitions to the queued/bare path:

step_started ownerMessageId=msg_OWNER // owned attempt 1
step_started ownerMessageId=msg_OWNER // owned attempt 2 (crash recovery)
step_started ownerMessageId=msg_OWNER // owned attempt 3 (crash recovery)
step_started (bare) // attempt 4 (lease expired → queued)
step_started (bare) // attempt 5 (queued retry)

I ran this through countStepStartedEvents on this branch to confirm (a throwaway probe, not committed). So the PR-description claim "genuine exhaustion still trips the guard at the same point" holds for pure-owned and pure-bare sequences but not strictly for a mixed one. It's still bounded overall (owned side by the queue's max-receive count, bare side by metadata.attempt), so this is not an infinite-loop regression and I'm not blocking on it — but the effective ceiling for an always-timing-out step that changes ownership mode can exceed maxRetries. Worth confirming that's acceptable, and ideally a test for the mixed case since the two current tests only cover the pure sequences.


Nit (folding in a second point to keep this on a diffable line): the comments here and near line 1953 call the owned-recovery start a "bare step_started", but ownerMessageId: metadata.messageId is stamped unconditionally just below — so it is not bare; it carries the owner stamp (which is what makes the ownedBy ceiling count it). Elsewhere "bare" means "no ownerMessageId" (the unowned background start). Reusing the word for both reads as contradictory.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

You're right on both counts — thanks for probing the mixed sequence, and I decided it's worth closing rather than just documenting. Addressed in 95f6d0f:

Mixed owned→bare gap: the background ceiling's scope is now totalAttempts = bare starts + the largest single owner's starts, instead of bare-only. Your exact sequence now computes attempt 3 (owned) + 2 (bare) + 1 = 6 > 4 → trips, restoring the #3035 behavior for genuine mixed lifecycles. The max-over-owners is what keeps the false positive fixed: a genuine lifecycle has at most one inline-ownership phase (ownership is claimed atomically at creation and lapses permanently at step_retrying or on any bare start), so the genuine owner's attempts are counted in full, while racing invocations' one-off stamped duplicates contribute at most their single largest count instead of accumulating. (If a racer's stamped start steals latest-start ownership mid-race, the superseded owner's attempts get shadowed by the max — that residual undercount is in the permissive direction and still bounded by the delivery-count gate.) Your sequence is committed as the mixed owned→bare timeout sequence trips the combined background ceiling test, and the racer-regression test now also asserts totalAttempts stays inside the ceiling.

The inline owned-recovery ceiling intentionally stays ownedBy: a mixed history can't precede owned recovery (a bare start clears ownership, so the owner's redelivery would no longer own the step and it falls through to the queued path, where the combined ceiling applies).

Terminology nit: fixed — the owned-recovery start is now described as a payload-less (but owner-stamped) start at both spots in runtime.ts, plus the two matching comments in step-executor.ts; "bare" is reserved for the background path's unstamped start.

@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for fd05393 (AI decision).

This fixes the retry-ceiling guard from #3035, which is main-only code: packages/core/src/runtime/step-executor.ts does not exist on origin/stable, and origin/stable:packages/core/src/runtime.ts contains no countStepStartedEvents, authoritativeAttempt, ownerMessageId, or exceeded max retries logic at all. With neither the guard nor the inline step-ownership stamping present on stable, the false-failure bug being fixed cannot occur there and the change has nothing to apply to.

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

fd05393d2b47d1a2f347538dd3b6062d808548d2

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.

3 participants

@TooTallNate@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [core] Don't count racing invocations' duplicate step_started events toward the maxRetries ceiling by TooTallNate · Pull Request #3069 · vercel/workflow · GitHub
Skip to content

[core] Don't count racing invocations' duplicate step_started events toward the maxRetries ceiling - #3069

Merged
TooTallNate merged 4 commits into
mainfrom
fix/inline-maxretries-race
Jul 24, 2026
Merged

[core] Don't count racing invocations' duplicate step_started events toward the maxRetries ceiling#3069
TooTallNate merged 4 commits into
mainfrom
fix/inline-maxretries-race

Conversation

@TooTallNate

Copy link
Copy Markdown
Member

Problem

The retry-ceiling guard added in #3035 derives a step's attempt number from the number of step_started events recorded for it. That count is not a reliable attempt number: invocations racing on the same pending batch (stale replays, wake replays, a step message dispatched off a lost create-claim) can each write a step_started for the same logical attempt. Worlds without an atomic start guard (world-local) let all of them through, so a healthy step's count can cross maxRetries + 1 and the guard fails the run with a false exceeded max retries FatalError.

This is the root cause of the world-testing > inline-batches-debug unit-test flake on CI (e.g. this run on main and this one on #3048): the test deliberately provokes concurrent replays racing on parallel step batches; on slow runners enough duplicate starts accumulate that a recovery re-run computes an attempt above the ceiling → run_failed → the test polls for completed until its 60s timeout. (The guard's error log was invisible in the test output because stepLogger uses the workflow:step:* namespace while the test only enables DEBUG=workflow:runtime:*.)

Fix

Scope the step_started count to the starts each ceiling is actually about, using the ownerMessageId stamp that inline starts already carry:

  • Inline owned-recovery ceiling: count only starts stamped with this message'sownerMessageId. Each real (re)delivery of the owning message re-stamps its ID on the start it writes, while racing invocations stamp their own IDs (or none) — so they no longer inflate this owner's attempt number.
  • Background-step ceiling (the delivery-count-gated verification): count only bare/unstamped starts. The background path never stamps ownership, so inline racers' stamped starts are excluded; throttle/too-early redeliveries (which write no start at all) stay excluded as before.

Real timeout retries are still bounded — the #3035 scenario is unchanged: each genuine recovery re-run writes another owner-stamped (or bare) start, so genuine exhaustion still trips the guard at the same point.

Testing

  • New unit tests for countStepStartedEvents covering both filters, including a regression case shaped like the CI flake (owner start + racer starts must not exhaust the ceiling) and a counter-case (4 genuine owner re-runs still exceed it).
  • Full @workflow/core suite: 1516 passed | 3 expected fail.
  • @workflow/world-testing suite (contains the flaky test): 15/15 × 4 consecutive runs. (The flake needs CI-slow timing to reproduce, so local runs can't fully prove it — but the new unit tests encode the failure mechanism directly.)

…toward the maxRetries ceiling
The retry-ceiling guard added in #3035 derives a step's attempt number from
the number of step_started events in the log. But invocations racing on the
same pending batch (stale replays, wake replays, step messages dispatched
off a lost create-claim) can each write a step_started for the same logical
attempt — worlds without an atomic start guard (world-local) let them all
through — so a healthy step's count could cross the ceiling and fail the
run with a false "exceeded max retries" (the world-testing
inline-batches-debug CI flake).
Scope the count to the starts each ceiling is actually about:
- inline owned-recovery: only starts stamped with THIS message's
ownerMessageId (each real (re)delivery of the owner re-stamps it;
racers stamp their own IDs or none)
- background steps: only bare/unstamped starts (that path never stamps
ownership, and throttle/too-early redeliveries still write no start)
Real timeout retries still bound: each recovery re-run writes another
owner-stamped (or bare) start, so genuine exhaustion still trips the guard.
@TooTallNate
TooTallNate requested review from a team and ijjk as code ownersJuly 23, 2026 20:17
CopilotAI review requested due to automatic review settings July 23, 2026 20:17
@changeset-bot

changeset-botBot commented Jul 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3a31e52

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

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

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

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
✅ 📦 Local Production162102271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7519011328651
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro126028
✅ example126028
✅ express126028
✅ fastify126028
✅ hono126028
✅ nextjs-turbopack15103
✅ nextjs-webpack15103
✅ nitro126028
✅ nuxt126028
✅ sveltekit14509
✅ vite126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128026
✅ e2e-local-dev-tanstack-start-128026
✅ e2e-local-postgres-nest-stable128026
✅ e2e-local-postgres-tanstack-start-128026
✅ e2e-local-prod-nest-stable128026
✅ e2e-local-prod-tanstack-start-128026
✅ e2e-vercel-prod-nest126028
✅ e2e-vercel-prod-tanstack-start126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@github-actions

github-actionsBot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 3a31e52 · Fri, 24 Jul 2026 22:10:47 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1229 (+58%) 🔻1365 🔴 (+25%) 🔻1406 🔴 (+26%) 🔻1548 🔴 (-1.0%)30
TTFSstream217 (+7.4%)1391 🔴 (+33%) 🔻1439 🔴 (+34%) 🔻1503 🔴 (+34%) 🔻30
TTFShook + stream1561 (+28%) 🔻1715 🔴 (+29%) 🔻1786 🔴 (+20%) 🔻1813 🔴 (-7.2%)30
STSO1020 steps (1-20)207 (+25%) 🔻285 🔴 (+8.0%)325 🔴 (-18%) 💚390 🔴 (-22%) 💚19
STSO1020 steps (101-120)216 (+32%) 🔻279 🔴 (+12%)321 🔴 (-8.8%)468 🔴 (+33%) 🔻19
STSO1020 steps (1001-1020)499 (+4.4%)616 🔴 (+14%)631 🔴 (+9.7%)651 🔴 (+13%)19
WO1020 steps417693 (+8.7%)417693 (+8.7%)417693 (+8.7%)417693 (+8.7%)1
SLstream latency125 (+67%) 🔻160 🔴 (+31%) 🔻178 🔴 (+40%) 🔻182 🔴 (-78%) 💚30
SOstream overhead (text)128 (+39%) 🔻198 (+29%) 🔻218 (+25%) 🔻332 (-7.0%)30
SOstream overhead (structured)129 (+25%) 🔻204 (+30%) 🔻230 (+28%) 🔻295 (+31%) 🔻30
📜 Previous results (3)

034e9a6

Fri, 24 Jul 2026 21:18:29 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1212 (+69%) 🔻1273 🔴 (+21%) 🔻1303 🔴 (+21%) 🔻1635 🔴 (+14%)30
TTFSstream358 (-62%) 💚1296 🔴 (+27%) 🔻1306 🔴 (+26%) 🔻1424 🔴 (+36%) 🔻30
TTFShook + stream464 (-61%) 💚1533 🔴 (+16%) 🔻1553 🔴 (+12%)1657 🔴 (-0.8%)30
STSO1020 steps (1-20)166 (+2.5%)270 🔴 (+8.4%)423 🔴 (+41%) 🔻433 🔴 (+42%) 🔻19
STSO1020 steps (101-120)174 (-5.9%)274 🔴 (-1.1%)356 🔴 (+9.2%)1718 🔴 (+384%) 🔻19
STSO1020 steps (1001-1020)469 (+2.9%)576 🔴 (+4.0%)709 🔴 (+19%) 🔻2977 🔴 (+262%) 🔻19
WO1020 steps375294 (-1.9%)375294 (-1.9%)375294 (-1.9%)375294 (-1.9%)1
SLstream latency86 (+15%)135 🔴 (+12%)150 🔴 (+8.7%)298 🔴 (-65%) 💚30
SOstream overhead (structured)90 (-8.2%)185 (+7.6%)308 (+24%) 🔻581 (+108%) 🔻30

95f6d0f

Fri, 24 Jul 2026 01:01:52 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1216 (+27%) 🔻1333 🔴 (+24%) 🔻1337 🔴 (+18%) 🔻1656 🔴 (+16%) 🔻30
TTFSstream1131 (+17%) 🔻1217 🔴 (+16%) 🔻1234 🔴 (+16%) 🔻1325 🔴 (+17%) 🔻30
TTFShook + stream1339 (+10%)1461 🔴 (+12%)1572 🔴 (+16%) 🔻1658 🔴 (+17%) 🔻30
STSO1020 steps (1-20)163 (-6.3%)277 🔴 (+12%)394 🔴 (+41%) 🔻482 🔴 (+40%) 🔻19
STSO1020 steps (101-120)185 (-2.1%)272 🔴 (-1.4%)385 🔴 (+17%) 🔻431 🔴 (+14%)19
STSO1020 steps (1001-1020)482 (+5.5%)568 🔴 (+10%)696 🔴 (+31%) 🔻698 🔴 (+12%)19
WO1020 steps398314 (+3.1%)398314 (+3.1%)398314 (+3.1%)398314 (+3.1%)1
SLstream latency89 (+11%)126 🔴 (-4.5%)153 🔴 (-15%)342 🔴 (-8.8%)30

a0eed2f

Thu, 23 Jul 2026 20:34:21 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1251 (+23%) 🔻1370 🔴 (+11%)1382 🔴 (+9.3%)1693 🔴 (+26%) 🔻30
TTFSstream1244 (+216%) 🔻1330 🔴 (+17%) 🔻1380 🔴 (+14%)1414 🔴 (-59%) 💚30
TTFShook + stream1208 (+101%) 🔻1646 🔴 (+12%)1676 🔴 (+10%)1830 🔴 (+11%)30
STSO1020 steps (1-20)180 (-12%)279 🔴 (-13%)296 🔴 (-13%)308 🔴 (-18%) 💚19
STSO1020 steps (101-120)194 (-11%)291 🔴 (-1.7%)380 🔴 (-5.0%)381 🔴 (-27%) 💚19
STSO1020 steps (1001-1020)479 (-8.2%)531 🔴 (-14%)592 🔴 (-20%) 💚608 🔴 (-19%) 💚19
WO1020 steps397530 (-15%) 💚397530 (-15%) 💚397530 (-15%) 💚397530 (-15%) 💚1
SLstream latency98 (±0%)148 🔴 (-31%) 💚175 🔴 (-32%) 💚396 🔴 (+33%) 🔻30
ℹ️ Metric definitions & methodology

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

CopilotAI 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

Fixes a false-positive maxRetries exhaustion in @workflow/core caused by counting duplicate step_started events emitted by concurrent/racing invocations as separate attempts, especially in worlds without an atomic start guard (e.g. world-local). The change scopes “authoritative attempt” derivation to the specific class of starts each ceiling is intended to measure.

Changes:

  • Add an ownership-aware filter to the step_started-counting helper so inline owned-recovery counts only starts stamped with the current ownerMessageId, while background-step verification counts only bare/unstamped starts.
  • Update both the background-step retry ceiling verification and the inline owned-recovery ceiling to use the filtered counts.
  • Add focused unit tests covering both filters and the CI-flake-shaped regression case; add a changeset for @workflow/core.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

FileDescription
packages/core/src/runtime.tsAdds owner-filtered step_started counting and applies it to inline/background retry ceiling logic to avoid false maxRetries exhaustion from racing invocations.
packages/core/src/runtime.test.tsAdds unit tests validating the new counting behavior (owned/unowned filters + regression/counter-case).
.changeset/inline-maxretries-race.mdPublishes the fix as a patch changeset for @workflow/core.

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

Comment threadpackages/core/src/runtime.ts Outdated
Comment on lines 277 to 309
export type StepStartOwnerFilter =
| { type: 'ownedBy'; messageId: string }
| { type: 'unowned' };

/**
* Number of `step_started` events already recorded for a step, used as the
* authoritative attempt count for the inline retry ceiling. Each real attempt
* writes exactly one `step_started` (the atomic create-claim / single-flight
* prevents concurrent double-starts from inflating this), so the count equals
* the number of attempts that have begun.
* authoritative attempt count for the retry ceiling.
*
* IMPORTANT: the raw (unfiltered) count is NOT a reliable attempt number.
* Concurrent invocations racing on the same pending batch (stale replays,
* wake replays, a step message dispatched by a replay that lost the
* create-claim race, ...) can each write a `step_started` for the same
* logical attempt — worlds without an atomic start guard (world-local) let
* all of them through. Counting those duplicates as "attempts" made the
* maxRetries ceiling fire on healthy runs and fail them with a false
* "exceeded max retries" (see workflow#3048 CI flake).
*
* Callers must therefore scope the count to the starts their ceiling is
* actually about via `ownerFilter`:
* - the inline owned-recovery ceiling counts only THIS message's starts
* (`ownedBy`) — each real (re)delivery of the owning message stamps its
* `ownerMessageId` on the start it writes, while racing invocations stamp
* their own IDs (or none), so they no longer inflate the count;
* - the background-step ceiling counts only bare starts (`unowned`) — the
* background path never stamps ownership, so inline racers' stamped
* starts are excluded, and throttle/too-early redeliveries (which write
* no start at all) stay excluded as before.
*/
function countStepStartedEvents(
export function countStepStartedEvents(
events: Event[] | null | undefined,
stepId: string
stepId: string,
ownerFilter?: StepStartOwnerFilter
): number {

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Good catch — ./runtime is indeed a public subpath export. Moved the helper (and its filter type) to an internal module, src/runtime/count-step-started-events.ts, imported relatively from runtime.ts; the tests moved to a sibling test file and import it by relative path. No new public API surface. Done in 95f6d0f.

@VaguelySerious

Copy link
Copy Markdown
Member

Huh, I thought this should already be safe under the assumptions that we only create step_started when we're actually about to run the step, so that step_started POST should already be gated by whether we're on the invocation with the right message ID 🤔

@VaguelySerious

Copy link
Copy Markdown
Member

(AI) Dug into the write path — the premise doesn't hold, on two counts:

1. ownerMessageId is a stamp, not a write gate. It's just a field on step_started's eventData (packages/world/src/events.ts:357-369), used by wake replays to reason about the liveness lease. Nothing rejects a write because it carries the "wrong" message ID. That's precisely why this PR has to reconstruct the message-scoping post-hoc at count time — there's no gate at the write layer to lean on.

2. The only write-time exactly-once guard is the create-claim, and it covers step creation, not step starts. In world-local the atomic claim fires only on the lazy-start path (!validatedStep && lazyStepStart, events-storage.ts:1443-1468): the first step_started that creates the step. The winner runs the body; losers get EntityConflictErrorskipped. But once the step exists — eager step_created, or after the create resolved — a subsequent step_started is just a start transition on an existing non-terminal step, and those are explicitly allowed:

Duplicate step_started events for a non-terminal step are still allowed (retries legitimately re-start a step), only writes to an already-terminal step are rejected. (events-storage.ts:110-112)

So in the inline-batches scenario the steps are eagerly created and queued, then several concurrent invocations (stale replay, wake replay, a step message dispatched off a lost create-claim) each pass the not-terminal check and each append a step_started for the same logical attempt. The create-claim never fires (nothing is being created), and there's no message-ID gate — so #3035's raw count reads N starts for 1 attempt and trips the ceiling.

Why not just gate the write by message ID instead? You can't distinguish the two cases at write time: the legitimate retry path is a different message re-starting the same non-terminal step, and a genuine owned-recovery re-run re-stamps the same messageId. "Duplicate racer start for the current attempt" and "legitimate next attempt" look identical to the write layer — both are non-terminal starts. ownerMessageId is the only signal that separates them, which is why the fix applies it at count time rather than at the POST.

(world-vercel's server-side guard makes the duplicate-non-terminal-start window much narrower, which is why the flake surfaced on world-local/world-testing — but the ceiling is computed in core against the event log, so the fix has to be world-agnostic.)

@VaguelySeriousVaguelySerious 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

s.correlationId
s.correlationId,
{
type: 'ownedBy',

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

Splitting the count into two disjoint filters means neither ceiling sees the combined attempt total across a mixed owned→bare sequence — and the authoritativeAttempt guard is the only bound for a step that always times out (writes no step_failed, so the error-gated guard at step-executor.ts:661 never fires).

Concretely, with maxRetries=3 (ceiling = attempt > 4), a step that times out under owned recovery a few times and then transitions to the queued/bare path:

step_started ownerMessageId=msg_OWNER // owned attempt 1
step_started ownerMessageId=msg_OWNER // owned attempt 2 (crash recovery)
step_started ownerMessageId=msg_OWNER // owned attempt 3 (crash recovery)
step_started (bare) // attempt 4 (lease expired → queued)
step_started (bare) // attempt 5 (queued retry)

I ran this through countStepStartedEvents on this branch to confirm (a throwaway probe, not committed). So the PR-description claim "genuine exhaustion still trips the guard at the same point" holds for pure-owned and pure-bare sequences but not strictly for a mixed one. It's still bounded overall (owned side by the queue's max-receive count, bare side by metadata.attempt), so this is not an infinite-loop regression and I'm not blocking on it — but the effective ceiling for an always-timing-out step that changes ownership mode can exceed maxRetries. Worth confirming that's acceptable, and ideally a test for the mixed case since the two current tests only cover the pure sequences.


Nit (folding in a second point to keep this on a diffable line): the comments here and near line 1953 call the owned-recovery start a "bare step_started", but ownerMessageId: metadata.messageId is stamped unconditionally just below — so it is not bare; it carries the owner stamp (which is what makes the ownedBy ceiling count it). Elsewhere "bare" means "no ownerMessageId" (the unowned background start). Reusing the word for both reads as contradictory.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

You're right on both counts — thanks for probing the mixed sequence, and I decided it's worth closing rather than just documenting. Addressed in 95f6d0f:

Mixed owned→bare gap: the background ceiling's scope is now totalAttempts = bare starts + the largest single owner's starts, instead of bare-only. Your exact sequence now computes attempt 3 (owned) + 2 (bare) + 1 = 6 > 4 → trips, restoring the #3035 behavior for genuine mixed lifecycles. The max-over-owners is what keeps the false positive fixed: a genuine lifecycle has at most one inline-ownership phase (ownership is claimed atomically at creation and lapses permanently at step_retrying or on any bare start), so the genuine owner's attempts are counted in full, while racing invocations' one-off stamped duplicates contribute at most their single largest count instead of accumulating. (If a racer's stamped start steals latest-start ownership mid-race, the superseded owner's attempts get shadowed by the max — that residual undercount is in the permissive direction and still bounded by the delivery-count gate.) Your sequence is committed as the mixed owned→bare timeout sequence trips the combined background ceiling test, and the racer-regression test now also asserts totalAttempts stays inside the ceiling.

The inline owned-recovery ceiling intentionally stays ownedBy: a mixed history can't precede owned recovery (a bare start clears ownership, so the owner's redelivery would no longer own the step and it falls through to the queued path, where the combined ceiling applies).

Terminology nit: fixed — the owned-recovery start is now described as a payload-less (but owner-stamped) start at both spots in runtime.ts, plus the two matching comments in step-executor.ts; "bare" is reserved for the background path's unstamped start.

@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for fd05393 (AI decision).

This fixes the retry-ceiling guard from #3035, which is main-only code: packages/core/src/runtime/step-executor.ts does not exist on origin/stable, and origin/stable:packages/core/src/runtime.ts contains no countStepStartedEvents, authoritativeAttempt, ownerMessageId, or exceeded max retries logic at all. With neither the guard nor the inline step-ownership stamping present on stable, the false-failure bug being fixed cannot occur there and the change has nothing to apply to.

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

fd05393d2b47d1a2f347538dd3b6062d808548d2

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.

3 participants

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

[core] Don't count racing invocations' duplicate step_started events toward the maxRetries ceiling - #3069

Merged
TooTallNate merged 4 commits into
mainfrom
fix/inline-maxretries-race
Jul 24, 2026
Merged

[core] Don't count racing invocations' duplicate step_started events toward the maxRetries ceiling#3069
TooTallNate merged 4 commits into
mainfrom
fix/inline-maxretries-race

Conversation

@TooTallNate

Copy link
Copy Markdown
Member

Problem

The retry-ceiling guard added in #3035 derives a step's attempt number from the number of step_started events recorded for it. That count is not a reliable attempt number: invocations racing on the same pending batch (stale replays, wake replays, a step message dispatched off a lost create-claim) can each write a step_started for the same logical attempt. Worlds without an atomic start guard (world-local) let all of them through, so a healthy step's count can cross maxRetries + 1 and the guard fails the run with a false exceeded max retries FatalError.

This is the root cause of the world-testing > inline-batches-debug unit-test flake on CI (e.g. this run on main and this one on #3048): the test deliberately provokes concurrent replays racing on parallel step batches; on slow runners enough duplicate starts accumulate that a recovery re-run computes an attempt above the ceiling → run_failed → the test polls for completed until its 60s timeout. (The guard's error log was invisible in the test output because stepLogger uses the workflow:step:* namespace while the test only enables DEBUG=workflow:runtime:*.)

Fix

Scope the step_started count to the starts each ceiling is actually about, using the ownerMessageId stamp that inline starts already carry:

  • Inline owned-recovery ceiling: count only starts stamped with this message'sownerMessageId. Each real (re)delivery of the owning message re-stamps its ID on the start it writes, while racing invocations stamp their own IDs (or none) — so they no longer inflate this owner's attempt number.
  • Background-step ceiling (the delivery-count-gated verification): count only bare/unstamped starts. The background path never stamps ownership, so inline racers' stamped starts are excluded; throttle/too-early redeliveries (which write no start at all) stay excluded as before.

Real timeout retries are still bounded — the #3035 scenario is unchanged: each genuine recovery re-run writes another owner-stamped (or bare) start, so genuine exhaustion still trips the guard at the same point.

Testing

  • New unit tests for countStepStartedEvents covering both filters, including a regression case shaped like the CI flake (owner start + racer starts must not exhaust the ceiling) and a counter-case (4 genuine owner re-runs still exceed it).
  • Full @workflow/core suite: 1516 passed | 3 expected fail.
  • @workflow/world-testing suite (contains the flaky test): 15/15 × 4 consecutive runs. (The flake needs CI-slow timing to reproduce, so local runs can't fully prove it — but the new unit tests encode the failure mechanism directly.)

…toward the maxRetries ceiling
The retry-ceiling guard added in #3035 derives a step's attempt number from
the number of step_started events in the log. But invocations racing on the
same pending batch (stale replays, wake replays, step messages dispatched
off a lost create-claim) can each write a step_started for the same logical
attempt — worlds without an atomic start guard (world-local) let them all
through — so a healthy step's count could cross the ceiling and fail the
run with a false "exceeded max retries" (the world-testing
inline-batches-debug CI flake).
Scope the count to the starts each ceiling is actually about:
- inline owned-recovery: only starts stamped with THIS message's
ownerMessageId (each real (re)delivery of the owner re-stamps it;
racers stamp their own IDs or none)
- background steps: only bare/unstamped starts (that path never stamps
ownership, and throttle/too-early redeliveries still write no start)
Real timeout retries still bound: each recovery re-run writes another
owner-stamped (or bare) start, so genuine exhaustion still trips the guard.
@TooTallNate
TooTallNate requested review from a team and ijjk as code ownersJuly 23, 2026 20:17
CopilotAI review requested due to automatic review settings July 23, 2026 20:17
@changeset-bot

changeset-botBot commented Jul 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3a31e52

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

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

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

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
✅ 📦 Local Production162102271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7519011328651
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro126028
✅ example126028
✅ express126028
✅ fastify126028
✅ hono126028
✅ nextjs-turbopack15103
✅ nextjs-webpack15103
✅ nitro126028
✅ nuxt126028
✅ sveltekit14509
✅ vite126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128026
✅ e2e-local-dev-tanstack-start-128026
✅ e2e-local-postgres-nest-stable128026
✅ e2e-local-postgres-tanstack-start-128026
✅ e2e-local-prod-nest-stable128026
✅ e2e-local-prod-tanstack-start-128026
✅ e2e-vercel-prod-nest126028
✅ e2e-vercel-prod-tanstack-start126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@github-actions

github-actionsBot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 3a31e52 · Fri, 24 Jul 2026 22:10:47 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1229 (+58%) 🔻1365 🔴 (+25%) 🔻1406 🔴 (+26%) 🔻1548 🔴 (-1.0%)30
TTFSstream217 (+7.4%)1391 🔴 (+33%) 🔻1439 🔴 (+34%) 🔻1503 🔴 (+34%) 🔻30
TTFShook + stream1561 (+28%) 🔻1715 🔴 (+29%) 🔻1786 🔴 (+20%) 🔻1813 🔴 (-7.2%)30
STSO1020 steps (1-20)207 (+25%) 🔻285 🔴 (+8.0%)325 🔴 (-18%) 💚390 🔴 (-22%) 💚19
STSO1020 steps (101-120)216 (+32%) 🔻279 🔴 (+12%)321 🔴 (-8.8%)468 🔴 (+33%) 🔻19
STSO1020 steps (1001-1020)499 (+4.4%)616 🔴 (+14%)631 🔴 (+9.7%)651 🔴 (+13%)19
WO1020 steps417693 (+8.7%)417693 (+8.7%)417693 (+8.7%)417693 (+8.7%)1
SLstream latency125 (+67%) 🔻160 🔴 (+31%) 🔻178 🔴 (+40%) 🔻182 🔴 (-78%) 💚30
SOstream overhead (text)128 (+39%) 🔻198 (+29%) 🔻218 (+25%) 🔻332 (-7.0%)30
SOstream overhead (structured)129 (+25%) 🔻204 (+30%) 🔻230 (+28%) 🔻295 (+31%) 🔻30
📜 Previous results (3)

034e9a6

Fri, 24 Jul 2026 21:18:29 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1212 (+69%) 🔻1273 🔴 (+21%) 🔻1303 🔴 (+21%) 🔻1635 🔴 (+14%)30
TTFSstream358 (-62%) 💚1296 🔴 (+27%) 🔻1306 🔴 (+26%) 🔻1424 🔴 (+36%) 🔻30
TTFShook + stream464 (-61%) 💚1533 🔴 (+16%) 🔻1553 🔴 (+12%)1657 🔴 (-0.8%)30
STSO1020 steps (1-20)166 (+2.5%)270 🔴 (+8.4%)423 🔴 (+41%) 🔻433 🔴 (+42%) 🔻19
STSO1020 steps (101-120)174 (-5.9%)274 🔴 (-1.1%)356 🔴 (+9.2%)1718 🔴 (+384%) 🔻19
STSO1020 steps (1001-1020)469 (+2.9%)576 🔴 (+4.0%)709 🔴 (+19%) 🔻2977 🔴 (+262%) 🔻19
WO1020 steps375294 (-1.9%)375294 (-1.9%)375294 (-1.9%)375294 (-1.9%)1
SLstream latency86 (+15%)135 🔴 (+12%)150 🔴 (+8.7%)298 🔴 (-65%) 💚30
SOstream overhead (structured)90 (-8.2%)185 (+7.6%)308 (+24%) 🔻581 (+108%) 🔻30

95f6d0f

Fri, 24 Jul 2026 01:01:52 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1216 (+27%) 🔻1333 🔴 (+24%) 🔻1337 🔴 (+18%) 🔻1656 🔴 (+16%) 🔻30
TTFSstream1131 (+17%) 🔻1217 🔴 (+16%) 🔻1234 🔴 (+16%) 🔻1325 🔴 (+17%) 🔻30
TTFShook + stream1339 (+10%)1461 🔴 (+12%)1572 🔴 (+16%) 🔻1658 🔴 (+17%) 🔻30
STSO1020 steps (1-20)163 (-6.3%)277 🔴 (+12%)394 🔴 (+41%) 🔻482 🔴 (+40%) 🔻19
STSO1020 steps (101-120)185 (-2.1%)272 🔴 (-1.4%)385 🔴 (+17%) 🔻431 🔴 (+14%)19
STSO1020 steps (1001-1020)482 (+5.5%)568 🔴 (+10%)696 🔴 (+31%) 🔻698 🔴 (+12%)19
WO1020 steps398314 (+3.1%)398314 (+3.1%)398314 (+3.1%)398314 (+3.1%)1
SLstream latency89 (+11%)126 🔴 (-4.5%)153 🔴 (-15%)342 🔴 (-8.8%)30

a0eed2f

Thu, 23 Jul 2026 20:34:21 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1251 (+23%) 🔻1370 🔴 (+11%)1382 🔴 (+9.3%)1693 🔴 (+26%) 🔻30
TTFSstream1244 (+216%) 🔻1330 🔴 (+17%) 🔻1380 🔴 (+14%)1414 🔴 (-59%) 💚30
TTFShook + stream1208 (+101%) 🔻1646 🔴 (+12%)1676 🔴 (+10%)1830 🔴 (+11%)30
STSO1020 steps (1-20)180 (-12%)279 🔴 (-13%)296 🔴 (-13%)308 🔴 (-18%) 💚19
STSO1020 steps (101-120)194 (-11%)291 🔴 (-1.7%)380 🔴 (-5.0%)381 🔴 (-27%) 💚19
STSO1020 steps (1001-1020)479 (-8.2%)531 🔴 (-14%)592 🔴 (-20%) 💚608 🔴 (-19%) 💚19
WO1020 steps397530 (-15%) 💚397530 (-15%) 💚397530 (-15%) 💚397530 (-15%) 💚1
SLstream latency98 (±0%)148 🔴 (-31%) 💚175 🔴 (-32%) 💚396 🔴 (+33%) 🔻30
ℹ️ Metric definitions & methodology

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

CopilotAI 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

Fixes a false-positive maxRetries exhaustion in @workflow/core caused by counting duplicate step_started events emitted by concurrent/racing invocations as separate attempts, especially in worlds without an atomic start guard (e.g. world-local). The change scopes “authoritative attempt” derivation to the specific class of starts each ceiling is intended to measure.

Changes:

  • Add an ownership-aware filter to the step_started-counting helper so inline owned-recovery counts only starts stamped with the current ownerMessageId, while background-step verification counts only bare/unstamped starts.
  • Update both the background-step retry ceiling verification and the inline owned-recovery ceiling to use the filtered counts.
  • Add focused unit tests covering both filters and the CI-flake-shaped regression case; add a changeset for @workflow/core.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

FileDescription
packages/core/src/runtime.tsAdds owner-filtered step_started counting and applies it to inline/background retry ceiling logic to avoid false maxRetries exhaustion from racing invocations.
packages/core/src/runtime.test.tsAdds unit tests validating the new counting behavior (owned/unowned filters + regression/counter-case).
.changeset/inline-maxretries-race.mdPublishes the fix as a patch changeset for @workflow/core.

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

Comment threadpackages/core/src/runtime.ts Outdated
Comment on lines 277 to 309
export type StepStartOwnerFilter =
| { type: 'ownedBy'; messageId: string }
| { type: 'unowned' };

/**
* Number of `step_started` events already recorded for a step, used as the
* authoritative attempt count for the inline retry ceiling. Each real attempt
* writes exactly one `step_started` (the atomic create-claim / single-flight
* prevents concurrent double-starts from inflating this), so the count equals
* the number of attempts that have begun.
* authoritative attempt count for the retry ceiling.
*
* IMPORTANT: the raw (unfiltered) count is NOT a reliable attempt number.
* Concurrent invocations racing on the same pending batch (stale replays,
* wake replays, a step message dispatched by a replay that lost the
* create-claim race, ...) can each write a `step_started` for the same
* logical attempt — worlds without an atomic start guard (world-local) let
* all of them through. Counting those duplicates as "attempts" made the
* maxRetries ceiling fire on healthy runs and fail them with a false
* "exceeded max retries" (see workflow#3048 CI flake).
*
* Callers must therefore scope the count to the starts their ceiling is
* actually about via `ownerFilter`:
* - the inline owned-recovery ceiling counts only THIS message's starts
* (`ownedBy`) — each real (re)delivery of the owning message stamps its
* `ownerMessageId` on the start it writes, while racing invocations stamp
* their own IDs (or none), so they no longer inflate the count;
* - the background-step ceiling counts only bare starts (`unowned`) — the
* background path never stamps ownership, so inline racers' stamped
* starts are excluded, and throttle/too-early redeliveries (which write
* no start at all) stay excluded as before.
*/
function countStepStartedEvents(
export function countStepStartedEvents(
events: Event[] | null | undefined,
stepId: string
stepId: string,
ownerFilter?: StepStartOwnerFilter
): number {

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Good catch — ./runtime is indeed a public subpath export. Moved the helper (and its filter type) to an internal module, src/runtime/count-step-started-events.ts, imported relatively from runtime.ts; the tests moved to a sibling test file and import it by relative path. No new public API surface. Done in 95f6d0f.

@VaguelySerious

Copy link
Copy Markdown
Member

Huh, I thought this should already be safe under the assumptions that we only create step_started when we're actually about to run the step, so that step_started POST should already be gated by whether we're on the invocation with the right message ID 🤔

@VaguelySerious

Copy link
Copy Markdown
Member

(AI) Dug into the write path — the premise doesn't hold, on two counts:

1. ownerMessageId is a stamp, not a write gate. It's just a field on step_started's eventData (packages/world/src/events.ts:357-369), used by wake replays to reason about the liveness lease. Nothing rejects a write because it carries the "wrong" message ID. That's precisely why this PR has to reconstruct the message-scoping post-hoc at count time — there's no gate at the write layer to lean on.

2. The only write-time exactly-once guard is the create-claim, and it covers step creation, not step starts. In world-local the atomic claim fires only on the lazy-start path (!validatedStep && lazyStepStart, events-storage.ts:1443-1468): the first step_started that creates the step. The winner runs the body; losers get EntityConflictErrorskipped. But once the step exists — eager step_created, or after the create resolved — a subsequent step_started is just a start transition on an existing non-terminal step, and those are explicitly allowed:

Duplicate step_started events for a non-terminal step are still allowed (retries legitimately re-start a step), only writes to an already-terminal step are rejected. (events-storage.ts:110-112)

So in the inline-batches scenario the steps are eagerly created and queued, then several concurrent invocations (stale replay, wake replay, a step message dispatched off a lost create-claim) each pass the not-terminal check and each append a step_started for the same logical attempt. The create-claim never fires (nothing is being created), and there's no message-ID gate — so #3035's raw count reads N starts for 1 attempt and trips the ceiling.

Why not just gate the write by message ID instead? You can't distinguish the two cases at write time: the legitimate retry path is a different message re-starting the same non-terminal step, and a genuine owned-recovery re-run re-stamps the same messageId. "Duplicate racer start for the current attempt" and "legitimate next attempt" look identical to the write layer — both are non-terminal starts. ownerMessageId is the only signal that separates them, which is why the fix applies it at count time rather than at the POST.

(world-vercel's server-side guard makes the duplicate-non-terminal-start window much narrower, which is why the flake surfaced on world-local/world-testing — but the ceiling is computed in core against the event log, so the fix has to be world-agnostic.)

@VaguelySeriousVaguelySerious 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

s.correlationId
s.correlationId,
{
type: 'ownedBy',

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

Splitting the count into two disjoint filters means neither ceiling sees the combined attempt total across a mixed owned→bare sequence — and the authoritativeAttempt guard is the only bound for a step that always times out (writes no step_failed, so the error-gated guard at step-executor.ts:661 never fires).

Concretely, with maxRetries=3 (ceiling = attempt > 4), a step that times out under owned recovery a few times and then transitions to the queued/bare path:

step_started ownerMessageId=msg_OWNER // owned attempt 1
step_started ownerMessageId=msg_OWNER // owned attempt 2 (crash recovery)
step_started ownerMessageId=msg_OWNER // owned attempt 3 (crash recovery)
step_started (bare) // attempt 4 (lease expired → queued)
step_started (bare) // attempt 5 (queued retry)

I ran this through countStepStartedEvents on this branch to confirm (a throwaway probe, not committed). So the PR-description claim "genuine exhaustion still trips the guard at the same point" holds for pure-owned and pure-bare sequences but not strictly for a mixed one. It's still bounded overall (owned side by the queue's max-receive count, bare side by metadata.attempt), so this is not an infinite-loop regression and I'm not blocking on it — but the effective ceiling for an always-timing-out step that changes ownership mode can exceed maxRetries. Worth confirming that's acceptable, and ideally a test for the mixed case since the two current tests only cover the pure sequences.


Nit (folding in a second point to keep this on a diffable line): the comments here and near line 1953 call the owned-recovery start a "bare step_started", but ownerMessageId: metadata.messageId is stamped unconditionally just below — so it is not bare; it carries the owner stamp (which is what makes the ownedBy ceiling count it). Elsewhere "bare" means "no ownerMessageId" (the unowned background start). Reusing the word for both reads as contradictory.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

You're right on both counts — thanks for probing the mixed sequence, and I decided it's worth closing rather than just documenting. Addressed in 95f6d0f:

Mixed owned→bare gap: the background ceiling's scope is now totalAttempts = bare starts + the largest single owner's starts, instead of bare-only. Your exact sequence now computes attempt 3 (owned) + 2 (bare) + 1 = 6 > 4 → trips, restoring the #3035 behavior for genuine mixed lifecycles. The max-over-owners is what keeps the false positive fixed: a genuine lifecycle has at most one inline-ownership phase (ownership is claimed atomically at creation and lapses permanently at step_retrying or on any bare start), so the genuine owner's attempts are counted in full, while racing invocations' one-off stamped duplicates contribute at most their single largest count instead of accumulating. (If a racer's stamped start steals latest-start ownership mid-race, the superseded owner's attempts get shadowed by the max — that residual undercount is in the permissive direction and still bounded by the delivery-count gate.) Your sequence is committed as the mixed owned→bare timeout sequence trips the combined background ceiling test, and the racer-regression test now also asserts totalAttempts stays inside the ceiling.

The inline owned-recovery ceiling intentionally stays ownedBy: a mixed history can't precede owned recovery (a bare start clears ownership, so the owner's redelivery would no longer own the step and it falls through to the queued path, where the combined ceiling applies).

Terminology nit: fixed — the owned-recovery start is now described as a payload-less (but owner-stamped) start at both spots in runtime.ts, plus the two matching comments in step-executor.ts; "bare" is reserved for the background path's unstamped start.

@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for fd05393 (AI decision).

This fixes the retry-ceiling guard from #3035, which is main-only code: packages/core/src/runtime/step-executor.ts does not exist on origin/stable, and origin/stable:packages/core/src/runtime.ts contains no countStepStartedEvents, authoritativeAttempt, ownerMessageId, or exceeded max retries logic at all. With neither the guard nor the inline step-ownership stamping present on stable, the false-failure bug being fixed cannot occur there and the change has nothing to apply to.

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

fd05393d2b47d1a2f347538dd3b6062d808548d2

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.

3 participants

@TooTallNate@VaguelySerious
, '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" + ' [core] Don't count racing invocations' duplicate step_started events toward the maxRetries ceiling by TooTallNate · Pull Request #3069 · vercel/workflow · GitHub
Skip to content

[core] Don't count racing invocations' duplicate step_started events toward the maxRetries ceiling - #3069

Merged
TooTallNate merged 4 commits into
mainfrom
fix/inline-maxretries-race
Jul 24, 2026
Merged

[core] Don't count racing invocations' duplicate step_started events toward the maxRetries ceiling#3069
TooTallNate merged 4 commits into
mainfrom
fix/inline-maxretries-race

Conversation

@TooTallNate

Copy link
Copy Markdown
Member

Problem

The retry-ceiling guard added in #3035 derives a step's attempt number from the number of step_started events recorded for it. That count is not a reliable attempt number: invocations racing on the same pending batch (stale replays, wake replays, a step message dispatched off a lost create-claim) can each write a step_started for the same logical attempt. Worlds without an atomic start guard (world-local) let all of them through, so a healthy step's count can cross maxRetries + 1 and the guard fails the run with a false exceeded max retries FatalError.

This is the root cause of the world-testing > inline-batches-debug unit-test flake on CI (e.g. this run on main and this one on #3048): the test deliberately provokes concurrent replays racing on parallel step batches; on slow runners enough duplicate starts accumulate that a recovery re-run computes an attempt above the ceiling → run_failed → the test polls for completed until its 60s timeout. (The guard's error log was invisible in the test output because stepLogger uses the workflow:step:* namespace while the test only enables DEBUG=workflow:runtime:*.)

Fix

Scope the step_started count to the starts each ceiling is actually about, using the ownerMessageId stamp that inline starts already carry:

  • Inline owned-recovery ceiling: count only starts stamped with this message'sownerMessageId. Each real (re)delivery of the owning message re-stamps its ID on the start it writes, while racing invocations stamp their own IDs (or none) — so they no longer inflate this owner's attempt number.
  • Background-step ceiling (the delivery-count-gated verification): count only bare/unstamped starts. The background path never stamps ownership, so inline racers' stamped starts are excluded; throttle/too-early redeliveries (which write no start at all) stay excluded as before.

Real timeout retries are still bounded — the #3035 scenario is unchanged: each genuine recovery re-run writes another owner-stamped (or bare) start, so genuine exhaustion still trips the guard at the same point.

Testing

  • New unit tests for countStepStartedEvents covering both filters, including a regression case shaped like the CI flake (owner start + racer starts must not exhaust the ceiling) and a counter-case (4 genuine owner re-runs still exceed it).
  • Full @workflow/core suite: 1516 passed | 3 expected fail.
  • @workflow/world-testing suite (contains the flaky test): 15/15 × 4 consecutive runs. (The flake needs CI-slow timing to reproduce, so local runs can't fully prove it — but the new unit tests encode the failure mechanism directly.)

…toward the maxRetries ceiling
The retry-ceiling guard added in #3035 derives a step's attempt number from
the number of step_started events in the log. But invocations racing on the
same pending batch (stale replays, wake replays, step messages dispatched
off a lost create-claim) can each write a step_started for the same logical
attempt — worlds without an atomic start guard (world-local) let them all
through — so a healthy step's count could cross the ceiling and fail the
run with a false "exceeded max retries" (the world-testing
inline-batches-debug CI flake).
Scope the count to the starts each ceiling is actually about:
- inline owned-recovery: only starts stamped with THIS message's
ownerMessageId (each real (re)delivery of the owner re-stamps it;
racers stamp their own IDs or none)
- background steps: only bare/unstamped starts (that path never stamps
ownership, and throttle/too-early redeliveries still write no start)
Real timeout retries still bound: each recovery re-run writes another
owner-stamped (or bare) start, so genuine exhaustion still trips the guard.
@TooTallNate
TooTallNate requested review from a team and ijjk as code ownersJuly 23, 2026 20:17
CopilotAI review requested due to automatic review settings July 23, 2026 20:17
@changeset-bot

changeset-botBot commented Jul 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3a31e52

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

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

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

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
✅ 📦 Local Production162102271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7519011328651
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro126028
✅ example126028
✅ express126028
✅ fastify126028
✅ hono126028
✅ nextjs-turbopack15103
✅ nextjs-webpack15103
✅ nitro126028
✅ nuxt126028
✅ sveltekit14509
✅ vite126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128026
✅ e2e-local-dev-tanstack-start-128026
✅ e2e-local-postgres-nest-stable128026
✅ e2e-local-postgres-tanstack-start-128026
✅ e2e-local-prod-nest-stable128026
✅ e2e-local-prod-tanstack-start-128026
✅ e2e-vercel-prod-nest126028
✅ e2e-vercel-prod-tanstack-start126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@github-actions

github-actionsBot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 3a31e52 · Fri, 24 Jul 2026 22:10:47 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1229 (+58%) 🔻1365 🔴 (+25%) 🔻1406 🔴 (+26%) 🔻1548 🔴 (-1.0%)30
TTFSstream217 (+7.4%)1391 🔴 (+33%) 🔻1439 🔴 (+34%) 🔻1503 🔴 (+34%) 🔻30
TTFShook + stream1561 (+28%) 🔻1715 🔴 (+29%) 🔻1786 🔴 (+20%) 🔻1813 🔴 (-7.2%)30
STSO1020 steps (1-20)207 (+25%) 🔻285 🔴 (+8.0%)325 🔴 (-18%) 💚390 🔴 (-22%) 💚19
STSO1020 steps (101-120)216 (+32%) 🔻279 🔴 (+12%)321 🔴 (-8.8%)468 🔴 (+33%) 🔻19
STSO1020 steps (1001-1020)499 (+4.4%)616 🔴 (+14%)631 🔴 (+9.7%)651 🔴 (+13%)19
WO1020 steps417693 (+8.7%)417693 (+8.7%)417693 (+8.7%)417693 (+8.7%)1
SLstream latency125 (+67%) 🔻160 🔴 (+31%) 🔻178 🔴 (+40%) 🔻182 🔴 (-78%) 💚30
SOstream overhead (text)128 (+39%) 🔻198 (+29%) 🔻218 (+25%) 🔻332 (-7.0%)30
SOstream overhead (structured)129 (+25%) 🔻204 (+30%) 🔻230 (+28%) 🔻295 (+31%) 🔻30
📜 Previous results (3)

034e9a6

Fri, 24 Jul 2026 21:18:29 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1212 (+69%) 🔻1273 🔴 (+21%) 🔻1303 🔴 (+21%) 🔻1635 🔴 (+14%)30
TTFSstream358 (-62%) 💚1296 🔴 (+27%) 🔻1306 🔴 (+26%) 🔻1424 🔴 (+36%) 🔻30
TTFShook + stream464 (-61%) 💚1533 🔴 (+16%) 🔻1553 🔴 (+12%)1657 🔴 (-0.8%)30
STSO1020 steps (1-20)166 (+2.5%)270 🔴 (+8.4%)423 🔴 (+41%) 🔻433 🔴 (+42%) 🔻19
STSO1020 steps (101-120)174 (-5.9%)274 🔴 (-1.1%)356 🔴 (+9.2%)1718 🔴 (+384%) 🔻19
STSO1020 steps (1001-1020)469 (+2.9%)576 🔴 (+4.0%)709 🔴 (+19%) 🔻2977 🔴 (+262%) 🔻19
WO1020 steps375294 (-1.9%)375294 (-1.9%)375294 (-1.9%)375294 (-1.9%)1
SLstream latency86 (+15%)135 🔴 (+12%)150 🔴 (+8.7%)298 🔴 (-65%) 💚30
SOstream overhead (structured)90 (-8.2%)185 (+7.6%)308 (+24%) 🔻581 (+108%) 🔻30

95f6d0f

Fri, 24 Jul 2026 01:01:52 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1216 (+27%) 🔻1333 🔴 (+24%) 🔻1337 🔴 (+18%) 🔻1656 🔴 (+16%) 🔻30
TTFSstream1131 (+17%) 🔻1217 🔴 (+16%) 🔻1234 🔴 (+16%) 🔻1325 🔴 (+17%) 🔻30
TTFShook + stream1339 (+10%)1461 🔴 (+12%)1572 🔴 (+16%) 🔻1658 🔴 (+17%) 🔻30
STSO1020 steps (1-20)163 (-6.3%)277 🔴 (+12%)394 🔴 (+41%) 🔻482 🔴 (+40%) 🔻19
STSO1020 steps (101-120)185 (-2.1%)272 🔴 (-1.4%)385 🔴 (+17%) 🔻431 🔴 (+14%)19
STSO1020 steps (1001-1020)482 (+5.5%)568 🔴 (+10%)696 🔴 (+31%) 🔻698 🔴 (+12%)19
WO1020 steps398314 (+3.1%)398314 (+3.1%)398314 (+3.1%)398314 (+3.1%)1
SLstream latency89 (+11%)126 🔴 (-4.5%)153 🔴 (-15%)342 🔴 (-8.8%)30

a0eed2f

Thu, 23 Jul 2026 20:34:21 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1251 (+23%) 🔻1370 🔴 (+11%)1382 🔴 (+9.3%)1693 🔴 (+26%) 🔻30
TTFSstream1244 (+216%) 🔻1330 🔴 (+17%) 🔻1380 🔴 (+14%)1414 🔴 (-59%) 💚30
TTFShook + stream1208 (+101%) 🔻1646 🔴 (+12%)1676 🔴 (+10%)1830 🔴 (+11%)30
STSO1020 steps (1-20)180 (-12%)279 🔴 (-13%)296 🔴 (-13%)308 🔴 (-18%) 💚19
STSO1020 steps (101-120)194 (-11%)291 🔴 (-1.7%)380 🔴 (-5.0%)381 🔴 (-27%) 💚19
STSO1020 steps (1001-1020)479 (-8.2%)531 🔴 (-14%)592 🔴 (-20%) 💚608 🔴 (-19%) 💚19
WO1020 steps397530 (-15%) 💚397530 (-15%) 💚397530 (-15%) 💚397530 (-15%) 💚1
SLstream latency98 (±0%)148 🔴 (-31%) 💚175 🔴 (-32%) 💚396 🔴 (+33%) 🔻30
ℹ️ Metric definitions & methodology

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

CopilotAI 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

Fixes a false-positive maxRetries exhaustion in @workflow/core caused by counting duplicate step_started events emitted by concurrent/racing invocations as separate attempts, especially in worlds without an atomic start guard (e.g. world-local). The change scopes “authoritative attempt” derivation to the specific class of starts each ceiling is intended to measure.

Changes:

  • Add an ownership-aware filter to the step_started-counting helper so inline owned-recovery counts only starts stamped with the current ownerMessageId, while background-step verification counts only bare/unstamped starts.
  • Update both the background-step retry ceiling verification and the inline owned-recovery ceiling to use the filtered counts.
  • Add focused unit tests covering both filters and the CI-flake-shaped regression case; add a changeset for @workflow/core.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

FileDescription
packages/core/src/runtime.tsAdds owner-filtered step_started counting and applies it to inline/background retry ceiling logic to avoid false maxRetries exhaustion from racing invocations.
packages/core/src/runtime.test.tsAdds unit tests validating the new counting behavior (owned/unowned filters + regression/counter-case).
.changeset/inline-maxretries-race.mdPublishes the fix as a patch changeset for @workflow/core.

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

Comment threadpackages/core/src/runtime.ts Outdated
Comment on lines 277 to 309
export type StepStartOwnerFilter =
| { type: 'ownedBy'; messageId: string }
| { type: 'unowned' };

/**
* Number of `step_started` events already recorded for a step, used as the
* authoritative attempt count for the inline retry ceiling. Each real attempt
* writes exactly one `step_started` (the atomic create-claim / single-flight
* prevents concurrent double-starts from inflating this), so the count equals
* the number of attempts that have begun.
* authoritative attempt count for the retry ceiling.
*
* IMPORTANT: the raw (unfiltered) count is NOT a reliable attempt number.
* Concurrent invocations racing on the same pending batch (stale replays,
* wake replays, a step message dispatched by a replay that lost the
* create-claim race, ...) can each write a `step_started` for the same
* logical attempt — worlds without an atomic start guard (world-local) let
* all of them through. Counting those duplicates as "attempts" made the
* maxRetries ceiling fire on healthy runs and fail them with a false
* "exceeded max retries" (see workflow#3048 CI flake).
*
* Callers must therefore scope the count to the starts their ceiling is
* actually about via `ownerFilter`:
* - the inline owned-recovery ceiling counts only THIS message's starts
* (`ownedBy`) — each real (re)delivery of the owning message stamps its
* `ownerMessageId` on the start it writes, while racing invocations stamp
* their own IDs (or none), so they no longer inflate the count;
* - the background-step ceiling counts only bare starts (`unowned`) — the
* background path never stamps ownership, so inline racers' stamped
* starts are excluded, and throttle/too-early redeliveries (which write
* no start at all) stay excluded as before.
*/
function countStepStartedEvents(
export function countStepStartedEvents(
events: Event[] | null | undefined,
stepId: string
stepId: string,
ownerFilter?: StepStartOwnerFilter
): number {

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Good catch — ./runtime is indeed a public subpath export. Moved the helper (and its filter type) to an internal module, src/runtime/count-step-started-events.ts, imported relatively from runtime.ts; the tests moved to a sibling test file and import it by relative path. No new public API surface. Done in 95f6d0f.

@VaguelySerious

Copy link
Copy Markdown
Member

Huh, I thought this should already be safe under the assumptions that we only create step_started when we're actually about to run the step, so that step_started POST should already be gated by whether we're on the invocation with the right message ID 🤔

@VaguelySerious

Copy link
Copy Markdown
Member

(AI) Dug into the write path — the premise doesn't hold, on two counts:

1. ownerMessageId is a stamp, not a write gate. It's just a field on step_started's eventData (packages/world/src/events.ts:357-369), used by wake replays to reason about the liveness lease. Nothing rejects a write because it carries the "wrong" message ID. That's precisely why this PR has to reconstruct the message-scoping post-hoc at count time — there's no gate at the write layer to lean on.

2. The only write-time exactly-once guard is the create-claim, and it covers step creation, not step starts. In world-local the atomic claim fires only on the lazy-start path (!validatedStep && lazyStepStart, events-storage.ts:1443-1468): the first step_started that creates the step. The winner runs the body; losers get EntityConflictErrorskipped. But once the step exists — eager step_created, or after the create resolved — a subsequent step_started is just a start transition on an existing non-terminal step, and those are explicitly allowed:

Duplicate step_started events for a non-terminal step are still allowed (retries legitimately re-start a step), only writes to an already-terminal step are rejected. (events-storage.ts:110-112)

So in the inline-batches scenario the steps are eagerly created and queued, then several concurrent invocations (stale replay, wake replay, a step message dispatched off a lost create-claim) each pass the not-terminal check and each append a step_started for the same logical attempt. The create-claim never fires (nothing is being created), and there's no message-ID gate — so #3035's raw count reads N starts for 1 attempt and trips the ceiling.

Why not just gate the write by message ID instead? You can't distinguish the two cases at write time: the legitimate retry path is a different message re-starting the same non-terminal step, and a genuine owned-recovery re-run re-stamps the same messageId. "Duplicate racer start for the current attempt" and "legitimate next attempt" look identical to the write layer — both are non-terminal starts. ownerMessageId is the only signal that separates them, which is why the fix applies it at count time rather than at the POST.

(world-vercel's server-side guard makes the duplicate-non-terminal-start window much narrower, which is why the flake surfaced on world-local/world-testing — but the ceiling is computed in core against the event log, so the fix has to be world-agnostic.)

@VaguelySeriousVaguelySerious 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

s.correlationId
s.correlationId,
{
type: 'ownedBy',

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

Splitting the count into two disjoint filters means neither ceiling sees the combined attempt total across a mixed owned→bare sequence — and the authoritativeAttempt guard is the only bound for a step that always times out (writes no step_failed, so the error-gated guard at step-executor.ts:661 never fires).

Concretely, with maxRetries=3 (ceiling = attempt > 4), a step that times out under owned recovery a few times and then transitions to the queued/bare path:

step_started ownerMessageId=msg_OWNER // owned attempt 1
step_started ownerMessageId=msg_OWNER // owned attempt 2 (crash recovery)
step_started ownerMessageId=msg_OWNER // owned attempt 3 (crash recovery)
step_started (bare) // attempt 4 (lease expired → queued)
step_started (bare) // attempt 5 (queued retry)

I ran this through countStepStartedEvents on this branch to confirm (a throwaway probe, not committed). So the PR-description claim "genuine exhaustion still trips the guard at the same point" holds for pure-owned and pure-bare sequences but not strictly for a mixed one. It's still bounded overall (owned side by the queue's max-receive count, bare side by metadata.attempt), so this is not an infinite-loop regression and I'm not blocking on it — but the effective ceiling for an always-timing-out step that changes ownership mode can exceed maxRetries. Worth confirming that's acceptable, and ideally a test for the mixed case since the two current tests only cover the pure sequences.


Nit (folding in a second point to keep this on a diffable line): the comments here and near line 1953 call the owned-recovery start a "bare step_started", but ownerMessageId: metadata.messageId is stamped unconditionally just below — so it is not bare; it carries the owner stamp (which is what makes the ownedBy ceiling count it). Elsewhere "bare" means "no ownerMessageId" (the unowned background start). Reusing the word for both reads as contradictory.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

You're right on both counts — thanks for probing the mixed sequence, and I decided it's worth closing rather than just documenting. Addressed in 95f6d0f:

Mixed owned→bare gap: the background ceiling's scope is now totalAttempts = bare starts + the largest single owner's starts, instead of bare-only. Your exact sequence now computes attempt 3 (owned) + 2 (bare) + 1 = 6 > 4 → trips, restoring the #3035 behavior for genuine mixed lifecycles. The max-over-owners is what keeps the false positive fixed: a genuine lifecycle has at most one inline-ownership phase (ownership is claimed atomically at creation and lapses permanently at step_retrying or on any bare start), so the genuine owner's attempts are counted in full, while racing invocations' one-off stamped duplicates contribute at most their single largest count instead of accumulating. (If a racer's stamped start steals latest-start ownership mid-race, the superseded owner's attempts get shadowed by the max — that residual undercount is in the permissive direction and still bounded by the delivery-count gate.) Your sequence is committed as the mixed owned→bare timeout sequence trips the combined background ceiling test, and the racer-regression test now also asserts totalAttempts stays inside the ceiling.

The inline owned-recovery ceiling intentionally stays ownedBy: a mixed history can't precede owned recovery (a bare start clears ownership, so the owner's redelivery would no longer own the step and it falls through to the queued path, where the combined ceiling applies).

Terminology nit: fixed — the owned-recovery start is now described as a payload-less (but owner-stamped) start at both spots in runtime.ts, plus the two matching comments in step-executor.ts; "bare" is reserved for the background path's unstamped start.

@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for fd05393 (AI decision).

This fixes the retry-ceiling guard from #3035, which is main-only code: packages/core/src/runtime/step-executor.ts does not exist on origin/stable, and origin/stable:packages/core/src/runtime.ts contains no countStepStartedEvents, authoritativeAttempt, ownerMessageId, or exceeded max retries logic at all. With neither the guard nor the inline step-ownership stamping present on stable, the false-failure bug being fixed cannot occur there and the change has nothing to apply to.

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

fd05393d2b47d1a2f347538dd3b6062d808548d2

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.

3 participants

@TooTallNate@VaguelySerious
, '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('^' + ".*" + ' [core] Don't count racing invocations' duplicate step_started events toward the maxRetries ceiling by TooTallNate · Pull Request #3069 · vercel/workflow · GitHub
Skip to content

[core] Don't count racing invocations' duplicate step_started events toward the maxRetries ceiling - #3069

Merged
TooTallNate merged 4 commits into
mainfrom
fix/inline-maxretries-race
Jul 24, 2026
Merged

[core] Don't count racing invocations' duplicate step_started events toward the maxRetries ceiling#3069
TooTallNate merged 4 commits into
mainfrom
fix/inline-maxretries-race

Conversation

@TooTallNate

Copy link
Copy Markdown
Member

Problem

The retry-ceiling guard added in #3035 derives a step's attempt number from the number of step_started events recorded for it. That count is not a reliable attempt number: invocations racing on the same pending batch (stale replays, wake replays, a step message dispatched off a lost create-claim) can each write a step_started for the same logical attempt. Worlds without an atomic start guard (world-local) let all of them through, so a healthy step's count can cross maxRetries + 1 and the guard fails the run with a false exceeded max retries FatalError.

This is the root cause of the world-testing > inline-batches-debug unit-test flake on CI (e.g. this run on main and this one on #3048): the test deliberately provokes concurrent replays racing on parallel step batches; on slow runners enough duplicate starts accumulate that a recovery re-run computes an attempt above the ceiling → run_failed → the test polls for completed until its 60s timeout. (The guard's error log was invisible in the test output because stepLogger uses the workflow:step:* namespace while the test only enables DEBUG=workflow:runtime:*.)

Fix

Scope the step_started count to the starts each ceiling is actually about, using the ownerMessageId stamp that inline starts already carry:

  • Inline owned-recovery ceiling: count only starts stamped with this message'sownerMessageId. Each real (re)delivery of the owning message re-stamps its ID on the start it writes, while racing invocations stamp their own IDs (or none) — so they no longer inflate this owner's attempt number.
  • Background-step ceiling (the delivery-count-gated verification): count only bare/unstamped starts. The background path never stamps ownership, so inline racers' stamped starts are excluded; throttle/too-early redeliveries (which write no start at all) stay excluded as before.

Real timeout retries are still bounded — the #3035 scenario is unchanged: each genuine recovery re-run writes another owner-stamped (or bare) start, so genuine exhaustion still trips the guard at the same point.

Testing

  • New unit tests for countStepStartedEvents covering both filters, including a regression case shaped like the CI flake (owner start + racer starts must not exhaust the ceiling) and a counter-case (4 genuine owner re-runs still exceed it).
  • Full @workflow/core suite: 1516 passed | 3 expected fail.
  • @workflow/world-testing suite (contains the flaky test): 15/15 × 4 consecutive runs. (The flake needs CI-slow timing to reproduce, so local runs can't fully prove it — but the new unit tests encode the failure mechanism directly.)

…toward the maxRetries ceiling
The retry-ceiling guard added in #3035 derives a step's attempt number from
the number of step_started events in the log. But invocations racing on the
same pending batch (stale replays, wake replays, step messages dispatched
off a lost create-claim) can each write a step_started for the same logical
attempt — worlds without an atomic start guard (world-local) let them all
through — so a healthy step's count could cross the ceiling and fail the
run with a false "exceeded max retries" (the world-testing
inline-batches-debug CI flake).
Scope the count to the starts each ceiling is actually about:
- inline owned-recovery: only starts stamped with THIS message's
ownerMessageId (each real (re)delivery of the owner re-stamps it;
racers stamp their own IDs or none)
- background steps: only bare/unstamped starts (that path never stamps
ownership, and throttle/too-early redeliveries still write no start)
Real timeout retries still bound: each recovery re-run writes another
owner-stamped (or bare) start, so genuine exhaustion still trips the guard.
@TooTallNate
TooTallNate requested review from a team and ijjk as code ownersJuly 23, 2026 20:17
CopilotAI review requested due to automatic review settings July 23, 2026 20:17
@changeset-bot

changeset-botBot commented Jul 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3a31e52

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

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

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

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
✅ 📦 Local Production162102271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7519011328651
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro126028
✅ example126028
✅ express126028
✅ fastify126028
✅ hono126028
✅ nextjs-turbopack15103
✅ nextjs-webpack15103
✅ nitro126028
✅ nuxt126028
✅ sveltekit14509
✅ vite126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128026
✅ e2e-local-dev-tanstack-start-128026
✅ e2e-local-postgres-nest-stable128026
✅ e2e-local-postgres-tanstack-start-128026
✅ e2e-local-prod-nest-stable128026
✅ e2e-local-prod-tanstack-start-128026
✅ e2e-vercel-prod-nest126028
✅ e2e-vercel-prod-tanstack-start126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@github-actions

github-actionsBot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 3a31e52 · Fri, 24 Jul 2026 22:10:47 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1229 (+58%) 🔻1365 🔴 (+25%) 🔻1406 🔴 (+26%) 🔻1548 🔴 (-1.0%)30
TTFSstream217 (+7.4%)1391 🔴 (+33%) 🔻1439 🔴 (+34%) 🔻1503 🔴 (+34%) 🔻30
TTFShook + stream1561 (+28%) 🔻1715 🔴 (+29%) 🔻1786 🔴 (+20%) 🔻1813 🔴 (-7.2%)30
STSO1020 steps (1-20)207 (+25%) 🔻285 🔴 (+8.0%)325 🔴 (-18%) 💚390 🔴 (-22%) 💚19
STSO1020 steps (101-120)216 (+32%) 🔻279 🔴 (+12%)321 🔴 (-8.8%)468 🔴 (+33%) 🔻19
STSO1020 steps (1001-1020)499 (+4.4%)616 🔴 (+14%)631 🔴 (+9.7%)651 🔴 (+13%)19
WO1020 steps417693 (+8.7%)417693 (+8.7%)417693 (+8.7%)417693 (+8.7%)1
SLstream latency125 (+67%) 🔻160 🔴 (+31%) 🔻178 🔴 (+40%) 🔻182 🔴 (-78%) 💚30
SOstream overhead (text)128 (+39%) 🔻198 (+29%) 🔻218 (+25%) 🔻332 (-7.0%)30
SOstream overhead (structured)129 (+25%) 🔻204 (+30%) 🔻230 (+28%) 🔻295 (+31%) 🔻30
📜 Previous results (3)

034e9a6

Fri, 24 Jul 2026 21:18:29 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1212 (+69%) 🔻1273 🔴 (+21%) 🔻1303 🔴 (+21%) 🔻1635 🔴 (+14%)30
TTFSstream358 (-62%) 💚1296 🔴 (+27%) 🔻1306 🔴 (+26%) 🔻1424 🔴 (+36%) 🔻30
TTFShook + stream464 (-61%) 💚1533 🔴 (+16%) 🔻1553 🔴 (+12%)1657 🔴 (-0.8%)30
STSO1020 steps (1-20)166 (+2.5%)270 🔴 (+8.4%)423 🔴 (+41%) 🔻433 🔴 (+42%) 🔻19
STSO1020 steps (101-120)174 (-5.9%)274 🔴 (-1.1%)356 🔴 (+9.2%)1718 🔴 (+384%) 🔻19
STSO1020 steps (1001-1020)469 (+2.9%)576 🔴 (+4.0%)709 🔴 (+19%) 🔻2977 🔴 (+262%) 🔻19
WO1020 steps375294 (-1.9%)375294 (-1.9%)375294 (-1.9%)375294 (-1.9%)1
SLstream latency86 (+15%)135 🔴 (+12%)150 🔴 (+8.7%)298 🔴 (-65%) 💚30
SOstream overhead (structured)90 (-8.2%)185 (+7.6%)308 (+24%) 🔻581 (+108%) 🔻30

95f6d0f

Fri, 24 Jul 2026 01:01:52 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1216 (+27%) 🔻1333 🔴 (+24%) 🔻1337 🔴 (+18%) 🔻1656 🔴 (+16%) 🔻30
TTFSstream1131 (+17%) 🔻1217 🔴 (+16%) 🔻1234 🔴 (+16%) 🔻1325 🔴 (+17%) 🔻30
TTFShook + stream1339 (+10%)1461 🔴 (+12%)1572 🔴 (+16%) 🔻1658 🔴 (+17%) 🔻30
STSO1020 steps (1-20)163 (-6.3%)277 🔴 (+12%)394 🔴 (+41%) 🔻482 🔴 (+40%) 🔻19
STSO1020 steps (101-120)185 (-2.1%)272 🔴 (-1.4%)385 🔴 (+17%) 🔻431 🔴 (+14%)19
STSO1020 steps (1001-1020)482 (+5.5%)568 🔴 (+10%)696 🔴 (+31%) 🔻698 🔴 (+12%)19
WO1020 steps398314 (+3.1%)398314 (+3.1%)398314 (+3.1%)398314 (+3.1%)1
SLstream latency89 (+11%)126 🔴 (-4.5%)153 🔴 (-15%)342 🔴 (-8.8%)30

a0eed2f

Thu, 23 Jul 2026 20:34:21 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1251 (+23%) 🔻1370 🔴 (+11%)1382 🔴 (+9.3%)1693 🔴 (+26%) 🔻30
TTFSstream1244 (+216%) 🔻1330 🔴 (+17%) 🔻1380 🔴 (+14%)1414 🔴 (-59%) 💚30
TTFShook + stream1208 (+101%) 🔻1646 🔴 (+12%)1676 🔴 (+10%)1830 🔴 (+11%)30
STSO1020 steps (1-20)180 (-12%)279 🔴 (-13%)296 🔴 (-13%)308 🔴 (-18%) 💚19
STSO1020 steps (101-120)194 (-11%)291 🔴 (-1.7%)380 🔴 (-5.0%)381 🔴 (-27%) 💚19
STSO1020 steps (1001-1020)479 (-8.2%)531 🔴 (-14%)592 🔴 (-20%) 💚608 🔴 (-19%) 💚19
WO1020 steps397530 (-15%) 💚397530 (-15%) 💚397530 (-15%) 💚397530 (-15%) 💚1
SLstream latency98 (±0%)148 🔴 (-31%) 💚175 🔴 (-32%) 💚396 🔴 (+33%) 🔻30
ℹ️ Metric definitions & methodology

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

CopilotAI 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

Fixes a false-positive maxRetries exhaustion in @workflow/core caused by counting duplicate step_started events emitted by concurrent/racing invocations as separate attempts, especially in worlds without an atomic start guard (e.g. world-local). The change scopes “authoritative attempt” derivation to the specific class of starts each ceiling is intended to measure.

Changes:

  • Add an ownership-aware filter to the step_started-counting helper so inline owned-recovery counts only starts stamped with the current ownerMessageId, while background-step verification counts only bare/unstamped starts.
  • Update both the background-step retry ceiling verification and the inline owned-recovery ceiling to use the filtered counts.
  • Add focused unit tests covering both filters and the CI-flake-shaped regression case; add a changeset for @workflow/core.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

FileDescription
packages/core/src/runtime.tsAdds owner-filtered step_started counting and applies it to inline/background retry ceiling logic to avoid false maxRetries exhaustion from racing invocations.
packages/core/src/runtime.test.tsAdds unit tests validating the new counting behavior (owned/unowned filters + regression/counter-case).
.changeset/inline-maxretries-race.mdPublishes the fix as a patch changeset for @workflow/core.

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

Comment threadpackages/core/src/runtime.ts Outdated
Comment on lines 277 to 309
export type StepStartOwnerFilter =
| { type: 'ownedBy'; messageId: string }
| { type: 'unowned' };

/**
* Number of `step_started` events already recorded for a step, used as the
* authoritative attempt count for the inline retry ceiling. Each real attempt
* writes exactly one `step_started` (the atomic create-claim / single-flight
* prevents concurrent double-starts from inflating this), so the count equals
* the number of attempts that have begun.
* authoritative attempt count for the retry ceiling.
*
* IMPORTANT: the raw (unfiltered) count is NOT a reliable attempt number.
* Concurrent invocations racing on the same pending batch (stale replays,
* wake replays, a step message dispatched by a replay that lost the
* create-claim race, ...) can each write a `step_started` for the same
* logical attempt — worlds without an atomic start guard (world-local) let
* all of them through. Counting those duplicates as "attempts" made the
* maxRetries ceiling fire on healthy runs and fail them with a false
* "exceeded max retries" (see workflow#3048 CI flake).
*
* Callers must therefore scope the count to the starts their ceiling is
* actually about via `ownerFilter`:
* - the inline owned-recovery ceiling counts only THIS message's starts
* (`ownedBy`) — each real (re)delivery of the owning message stamps its
* `ownerMessageId` on the start it writes, while racing invocations stamp
* their own IDs (or none), so they no longer inflate the count;
* - the background-step ceiling counts only bare starts (`unowned`) — the
* background path never stamps ownership, so inline racers' stamped
* starts are excluded, and throttle/too-early redeliveries (which write
* no start at all) stay excluded as before.
*/
function countStepStartedEvents(
export function countStepStartedEvents(
events: Event[] | null | undefined,
stepId: string
stepId: string,
ownerFilter?: StepStartOwnerFilter
): number {

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Good catch — ./runtime is indeed a public subpath export. Moved the helper (and its filter type) to an internal module, src/runtime/count-step-started-events.ts, imported relatively from runtime.ts; the tests moved to a sibling test file and import it by relative path. No new public API surface. Done in 95f6d0f.

@VaguelySerious

Copy link
Copy Markdown
Member

Huh, I thought this should already be safe under the assumptions that we only create step_started when we're actually about to run the step, so that step_started POST should already be gated by whether we're on the invocation with the right message ID 🤔

@VaguelySerious

Copy link
Copy Markdown
Member

(AI) Dug into the write path — the premise doesn't hold, on two counts:

1. ownerMessageId is a stamp, not a write gate. It's just a field on step_started's eventData (packages/world/src/events.ts:357-369), used by wake replays to reason about the liveness lease. Nothing rejects a write because it carries the "wrong" message ID. That's precisely why this PR has to reconstruct the message-scoping post-hoc at count time — there's no gate at the write layer to lean on.

2. The only write-time exactly-once guard is the create-claim, and it covers step creation, not step starts. In world-local the atomic claim fires only on the lazy-start path (!validatedStep && lazyStepStart, events-storage.ts:1443-1468): the first step_started that creates the step. The winner runs the body; losers get EntityConflictErrorskipped. But once the step exists — eager step_created, or after the create resolved — a subsequent step_started is just a start transition on an existing non-terminal step, and those are explicitly allowed:

Duplicate step_started events for a non-terminal step are still allowed (retries legitimately re-start a step), only writes to an already-terminal step are rejected. (events-storage.ts:110-112)

So in the inline-batches scenario the steps are eagerly created and queued, then several concurrent invocations (stale replay, wake replay, a step message dispatched off a lost create-claim) each pass the not-terminal check and each append a step_started for the same logical attempt. The create-claim never fires (nothing is being created), and there's no message-ID gate — so #3035's raw count reads N starts for 1 attempt and trips the ceiling.

Why not just gate the write by message ID instead? You can't distinguish the two cases at write time: the legitimate retry path is a different message re-starting the same non-terminal step, and a genuine owned-recovery re-run re-stamps the same messageId. "Duplicate racer start for the current attempt" and "legitimate next attempt" look identical to the write layer — both are non-terminal starts. ownerMessageId is the only signal that separates them, which is why the fix applies it at count time rather than at the POST.

(world-vercel's server-side guard makes the duplicate-non-terminal-start window much narrower, which is why the flake surfaced on world-local/world-testing — but the ceiling is computed in core against the event log, so the fix has to be world-agnostic.)

@VaguelySeriousVaguelySerious 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

s.correlationId
s.correlationId,
{
type: 'ownedBy',

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

Splitting the count into two disjoint filters means neither ceiling sees the combined attempt total across a mixed owned→bare sequence — and the authoritativeAttempt guard is the only bound for a step that always times out (writes no step_failed, so the error-gated guard at step-executor.ts:661 never fires).

Concretely, with maxRetries=3 (ceiling = attempt > 4), a step that times out under owned recovery a few times and then transitions to the queued/bare path:

step_started ownerMessageId=msg_OWNER // owned attempt 1
step_started ownerMessageId=msg_OWNER // owned attempt 2 (crash recovery)
step_started ownerMessageId=msg_OWNER // owned attempt 3 (crash recovery)
step_started (bare) // attempt 4 (lease expired → queued)
step_started (bare) // attempt 5 (queued retry)

I ran this through countStepStartedEvents on this branch to confirm (a throwaway probe, not committed). So the PR-description claim "genuine exhaustion still trips the guard at the same point" holds for pure-owned and pure-bare sequences but not strictly for a mixed one. It's still bounded overall (owned side by the queue's max-receive count, bare side by metadata.attempt), so this is not an infinite-loop regression and I'm not blocking on it — but the effective ceiling for an always-timing-out step that changes ownership mode can exceed maxRetries. Worth confirming that's acceptable, and ideally a test for the mixed case since the two current tests only cover the pure sequences.


Nit (folding in a second point to keep this on a diffable line): the comments here and near line 1953 call the owned-recovery start a "bare step_started", but ownerMessageId: metadata.messageId is stamped unconditionally just below — so it is not bare; it carries the owner stamp (which is what makes the ownedBy ceiling count it). Elsewhere "bare" means "no ownerMessageId" (the unowned background start). Reusing the word for both reads as contradictory.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

You're right on both counts — thanks for probing the mixed sequence, and I decided it's worth closing rather than just documenting. Addressed in 95f6d0f:

Mixed owned→bare gap: the background ceiling's scope is now totalAttempts = bare starts + the largest single owner's starts, instead of bare-only. Your exact sequence now computes attempt 3 (owned) + 2 (bare) + 1 = 6 > 4 → trips, restoring the #3035 behavior for genuine mixed lifecycles. The max-over-owners is what keeps the false positive fixed: a genuine lifecycle has at most one inline-ownership phase (ownership is claimed atomically at creation and lapses permanently at step_retrying or on any bare start), so the genuine owner's attempts are counted in full, while racing invocations' one-off stamped duplicates contribute at most their single largest count instead of accumulating. (If a racer's stamped start steals latest-start ownership mid-race, the superseded owner's attempts get shadowed by the max — that residual undercount is in the permissive direction and still bounded by the delivery-count gate.) Your sequence is committed as the mixed owned→bare timeout sequence trips the combined background ceiling test, and the racer-regression test now also asserts totalAttempts stays inside the ceiling.

The inline owned-recovery ceiling intentionally stays ownedBy: a mixed history can't precede owned recovery (a bare start clears ownership, so the owner's redelivery would no longer own the step and it falls through to the queued path, where the combined ceiling applies).

Terminology nit: fixed — the owned-recovery start is now described as a payload-less (but owner-stamped) start at both spots in runtime.ts, plus the two matching comments in step-executor.ts; "bare" is reserved for the background path's unstamped start.

@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for fd05393 (AI decision).

This fixes the retry-ceiling guard from #3035, which is main-only code: packages/core/src/runtime/step-executor.ts does not exist on origin/stable, and origin/stable:packages/core/src/runtime.ts contains no countStepStartedEvents, authoritativeAttempt, ownerMessageId, or exceeded max retries logic at all. With neither the guard nor the inline step-ownership stamping present on stable, the false-failure bug being fixed cannot occur there and the change has nothing to apply to.

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

fd05393d2b47d1a2f347538dd3b6062d808548d2

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.

3 participants

@TooTallNate@VaguelySerious
, '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); } })(); })(); [core] Don't count racing invocations' duplicate step_started events toward the maxRetries ceiling by TooTallNate · Pull Request #3069 · vercel/workflow · GitHub
Skip to content

[core] Don't count racing invocations' duplicate step_started events toward the maxRetries ceiling - #3069

Merged
TooTallNate merged 4 commits into
mainfrom
fix/inline-maxretries-race
Jul 24, 2026
Merged

[core] Don't count racing invocations' duplicate step_started events toward the maxRetries ceiling#3069
TooTallNate merged 4 commits into
mainfrom
fix/inline-maxretries-race

Conversation

@TooTallNate

Copy link
Copy Markdown
Member

Problem

The retry-ceiling guard added in #3035 derives a step's attempt number from the number of step_started events recorded for it. That count is not a reliable attempt number: invocations racing on the same pending batch (stale replays, wake replays, a step message dispatched off a lost create-claim) can each write a step_started for the same logical attempt. Worlds without an atomic start guard (world-local) let all of them through, so a healthy step's count can cross maxRetries + 1 and the guard fails the run with a false exceeded max retries FatalError.

This is the root cause of the world-testing > inline-batches-debug unit-test flake on CI (e.g. this run on main and this one on #3048): the test deliberately provokes concurrent replays racing on parallel step batches; on slow runners enough duplicate starts accumulate that a recovery re-run computes an attempt above the ceiling → run_failed → the test polls for completed until its 60s timeout. (The guard's error log was invisible in the test output because stepLogger uses the workflow:step:* namespace while the test only enables DEBUG=workflow:runtime:*.)

Fix

Scope the step_started count to the starts each ceiling is actually about, using the ownerMessageId stamp that inline starts already carry:

  • Inline owned-recovery ceiling: count only starts stamped with this message'sownerMessageId. Each real (re)delivery of the owning message re-stamps its ID on the start it writes, while racing invocations stamp their own IDs (or none) — so they no longer inflate this owner's attempt number.
  • Background-step ceiling (the delivery-count-gated verification): count only bare/unstamped starts. The background path never stamps ownership, so inline racers' stamped starts are excluded; throttle/too-early redeliveries (which write no start at all) stay excluded as before.

Real timeout retries are still bounded — the #3035 scenario is unchanged: each genuine recovery re-run writes another owner-stamped (or bare) start, so genuine exhaustion still trips the guard at the same point.

Testing

  • New unit tests for countStepStartedEvents covering both filters, including a regression case shaped like the CI flake (owner start + racer starts must not exhaust the ceiling) and a counter-case (4 genuine owner re-runs still exceed it).
  • Full @workflow/core suite: 1516 passed | 3 expected fail.
  • @workflow/world-testing suite (contains the flaky test): 15/15 × 4 consecutive runs. (The flake needs CI-slow timing to reproduce, so local runs can't fully prove it — but the new unit tests encode the failure mechanism directly.)

…toward the maxRetries ceiling
The retry-ceiling guard added in #3035 derives a step's attempt number from
the number of step_started events in the log. But invocations racing on the
same pending batch (stale replays, wake replays, step messages dispatched
off a lost create-claim) can each write a step_started for the same logical
attempt — worlds without an atomic start guard (world-local) let them all
through — so a healthy step's count could cross the ceiling and fail the
run with a false "exceeded max retries" (the world-testing
inline-batches-debug CI flake).
Scope the count to the starts each ceiling is actually about:
- inline owned-recovery: only starts stamped with THIS message's
ownerMessageId (each real (re)delivery of the owner re-stamps it;
racers stamp their own IDs or none)
- background steps: only bare/unstamped starts (that path never stamps
ownership, and throttle/too-early redeliveries still write no start)
Real timeout retries still bound: each recovery re-run writes another
owner-stamped (or bare) start, so genuine exhaustion still trips the guard.
@TooTallNate
TooTallNate requested review from a team and ijjk as code ownersJuly 23, 2026 20:17
CopilotAI review requested due to automatic review settings July 23, 2026 20:17
@changeset-bot

changeset-botBot commented Jul 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3a31e52

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

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

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

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
✅ 📦 Local Production162102271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7519011328651
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro126028
✅ example126028
✅ express126028
✅ fastify126028
✅ hono126028
✅ nextjs-turbopack15103
✅ nextjs-webpack15103
✅ nitro126028
✅ nuxt126028
✅ sveltekit14509
✅ vite126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128026
✅ e2e-local-dev-tanstack-start-128026
✅ e2e-local-postgres-nest-stable128026
✅ e2e-local-postgres-tanstack-start-128026
✅ e2e-local-prod-nest-stable128026
✅ e2e-local-prod-tanstack-start-128026
✅ e2e-vercel-prod-nest126028
✅ e2e-vercel-prod-tanstack-start126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@github-actions

github-actionsBot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 3a31e52 · Fri, 24 Jul 2026 22:10:47 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1229 (+58%) 🔻1365 🔴 (+25%) 🔻1406 🔴 (+26%) 🔻1548 🔴 (-1.0%)30
TTFSstream217 (+7.4%)1391 🔴 (+33%) 🔻1439 🔴 (+34%) 🔻1503 🔴 (+34%) 🔻30
TTFShook + stream1561 (+28%) 🔻1715 🔴 (+29%) 🔻1786 🔴 (+20%) 🔻1813 🔴 (-7.2%)30
STSO1020 steps (1-20)207 (+25%) 🔻285 🔴 (+8.0%)325 🔴 (-18%) 💚390 🔴 (-22%) 💚19
STSO1020 steps (101-120)216 (+32%) 🔻279 🔴 (+12%)321 🔴 (-8.8%)468 🔴 (+33%) 🔻19
STSO1020 steps (1001-1020)499 (+4.4%)616 🔴 (+14%)631 🔴 (+9.7%)651 🔴 (+13%)19
WO1020 steps417693 (+8.7%)417693 (+8.7%)417693 (+8.7%)417693 (+8.7%)1
SLstream latency125 (+67%) 🔻160 🔴 (+31%) 🔻178 🔴 (+40%) 🔻182 🔴 (-78%) 💚30
SOstream overhead (text)128 (+39%) 🔻198 (+29%) 🔻218 (+25%) 🔻332 (-7.0%)30
SOstream overhead (structured)129 (+25%) 🔻204 (+30%) 🔻230 (+28%) 🔻295 (+31%) 🔻30
📜 Previous results (3)

034e9a6

Fri, 24 Jul 2026 21:18:29 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1212 (+69%) 🔻1273 🔴 (+21%) 🔻1303 🔴 (+21%) 🔻1635 🔴 (+14%)30
TTFSstream358 (-62%) 💚1296 🔴 (+27%) 🔻1306 🔴 (+26%) 🔻1424 🔴 (+36%) 🔻30
TTFShook + stream464 (-61%) 💚1533 🔴 (+16%) 🔻1553 🔴 (+12%)1657 🔴 (-0.8%)30
STSO1020 steps (1-20)166 (+2.5%)270 🔴 (+8.4%)423 🔴 (+41%) 🔻433 🔴 (+42%) 🔻19
STSO1020 steps (101-120)174 (-5.9%)274 🔴 (-1.1%)356 🔴 (+9.2%)1718 🔴 (+384%) 🔻19
STSO1020 steps (1001-1020)469 (+2.9%)576 🔴 (+4.0%)709 🔴 (+19%) 🔻2977 🔴 (+262%) 🔻19
WO1020 steps375294 (-1.9%)375294 (-1.9%)375294 (-1.9%)375294 (-1.9%)1
SLstream latency86 (+15%)135 🔴 (+12%)150 🔴 (+8.7%)298 🔴 (-65%) 💚30
SOstream overhead (structured)90 (-8.2%)185 (+7.6%)308 (+24%) 🔻581 (+108%) 🔻30

95f6d0f

Fri, 24 Jul 2026 01:01:52 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1216 (+27%) 🔻1333 🔴 (+24%) 🔻1337 🔴 (+18%) 🔻1656 🔴 (+16%) 🔻30
TTFSstream1131 (+17%) 🔻1217 🔴 (+16%) 🔻1234 🔴 (+16%) 🔻1325 🔴 (+17%) 🔻30
TTFShook + stream1339 (+10%)1461 🔴 (+12%)1572 🔴 (+16%) 🔻1658 🔴 (+17%) 🔻30
STSO1020 steps (1-20)163 (-6.3%)277 🔴 (+12%)394 🔴 (+41%) 🔻482 🔴 (+40%) 🔻19
STSO1020 steps (101-120)185 (-2.1%)272 🔴 (-1.4%)385 🔴 (+17%) 🔻431 🔴 (+14%)19
STSO1020 steps (1001-1020)482 (+5.5%)568 🔴 (+10%)696 🔴 (+31%) 🔻698 🔴 (+12%)19
WO1020 steps398314 (+3.1%)398314 (+3.1%)398314 (+3.1%)398314 (+3.1%)1
SLstream latency89 (+11%)126 🔴 (-4.5%)153 🔴 (-15%)342 🔴 (-8.8%)30

a0eed2f

Thu, 23 Jul 2026 20:34:21 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1251 (+23%) 🔻1370 🔴 (+11%)1382 🔴 (+9.3%)1693 🔴 (+26%) 🔻30
TTFSstream1244 (+216%) 🔻1330 🔴 (+17%) 🔻1380 🔴 (+14%)1414 🔴 (-59%) 💚30
TTFShook + stream1208 (+101%) 🔻1646 🔴 (+12%)1676 🔴 (+10%)1830 🔴 (+11%)30
STSO1020 steps (1-20)180 (-12%)279 🔴 (-13%)296 🔴 (-13%)308 🔴 (-18%) 💚19
STSO1020 steps (101-120)194 (-11%)291 🔴 (-1.7%)380 🔴 (-5.0%)381 🔴 (-27%) 💚19
STSO1020 steps (1001-1020)479 (-8.2%)531 🔴 (-14%)592 🔴 (-20%) 💚608 🔴 (-19%) 💚19
WO1020 steps397530 (-15%) 💚397530 (-15%) 💚397530 (-15%) 💚397530 (-15%) 💚1
SLstream latency98 (±0%)148 🔴 (-31%) 💚175 🔴 (-32%) 💚396 🔴 (+33%) 🔻30
ℹ️ Metric definitions & methodology

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

CopilotAI 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

Fixes a false-positive maxRetries exhaustion in @workflow/core caused by counting duplicate step_started events emitted by concurrent/racing invocations as separate attempts, especially in worlds without an atomic start guard (e.g. world-local). The change scopes “authoritative attempt” derivation to the specific class of starts each ceiling is intended to measure.

Changes:

  • Add an ownership-aware filter to the step_started-counting helper so inline owned-recovery counts only starts stamped with the current ownerMessageId, while background-step verification counts only bare/unstamped starts.
  • Update both the background-step retry ceiling verification and the inline owned-recovery ceiling to use the filtered counts.
  • Add focused unit tests covering both filters and the CI-flake-shaped regression case; add a changeset for @workflow/core.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

FileDescription
packages/core/src/runtime.tsAdds owner-filtered step_started counting and applies it to inline/background retry ceiling logic to avoid false maxRetries exhaustion from racing invocations.
packages/core/src/runtime.test.tsAdds unit tests validating the new counting behavior (owned/unowned filters + regression/counter-case).
.changeset/inline-maxretries-race.mdPublishes the fix as a patch changeset for @workflow/core.

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

Comment threadpackages/core/src/runtime.ts Outdated
Comment on lines 277 to 309
export type StepStartOwnerFilter =
| { type: 'ownedBy'; messageId: string }
| { type: 'unowned' };

/**
* Number of `step_started` events already recorded for a step, used as the
* authoritative attempt count for the inline retry ceiling. Each real attempt
* writes exactly one `step_started` (the atomic create-claim / single-flight
* prevents concurrent double-starts from inflating this), so the count equals
* the number of attempts that have begun.
* authoritative attempt count for the retry ceiling.
*
* IMPORTANT: the raw (unfiltered) count is NOT a reliable attempt number.
* Concurrent invocations racing on the same pending batch (stale replays,
* wake replays, a step message dispatched by a replay that lost the
* create-claim race, ...) can each write a `step_started` for the same
* logical attempt — worlds without an atomic start guard (world-local) let
* all of them through. Counting those duplicates as "attempts" made the
* maxRetries ceiling fire on healthy runs and fail them with a false
* "exceeded max retries" (see workflow#3048 CI flake).
*
* Callers must therefore scope the count to the starts their ceiling is
* actually about via `ownerFilter`:
* - the inline owned-recovery ceiling counts only THIS message's starts
* (`ownedBy`) — each real (re)delivery of the owning message stamps its
* `ownerMessageId` on the start it writes, while racing invocations stamp
* their own IDs (or none), so they no longer inflate the count;
* - the background-step ceiling counts only bare starts (`unowned`) — the
* background path never stamps ownership, so inline racers' stamped
* starts are excluded, and throttle/too-early redeliveries (which write
* no start at all) stay excluded as before.
*/
function countStepStartedEvents(
export function countStepStartedEvents(
events: Event[] | null | undefined,
stepId: string
stepId: string,
ownerFilter?: StepStartOwnerFilter
): number {

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Good catch — ./runtime is indeed a public subpath export. Moved the helper (and its filter type) to an internal module, src/runtime/count-step-started-events.ts, imported relatively from runtime.ts; the tests moved to a sibling test file and import it by relative path. No new public API surface. Done in 95f6d0f.

@VaguelySerious

Copy link
Copy Markdown
Member

Huh, I thought this should already be safe under the assumptions that we only create step_started when we're actually about to run the step, so that step_started POST should already be gated by whether we're on the invocation with the right message ID 🤔

@VaguelySerious

Copy link
Copy Markdown
Member

(AI) Dug into the write path — the premise doesn't hold, on two counts:

1. ownerMessageId is a stamp, not a write gate. It's just a field on step_started's eventData (packages/world/src/events.ts:357-369), used by wake replays to reason about the liveness lease. Nothing rejects a write because it carries the "wrong" message ID. That's precisely why this PR has to reconstruct the message-scoping post-hoc at count time — there's no gate at the write layer to lean on.

2. The only write-time exactly-once guard is the create-claim, and it covers step creation, not step starts. In world-local the atomic claim fires only on the lazy-start path (!validatedStep && lazyStepStart, events-storage.ts:1443-1468): the first step_started that creates the step. The winner runs the body; losers get EntityConflictErrorskipped. But once the step exists — eager step_created, or after the create resolved — a subsequent step_started is just a start transition on an existing non-terminal step, and those are explicitly allowed:

Duplicate step_started events for a non-terminal step are still allowed (retries legitimately re-start a step), only writes to an already-terminal step are rejected. (events-storage.ts:110-112)

So in the inline-batches scenario the steps are eagerly created and queued, then several concurrent invocations (stale replay, wake replay, a step message dispatched off a lost create-claim) each pass the not-terminal check and each append a step_started for the same logical attempt. The create-claim never fires (nothing is being created), and there's no message-ID gate — so #3035's raw count reads N starts for 1 attempt and trips the ceiling.

Why not just gate the write by message ID instead? You can't distinguish the two cases at write time: the legitimate retry path is a different message re-starting the same non-terminal step, and a genuine owned-recovery re-run re-stamps the same messageId. "Duplicate racer start for the current attempt" and "legitimate next attempt" look identical to the write layer — both are non-terminal starts. ownerMessageId is the only signal that separates them, which is why the fix applies it at count time rather than at the POST.

(world-vercel's server-side guard makes the duplicate-non-terminal-start window much narrower, which is why the flake surfaced on world-local/world-testing — but the ceiling is computed in core against the event log, so the fix has to be world-agnostic.)

@VaguelySeriousVaguelySerious 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

s.correlationId
s.correlationId,
{
type: 'ownedBy',

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

Splitting the count into two disjoint filters means neither ceiling sees the combined attempt total across a mixed owned→bare sequence — and the authoritativeAttempt guard is the only bound for a step that always times out (writes no step_failed, so the error-gated guard at step-executor.ts:661 never fires).

Concretely, with maxRetries=3 (ceiling = attempt > 4), a step that times out under owned recovery a few times and then transitions to the queued/bare path:

step_started ownerMessageId=msg_OWNER // owned attempt 1
step_started ownerMessageId=msg_OWNER // owned attempt 2 (crash recovery)
step_started ownerMessageId=msg_OWNER // owned attempt 3 (crash recovery)
step_started (bare) // attempt 4 (lease expired → queued)
step_started (bare) // attempt 5 (queued retry)

I ran this through countStepStartedEvents on this branch to confirm (a throwaway probe, not committed). So the PR-description claim "genuine exhaustion still trips the guard at the same point" holds for pure-owned and pure-bare sequences but not strictly for a mixed one. It's still bounded overall (owned side by the queue's max-receive count, bare side by metadata.attempt), so this is not an infinite-loop regression and I'm not blocking on it — but the effective ceiling for an always-timing-out step that changes ownership mode can exceed maxRetries. Worth confirming that's acceptable, and ideally a test for the mixed case since the two current tests only cover the pure sequences.


Nit (folding in a second point to keep this on a diffable line): the comments here and near line 1953 call the owned-recovery start a "bare step_started", but ownerMessageId: metadata.messageId is stamped unconditionally just below — so it is not bare; it carries the owner stamp (which is what makes the ownedBy ceiling count it). Elsewhere "bare" means "no ownerMessageId" (the unowned background start). Reusing the word for both reads as contradictory.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

You're right on both counts — thanks for probing the mixed sequence, and I decided it's worth closing rather than just documenting. Addressed in 95f6d0f:

Mixed owned→bare gap: the background ceiling's scope is now totalAttempts = bare starts + the largest single owner's starts, instead of bare-only. Your exact sequence now computes attempt 3 (owned) + 2 (bare) + 1 = 6 > 4 → trips, restoring the #3035 behavior for genuine mixed lifecycles. The max-over-owners is what keeps the false positive fixed: a genuine lifecycle has at most one inline-ownership phase (ownership is claimed atomically at creation and lapses permanently at step_retrying or on any bare start), so the genuine owner's attempts are counted in full, while racing invocations' one-off stamped duplicates contribute at most their single largest count instead of accumulating. (If a racer's stamped start steals latest-start ownership mid-race, the superseded owner's attempts get shadowed by the max — that residual undercount is in the permissive direction and still bounded by the delivery-count gate.) Your sequence is committed as the mixed owned→bare timeout sequence trips the combined background ceiling test, and the racer-regression test now also asserts totalAttempts stays inside the ceiling.

The inline owned-recovery ceiling intentionally stays ownedBy: a mixed history can't precede owned recovery (a bare start clears ownership, so the owner's redelivery would no longer own the step and it falls through to the queued path, where the combined ceiling applies).

Terminology nit: fixed — the owned-recovery start is now described as a payload-less (but owner-stamped) start at both spots in runtime.ts, plus the two matching comments in step-executor.ts; "bare" is reserved for the background path's unstamped start.

@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for fd05393 (AI decision).

This fixes the retry-ceiling guard from #3035, which is main-only code: packages/core/src/runtime/step-executor.ts does not exist on origin/stable, and origin/stable:packages/core/src/runtime.ts contains no countStepStartedEvents, authoritativeAttempt, ownerMessageId, or exceeded max retries logic at all. With neither the guard nor the inline step-ownership stamping present on stable, the false-failure bug being fixed cannot occur there and the change has nothing to apply to.

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

fd05393d2b47d1a2f347538dd3b6062d808548d2

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.

3 participants

@TooTallNate@VaguelySerious