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's ownerMessageId. 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 owners July 23, 2026 20:17
Copilot AI review requested due to automatic review settings July 23, 2026 20:17
@changeset-bot

changeset-bot Bot 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
Name Type
@workflow/core Patch
@workflow/builders Patch
@workflow/cli Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/vitest Patch
@workflow/web-shared Patch
@workflow/web Patch
workflow Patch
@workflow/world-testing Patch
@workflow/astro Patch
@workflow/nest Patch
@workflow/nuxt Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite Patch

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

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

@vercel

vercel Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
Passed Failed Skipped Total
✅ ▲ Vercel Production 1455 0 239 1694
✅ 💻 Local Development 1621 0 227 1848
✅ 📦 Local Production 1621 0 227 1848
✅ 🐘 Local Postgres 1621 0 227 1848
✅ 🪟 Windows 154 0 0 154
✅ 📋 Other 1020 0 212 1232
✅ vercel-multi-region 27 0 0 27
Total 7519 0 1132 8651
Details by Category

✅ ▲ Vercel Production

App Passed Failed Skipped
✅ astro 126 0 28
✅ example 126 0 28
✅ express 126 0 28
✅ fastify 126 0 28
✅ hono 126 0 28
✅ nextjs-turbopack 151 0 3
✅ nextjs-webpack 151 0 3
✅ nitro 126 0 28
✅ nuxt 126 0 28
✅ sveltekit 145 0 9
✅ vite 126 0 28

✅ 💻 Local Development

App Passed Failed Skipped
✅ astro-stable 128 0 26
✅ express-stable 128 0 26
✅ fastify-stable 128 0 26
✅ hono-stable 128 0 26
✅ nextjs-turbopack-canary 135 0 19
✅ nextjs-turbopack-stable 154 0 0
✅ nextjs-webpack-canary 135 0 19
✅ nextjs-webpack-stable 154 0 0
✅ nitro-stable 128 0 26
✅ nuxt-stable 128 0 26
✅ sveltekit-stable 147 0 7
✅ vite-stable 128 0 26

✅ 📦 Local Production

App Passed Failed Skipped
✅ astro-stable 128 0 26
✅ express-stable 128 0 26
✅ fastify-stable 128 0 26
✅ hono-stable 128 0 26
✅ nextjs-turbopack-canary 135 0 19
✅ nextjs-turbopack-stable 154 0 0
✅ nextjs-webpack-canary 135 0 19
✅ nextjs-webpack-stable 154 0 0
✅ nitro-stable 128 0 26
✅ nuxt-stable 128 0 26
✅ sveltekit-stable 147 0 7
✅ vite-stable 128 0 26

✅ 🐘 Local Postgres

App Passed Failed Skipped
✅ astro-stable 128 0 26
✅ express-stable 128 0 26
✅ fastify-stable 128 0 26
✅ hono-stable 128 0 26
✅ nextjs-turbopack-canary 135 0 19
✅ nextjs-turbopack-stable 154 0 0
✅ nextjs-webpack-canary 135 0 19
✅ nextjs-webpack-stable 154 0 0
✅ nitro-stable 128 0 26
✅ nuxt-stable 128 0 26
✅ sveltekit-stable 147 0 7
✅ vite-stable 128 0 26

✅ 🪟 Windows

App Passed Failed Skipped
✅ nextjs-turbopack 154 0 0

✅ 📋 Other

App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 128 0 26
✅ e2e-local-dev-tanstack-start- 128 0 26
✅ e2e-local-postgres-nest-stable 128 0 26
✅ e2e-local-postgres-tanstack-start- 128 0 26
✅ e2e-local-prod-nest-stable 128 0 26
✅ e2e-local-prod-tanstack-start- 128 0 26
✅ e2e-vercel-prod-nest 126 0 28
✅ e2e-vercel-prod-tanstack-start 126 0 28

✅ vercel-multi-region

App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

📋 View full workflow run

@github-actions

github-actions Bot 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

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1229 (+58%) 🔻 1365 🔴 (+25%) 🔻 1406 🔴 (+26%) 🔻 1548 🔴 (-1.0%) 30
TTFS stream 217 (+7.4%) 1391 🔴 (+33%) 🔻 1439 🔴 (+34%) 🔻 1503 🔴 (+34%) 🔻 30
TTFS hook + stream 1561 (+28%) 🔻 1715 🔴 (+29%) 🔻 1786 🔴 (+20%) 🔻 1813 🔴 (-7.2%) 30
STSO 1020 steps (1-20) 207 (+25%) 🔻 285 🔴 (+8.0%) 325 🔴 (-18%) 💚 390 🔴 (-22%) 💚 19
STSO 1020 steps (101-120) 216 (+32%) 🔻 279 🔴 (+12%) 321 🔴 (-8.8%) 468 🔴 (+33%) 🔻 19
STSO 1020 steps (1001-1020) 499 (+4.4%) 616 🔴 (+14%) 631 🔴 (+9.7%) 651 🔴 (+13%) 19
WO 1020 steps 417693 (+8.7%) 417693 (+8.7%) 417693 (+8.7%) 417693 (+8.7%) 1
SL stream latency 125 (+67%) 🔻 160 🔴 (+31%) 🔻 178 🔴 (+40%) 🔻 182 🔴 (-78%) 💚 30
SO stream overhead (text) 128 (+39%) 🔻 198 (+29%) 🔻 218 (+25%) 🔻 332 (-7.0%) 30
SO stream 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

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1212 (+69%) 🔻 1273 🔴 (+21%) 🔻 1303 🔴 (+21%) 🔻 1635 🔴 (+14%) 30
TTFS stream 358 (-62%) 💚 1296 🔴 (+27%) 🔻 1306 🔴 (+26%) 🔻 1424 🔴 (+36%) 🔻 30
TTFS hook + stream 464 (-61%) 💚 1533 🔴 (+16%) 🔻 1553 🔴 (+12%) 1657 🔴 (-0.8%) 30
STSO 1020 steps (1-20) 166 (+2.5%) 270 🔴 (+8.4%) 423 🔴 (+41%) 🔻 433 🔴 (+42%) 🔻 19
STSO 1020 steps (101-120) 174 (-5.9%) 274 🔴 (-1.1%) 356 🔴 (+9.2%) 1718 🔴 (+384%) 🔻 19
STSO 1020 steps (1001-1020) 469 (+2.9%) 576 🔴 (+4.0%) 709 🔴 (+19%) 🔻 2977 🔴 (+262%) 🔻 19
WO 1020 steps 375294 (-1.9%) 375294 (-1.9%) 375294 (-1.9%) 375294 (-1.9%) 1
SL stream latency 86 (+15%) 135 🔴 (+12%) 150 🔴 (+8.7%) 298 🔴 (-65%) 💚 30
SO stream 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

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1216 (+27%) 🔻 1333 🔴 (+24%) 🔻 1337 🔴 (+18%) 🔻 1656 🔴 (+16%) 🔻 30
TTFS stream 1131 (+17%) 🔻 1217 🔴 (+16%) 🔻 1234 🔴 (+16%) 🔻 1325 🔴 (+17%) 🔻 30
TTFS hook + stream 1339 (+10%) 1461 🔴 (+12%) 1572 🔴 (+16%) 🔻 1658 🔴 (+17%) 🔻 30
STSO 1020 steps (1-20) 163 (-6.3%) 277 🔴 (+12%) 394 🔴 (+41%) 🔻 482 🔴 (+40%) 🔻 19
STSO 1020 steps (101-120) 185 (-2.1%) 272 🔴 (-1.4%) 385 🔴 (+17%) 🔻 431 🔴 (+14%) 19
STSO 1020 steps (1001-1020) 482 (+5.5%) 568 🔴 (+10%) 696 🔴 (+31%) 🔻 698 🔴 (+12%) 19
WO 1020 steps 398314 (+3.1%) 398314 (+3.1%) 398314 (+3.1%) 398314 (+3.1%) 1
SL stream latency 89 (+11%) 126 🔴 (-4.5%) 153 🔴 (-15%) 342 🔴 (-8.8%) 30

a0eed2f

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

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1251 (+23%) 🔻 1370 🔴 (+11%) 1382 🔴 (+9.3%) 1693 🔴 (+26%) 🔻 30
TTFS stream 1244 (+216%) 🔻 1330 🔴 (+17%) 🔻 1380 🔴 (+14%) 1414 🔴 (-59%) 💚 30
TTFS hook + stream 1208 (+101%) 🔻 1646 🔴 (+12%) 1676 🔴 (+10%) 1830 🔴 (+11%) 30
STSO 1020 steps (1-20) 180 (-12%) 279 🔴 (-13%) 296 🔴 (-13%) 308 🔴 (-18%) 💚 19
STSO 1020 steps (101-120) 194 (-11%) 291 🔴 (-1.7%) 380 🔴 (-5.0%) 381 🔴 (-27%) 💚 19
STSO 1020 steps (1001-1020) 479 (-8.2%) 531 🔴 (-14%) 592 🔴 (-20%) 💚 608 🔴 (-19%) 💚 19
WO 1020 steps 397530 (-15%) 💚 397530 (-15%) 💚 397530 (-15%) 💚 397530 (-15%) 💚 1
SL stream latency 98 (±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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

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.

File Description
packages/core/src/runtime.ts Adds 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.ts Adds unit tests validating the new counting behavior (owned/unowned filters + regression/counter-case).
.changeset/inline-maxretries-race.md Publishes the fix as a patch changeset for @workflow/core.

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

Comment thread packages/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
Member Author

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

@VaguelySerious VaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI review: no blocking issues

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
Member Author

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 free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants