Skip to content

[core] Report the replay position on every event write - #3479

Merged
VaguelySerious merged 4 commits into
mainfrom
peter/slot-only-precondition
Aug 12, 2026
Merged

[core] Report the replay position on every event write#3479
VaguelySerious merged 4 commits into
mainfrom
peter/slot-only-precondition

Conversation

@VaguelySerious

Copy link
Copy Markdown
Member

Two changes that only make sense together: every replay-context write now reports the log position it was computed against, and the ULID-era snapshot triple that used to carry that information (plus its WORKFLOW_PRECONDITION_GUARD kill switch) is deleted.

1. eventCount is sent whenever there is a position to report

eventCount (how many events the writer had loaded, i.e. the position it expects to land on minus one) previously reached the World from a handful of call sites. The hottest writes sent nothing at all, so the World had no anchor for the write it was most likely to be racing.

Every write from the replay loop now goes through one seam in runtime.ts that stamps slotSnapshot() onto the params, so it is attached by construction rather than per call site:

  • step_completed / step_failed and the rest of the loop's writes
  • inline step claims and terminal writes (step-executor.ts), where knownSlot / observeSlot advance the position across the step's own writes rather than reusing the stale entry value
  • suspension writes (suspension-handler.ts)
  • the replay-timeout run_failed (replay-budget.ts), which is a terminal write made on a particular view of the log and now says which view

Writes with no loaded log to be stale against still send nothing, and a World must accept those: start(), queued step bodies, hook resume, runs.*, out-of-band writers. maxEventSlot() also returns undefined unless every loaded event ID parses as a slot, so a run on the old ID scheme sends no position either.

2. WORKFLOW_PRECONDITION_GUARD and the ULID triple are removed

stateUpdatedAt / stateEventCount / stateCursor are gone from CreateEventParams, along with isPreconditionGuardEnabled(), latestEventStateUpdatedAt(), and the env var. PreconditionSnapshotParams becomes SlotSnapshotParams; preconditionSnapshotParams(events, cursor) becomes slotSnapshotParams(events).

The triple approximated a position with a timestamp watermark plus a count of events at or below it. A complete-but-stale snapshot passes that check by construction: every event the writer holds is at or below its own watermark, so the count matches and no fence fires. The write that corrupts a log is exactly the one that view cannot see. A dense slot has no such blind spot, and specVersion 6 assigns it at commit time.

The 412 reception path is untouched: PreconditionFailedError, the in-process replay restarts, the re-invocation fallback, and WORKFLOW_PRECONDITION_MAX_* all stay. This PR removes the client's ability to ask for the old fence, not its ability to survive one.

Docs: the WORKFLOW_PRECONDITION_GUARD section in Runtime Tuning becomes "Stale-write rejection" (a World capability rather than a variable), PreconditionFailedError is reworded, and the World-author guide replaces the triple's contract with the eventCount one.

Problem: step_started is not duplicate-inert

step_started is deliberately excluded from ONCE_PER_ENTITY_CLASSES in #3381, because a second step_started for a correlation ID is how a retry is recorded. That exclusion is load-bearing for retries and wrong for races.

A losing lazy step_started that lands anyway is therefore consumed as a real attempt: it increments attempt, and the retry budget for that step shrinks by one for a write that represented no execution. On a step whose maxAttempts is small, a duplicate storm can exhaust the budget without the body ever having failed. Nothing here fixes that. It needs a discriminator on the event that separates "this is attempt N+1" from "this is a duplicate claim for attempt N", which is a wire-format change and belongs in its own PR.

Problem: capabilities.preconditionGuard no longer means what its consumers read it to mean

The capability is documented as "this World rejects a stale create with 412". Against a v6 run that is now unreachable: the Vercel World evaluates its fence only for a create carrying an old-scheme snapshot, and this SDK sends none. A run created by a deployment carrying this SDK is v6, and skew protection keeps pre-v6 runs replaying on the deployments that created them, so no write from this SDK can be 412'd on the snapshot path.

preconditionGuard: true is nonetheless kept on the Vercel World, because three runtime behaviors are keyed on it and all three are the conservative choice:

Behavior With the capability
requestInlineDelta with an open hook permitted (delta consumed instead of an extra events.list)
suppressOptimisticStart on, so an inline step_started claim is awaited before user code runs
resilientDispatchEligible (node VM) off, so a step_created publish is sequenced after the create

Read as "a write can be refused" they are all still correct, and the slot path carries the load the fence used to. Read as "the fence is armed" the first row is now unpaid-for: it keeps an optimization enabled on the strength of a check that cannot fire. I did not change it here because doing so is a behavior change with its own blast radius, but it should be revisited, most likely by splitting the capability into "can refuse a write" and "fences on eventCount".

world-sim

The sim mints ULIDs, so a client on slot IDs sends it no position and its fence had nothing to read. The facade now reconstructs the snapshot from the pages the writer actually read. Two rules turned out to be load-bearing, both learned by getting them wrong:

  • A read is what starts the set. A writer that writes before loading anything (a fresh delivery's run_started) gets no snapshot. Crediting it with its own write hands the fence a position below what the log holds and rejects the next concurrent write on a claim nobody made.
  • The set lives for one delivery. It is scoped to the queue handler invocation via AsyncLocalStorage. A facade-lifetime map hands a cold-starting replay the previous delivery's view and fences it forever for a log it never loaded, which is what in-flight-after-decision did until this was fixed.

Both books match the pre-change baseline exactly: mint-ordered 35 passed / 6 failed / 6 violations with the identical failing set, append-only 41 passed / 0 failed / 0 violations.

Validation

npx turbo run build 45/45, pnpm typecheck 43/43, pnpm test 48/48 tasks (core 2048 passed + 3 expected-fail, world-sim 72/72, world-postgres 179/179). pnpm lint reports 4 errors, all pre-existing on main and outside the changed files.

No World other than the Vercel one ever read the triple, so nothing else in the repo changes behavior.

@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated (UTC)
example-nextjs-workflow-turbopack Ready Ready Preview Aug 12, 2026 7:28pm
example-nextjs-workflow-webpack Ready Ready Preview Aug 12, 2026 7:28pm
example-workflow Ready Ready Preview Aug 12, 2026 7:28pm
workbench-astro-workflow Ready Ready Preview Aug 12, 2026 7:28pm
workbench-express-workflow Ready Ready Preview Aug 12, 2026 7:28pm
workbench-fastify-workflow Ready Ready Preview Aug 12, 2026 7:28pm
workbench-hono-workflow Ready Ready Preview Aug 12, 2026 7:28pm
workbench-nestjs-workflow Ready Ready Preview Aug 12, 2026 7:28pm
workbench-nitro-workflow Ready Ready Preview Aug 12, 2026 7:28pm
workbench-nuxt-workflow Ready Ready Preview Aug 12, 2026 7:28pm
workbench-python-workflow Error Error Aug 12, 2026 7:28pm
workbench-sveltekit-workflow Ready Ready Preview Aug 12, 2026 7:28pm
workbench-tanstack-start-workflow Ready Ready Preview Aug 12, 2026 7:28pm
workbench-vite-workflow Ready Ready Preview Aug 12, 2026 7:28pm
workflow-docs Ready Ready Preview, v0 Aug 12, 2026 7:28pm
workflow-swc-playground Ready Ready Preview Aug 12, 2026 7:28pm
workflow-tarballs Ready Ready Preview Aug 12, 2026 7:28pm
workflow-web Ready Ready Preview Aug 12, 2026 7:28pm

@changeset-bot

changeset-bot Bot commented Aug 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 11e7eed

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

This PR includes changesets to release 21 packages
Name Type
@workflow/core Patch
@workflow/errors Patch
@workflow/world Patch
@workflow/world-vercel Patch
@workflow/builders Patch
@workflow/cli Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/vitest Patch
@workflow/web-shared Patch
@workflow/web Patch
workflow Patch
@workflow/world-testing Patch
@workflow/world-local Patch
@workflow/world-postgres Patch
@workflow/astro Patch
@workflow/nest Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite Patch
@workflow/nuxt Patch

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

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

@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

❌ Failed E2E Tests

▲ Vercel Production (1 failed)

nextjs-turbopack-node (1 failed):

  • distributedAbortController - manual abort triggers signal | wrun_41KZVR2W2A0GVRB5Y0Z1024246 | 🔍 observability

💻 Local Development (1 failed)

nextjs-webpack-stable-node (1 failed):

  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep | wrun_41KZVQTFF20GPB7QE0VNMTPRJX

E2E Test Summary

Summary
Passed Failed Skipped Total
❌ ▲ Vercel Production 3465 1 590 4056
❌ 💻 Local Development 3386 1 513 3900
✅ 📦 Local Production 3810 0 558 4368
✅ 🐘 Local Postgres 3810 0 558 4368
✅ 🪟 Windows 312 0 0 312
✅ vercel-multi-region 27 0 0 27
Total 14810 2 2219 17031
Details by Category

❌ ▲ Vercel Production

App Passed Failed Skipped
✅ astro-node 128 0 28
✅ astro-quickjs 128 0 28
✅ example-node 128 0 28
✅ example-quickjs 128 0 28
✅ express-node 128 0 28
✅ express-quickjs 128 0 28
✅ fastify-node 128 0 28
✅ fastify-quickjs 128 0 28
✅ hono-node 128 0 28
✅ hono-quickjs 128 0 28
✅ nest-node 128 0 28
✅ nest-quickjs 128 0 28
❌ nextjs-turbopack-node 152 1 3
✅ nextjs-turbopack-quickjs 153 0 3
✅ nextjs-webpack-node 153 0 3
✅ nextjs-webpack-quickjs 153 0 3
✅ nitro-node 128 0 28
✅ nitro-quickjs 128 0 28
✅ nuxt-node 128 0 28
✅ nuxt-quickjs 128 0 28
✅ sveltekit-node 147 0 9
✅ sveltekit-quickjs 147 0 9
✅ tanstack-start-node 128 0 28
✅ tanstack-start-quickjs 128 0 28
✅ vite-node 128 0 28
✅ vite-quickjs 128 0 28

❌ 💻 Local Development

App Passed Failed Skipped
✅ astro-stable-node 130 0 26
✅ astro-stable-quickjs 130 0 26
✅ express-stable-node 130 0 26
✅ express-stable-quickjs 130 0 26
✅ fastify-stable-node 130 0 26
✅ fastify-stable-quickjs 130 0 26
✅ hono-stable-node 130 0 26
✅ hono-stable-quickjs 130 0 26
✅ nest-stable-node 130 0 26
✅ nest-stable-quickjs 130 0 26
✅ nextjs-turbopack-canary-node 137 0 19
✅ nextjs-turbopack-canary-quickjs 137 0 19
✅ nextjs-turbopack-stable-node 156 0 0
✅ nextjs-turbopack-stable-quickjs 156 0 0
✅ nextjs-webpack-canary-node 137 0 19
❌ nextjs-webpack-stable-node 155 1 0
✅ nitro-stable-node 130 0 26
✅ nitro-stable-quickjs 130 0 26
✅ nuxt-stable-node 130 0 26
✅ nuxt-stable-quickjs 130 0 26
✅ sveltekit-stable-node 149 0 7
✅ sveltekit-stable-quickjs 149 0 7
✅ tanstack-start-node 130 0 26
✅ tanstack-start-quickjs 130 0 26
✅ vite-stable-node 130 0 26

✅ 📦 Local Production

App Passed Failed Skipped
✅ astro-stable-node 130 0 26
✅ astro-stable-quickjs 130 0 26
✅ express-stable-node 130 0 26
✅ express-stable-quickjs 130 0 26
✅ fastify-stable-node 130 0 26
✅ fastify-stable-quickjs 130 0 26
✅ hono-stable-node 130 0 26
✅ hono-stable-quickjs 130 0 26
✅ nest-stable-node 130 0 26
✅ nest-stable-quickjs 130 0 26
✅ nextjs-turbopack-canary-node 137 0 19
✅ nextjs-turbopack-canary-quickjs 137 0 19
✅ nextjs-turbopack-stable-node 156 0 0
✅ nextjs-turbopack-stable-quickjs 156 0 0
✅ nextjs-webpack-canary-node 137 0 19
✅ nextjs-webpack-canary-quickjs 137 0 19
✅ nextjs-webpack-stable-node 156 0 0
✅ nextjs-webpack-stable-quickjs 156 0 0
✅ nitro-stable-node 130 0 26
✅ nitro-stable-quickjs 130 0 26
✅ nuxt-stable-node 130 0 26
✅ nuxt-stable-quickjs 130 0 26
✅ sveltekit-stable-node 149 0 7
✅ sveltekit-stable-quickjs 149 0 7
✅ tanstack-start-node 130 0 26
✅ tanstack-start-quickjs 130 0 26
✅ vite-stable-node 130 0 26
✅ vite-stable-quickjs 130 0 26

✅ 🐘 Local Postgres

App Passed Failed Skipped
✅ astro-stable-node 130 0 26
✅ astro-stable-quickjs 130 0 26
✅ express-stable-node 130 0 26
✅ express-stable-quickjs 130 0 26
✅ fastify-stable-node 130 0 26
✅ fastify-stable-quickjs 130 0 26
✅ hono-stable-node 130 0 26
✅ hono-stable-quickjs 130 0 26
✅ nest-stable-node 130 0 26
✅ nest-stable-quickjs 130 0 26
✅ nextjs-turbopack-canary-node 137 0 19
✅ nextjs-turbopack-canary-quickjs 137 0 19
✅ nextjs-turbopack-stable-node 156 0 0
✅ nextjs-turbopack-stable-quickjs 156 0 0
✅ nextjs-webpack-canary-node 137 0 19
✅ nextjs-webpack-canary-quickjs 137 0 19
✅ nextjs-webpack-stable-node 156 0 0
✅ nextjs-webpack-stable-quickjs 156 0 0
✅ nitro-stable-node 130 0 26
✅ nitro-stable-quickjs 130 0 26
✅ nuxt-stable-node 130 0 26
✅ nuxt-stable-quickjs 130 0 26
✅ sveltekit-stable-node 149 0 7
✅ sveltekit-stable-quickjs 149 0 7
✅ tanstack-start-node 130 0 26
✅ tanstack-start-quickjs 130 0 26
✅ vite-stable-node 130 0 26
✅ vite-stable-quickjs 130 0 26

✅ 🪟 Windows

App Passed Failed Skipped
✅ nextjs-turbopack-node 156 0 0
✅ nextjs-turbopack-quickjs 156 0 0

✅ vercel-multi-region

App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

📋 View full workflow run

@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 11e7eed · Wed, 12 Aug 2026 19:43:06 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 230 (-75%) 💚 1362 🔴 (+11%) 1399 🔴 (+5.1%) 1495 🔴 (-8.9%) 30
TTFS stream 220 (-15%) 💚 1382 🔴 (+16%) 🔻 1473 🔴 (+21%) 🔻 1549 🔴 (+21%) 🔻 30
TTFS hook + stream 381 (-7.3%) 1663 🔴 (+9.2%) 1734 🔴 (+9.9%) 2079 🔴 (+1.5%) 30
STSO 1020 steps (inline) 133 (+25%) 🔻 197 (+8.2%) 218 (+2.3%) 322 (-4.2%) 1019
WO 1020 steps 191981 (+12%) 191981 (+12%) 191981 (+12%) 191981 (+12%) 1
SL stream latency 97 (-4.9%) 152 🔴 (-17%) 💚 213 🔴 (-31%) 💚 432 🔴 (-23%) 💚 30
SO stream overhead (text) 134 (-6.3%) 261 🔴 (±0%) 370 (+21%) 🔻 823 (+74%) 🔻 30
SO stream overhead (structured) 126 (-22%) 💚 225 (-42%) 💚 357 (-31%) 💚 549 (-38%) 💚 30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 170730ms → this run 190606ms (Δ +19876ms, +12%)

100-150 ms  ┃███████████              main 385  this  19  -366
150-200 ms  ███████████████░░░░░░░░┃  main 485  this 777  +292
200-250 ms  ███░┃                     main 109  this 173   +64
250-300 ms  ┃                         main  27  this  37   +10
300-350 ms  ┃                         main   5  this   5    +0
350-400 ms  ┃                         main   5  this   1    -4
400-450 ms  ┃                         main   1  this   4    +3
450-500 ms  ┃                         main   2  this   1    -1
500-550 ms  ┃                         main   0  this   1    +1
550-600 ms  ┃                         main   0  this   1    +1
📜 Previous results (2)

64002fe

Wed, 12 Aug 2026 17:26:48 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1379 (+36%) 🔻 1538 🔴 (+43%) 🔻 1614 🔴 (+42%) 🔻 1666 🔴 (+2.1%) 30
TTFS stream 287 (+10%) 1488 🔴 (+40%) 🔻 1650 🔴 (+51%) 🔻 1746 🔴 (+43%) 🔻 30
TTFS hook + stream 1625 (+268%) 🔻 1844 🔴 (+33%) 🔻 1867 🔴 (+31%) 🔻 5102 🔴 (+218%) 🔻 30
STSO 1020 steps (inline) 127 (+17%) 🔻 204 (+10%) 248 (+15%) 🔻 527 (+35%) 🔻 1019
WO 1020 steps 208889 (+16%) 🔻 208889 (+16%) 🔻 208889 (+16%) 🔻 208889 (+16%) 🔻 1
SL stream latency 106 (+14%) 145 🔴 (-1.4%) 174 🔴 (+13%) 276 🔴 (+27%) 🔻 30
SO stream overhead (text) 134 (+24%) 🔻 216 (-5.7%) 257 (-10%) 335 (-65%) 💚 30
SO stream overhead (structured) 138 (+9.5%) 225 (-13%) 288 (-4.3%) 526 (-41%) 💚 30

2d1c9ae

Tue, 11 Aug 2026 23:11:38 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1153 (+257%) 🔻 1403 🔴 (+33%) 🔻 1432 🔴 (+28%) 🔻 1460 🔴 (+4.4%) 30
TTFS stream 229 (+44%) 🔻 1232 🔴 (+14%) 1403 🔴 (+29%) 🔻 1475 🔴 (+19%) 🔻 30
TTFS hook + stream 1429 (+7.0%) 1549 🔴 (+6.3%) 1661 🔴 (+11%) 1829 🔴 (+16%) 🔻 30
STSO 1020 steps (inline) 115 (-9.4%) 159 (-16%) 💚 182 (-16%) 💚 303 (-28%) 💚 1019
WO 1020 steps 166107 (-9.9%) 166107 (-9.9%) 166107 (-9.9%) 166107 (-9.9%) 1
SL stream latency 86 (-1.1%) 118 🔴 (-7.1%) 129 🔴 (-5.8%) 190 🔴 (-61%) 💚 30
SO stream overhead (text) 107 (-0.9%) 169 (-16%) 💚 201 (-36%) 💚 277 (-42%) 💚 30
SO stream overhead (structured) 100 (-6.5%) 167 (-5.6%) 247 (+2.9%) 632 (+43%) 🔻 30
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: is main, marks where this run lands, bridges the gap when this run has more samples in a bucket.

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

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.

@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 Mint-ordered log — 6 fail of 41 total

log=mint-ordered · fence=per-spec

scenario outcome events virt replay violations
smoke-no-steps completed 3 0ms ok 0
smoke-one-step completed 6 0ms ok 0
hook-at-step-started completed 12 0ms ok 0
hook-at-step-completed completed 12 0ms ok 0
hook-at-hook-created completed 12 0ms ok 0
deadline-hook-wins completed 7 1.0h ok 0
deadline-expires completed 7 1.0h ok 0
long-sleep completed 11 30.0d ok 0
hook-never-arrives stalled 3 0ms skipped 0
step-retries-twice completed 10 2.0s ok 0
parallel-steps completed 9 0ms ok 0
hook-on-execution-state completed 12 0ms ok 0
peek-hook-before-branch completed 12 0ms ok 0
peek-hook-after-branch completed 12 0ms ok 0
peek-hook-at-registration completed 12 0ms ok 0
race-hook-before-probe completed 12 0ms ok 0
race-hook-after-probe completed 12 0ms ok 0
race-duplicate-delivery completed 13 0ms ok 0
attr-hook-before-step completed 11 0ms ok 0
attr-hook-after-step completed 11 0ms ok 0
attr-from-step-body completed 13 0ms ok 0
fork-hook-after-timeout completed 14 1.0m ok 0
fork-hook-before-timeout completed 14 1.0m ok 0
count-hook-after-timeout completed 17 1.0m ok 0
count-hook-before-timeout completed 20 1.0m ok 0
stale-read-step-count-fork completed 17 1.0m MISMATCH 1
stale-read-equal-step-counts completed 14 1.0m MISMATCH 1
step-vs-step-fork completed 12 0ms MISMATCH 1
step-vs-step-fork-fenced completed 12 0ms MISMATCH 1
fence-catches-benign-direction completed 12 5ms ok 0
in-flight-before-decision completed 17 1.0m MISMATCH 1
in-flight-before-decision-counted completed 20 1.0m ok 0
in-flight-after-decision failed 14 2.0m MISMATCH 1
stale-read-step-count-fork-fenced completed 20 1.0m ok 0
fork-hook-wins completed 13 1.0m ok 0
fork-timeout-wins completed 13 1.0m ok 0
unclaimed-payload-under-fork completed 17 1.0m ok 0
claimed-payload-under-fork completed 17 1.0m ok 0
writers-independent-step-bodies completed 12 0ms ok 0
writers-scripted-tempo completed 12 0ms ok 0
cancel-mid-step cancelled 7 0ms skipped 0

Full trace: world-sim-mint.txt

🟢 Append-only log — 0 fail of 41 total

log=append-only · fence=per-spec

scenario outcome events virt replay violations
smoke-no-steps completed 3 0ms ok 0
smoke-one-step completed 6 0ms ok 0
hook-at-step-started completed 12 0ms ok 0
hook-at-step-completed completed 12 0ms ok 0
hook-at-hook-created completed 12 0ms ok 0
deadline-hook-wins completed 7 1.0h ok 0
deadline-expires completed 7 1.0h ok 0
long-sleep completed 11 30.0d ok 0
hook-never-arrives stalled 3 0ms skipped 0
step-retries-twice completed 10 2.0s ok 0
parallel-steps completed 9 0ms ok 0
hook-on-execution-state completed 12 0ms ok 0
peek-hook-before-branch completed 12 0ms ok 0
peek-hook-after-branch completed 12 0ms ok 0
peek-hook-at-registration completed 12 0ms ok 0
race-hook-before-probe completed 12 0ms ok 0
race-hook-after-probe completed 12 0ms ok 0
race-duplicate-delivery completed 13 0ms ok 0
attr-hook-before-step completed 11 0ms ok 0
attr-hook-after-step completed 11 0ms ok 0
attr-from-step-body completed 13 0ms ok 0
fork-hook-after-timeout completed 14 1.0m ok 0
fork-hook-before-timeout completed 14 1.0m ok 0
count-hook-after-timeout completed 17 1.0m ok 0
count-hook-before-timeout completed 20 1.0m ok 0
stale-read-step-count-fork completed 20 1.0m ok 0
stale-read-equal-step-counts completed 14 1.0m ok 0
step-vs-step-fork completed 12 0ms ok 0
step-vs-step-fork-fenced completed 12 0ms ok 0
fence-catches-benign-direction completed 12 5ms ok 0
in-flight-before-decision completed 17 1.0m ok 0
in-flight-before-decision-counted completed 17 1.0m ok 0
in-flight-after-decision completed 19 2.0m ok 0
stale-read-step-count-fork-fenced completed 20 1.0m ok 0
fork-hook-wins completed 13 1.0m ok 0
fork-timeout-wins completed 13 1.0m ok 0
unclaimed-payload-under-fork completed 17 1.0m ok 0
claimed-payload-under-fork completed 17 1.0m ok 0
writers-independent-step-bodies completed 12 0ms ok 0
writers-scripted-tempo completed 12 0ms ok 0
cancel-mid-step cancelled 7 0ms skipped 0

Full trace: world-sim-append-only.txt

@vercel vercel Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Additional Suggestion:

JSDoc {@link latestEventStateUpdatedAt} on appendUniqueEvents references a function deleted by this PR, leaving a dangling doc reference.

Fix on Vercel

@VaguelySerious VaguelySerious changed the title [core] Report the replay position on every event write; drop WORKFLOW_PRECONDITION_GUARD [core] Report the replay position on every event write Aug 11, 2026

@shalabhc shalabhc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Some non blocking comments below. Overall looks good.

  1. My clanker kept reporting issues with correctness but I think they are solved by the new principle that if a prefix P produces an event E then any prefix P+Q cannot 'retract' E. Is this written down somewhere - it should be (at least my llm didn't see it).

  2. Maybe can move 'hasMore' into the merge call?

    DetailsOne merge primitive, four policies. appendUniqueEvents is the only thing that actually puts events in a log, and the two wrappers over it are justified — a delta extends the tail and carries a cursor, a report is a window strictly below the write and carries none, so one must not re-sort and the other must. What is duplicated is the policy: hasMore !== true is decided independently at runtime.ts:2702, suspension-handler.ts:375 (same three lines) and runtime.ts:950 (plus cursor-unchanged), each re-deriving the reasoning in its own docblock, while step-executor.ts observeSlot discards the report instead. Worth collapsing the three into one absorbReport(log, result) returning the added count. observeSlot could not call it — it holds no log to merge into — which is the asymmetry worth a line of its own, since as written it reads as an oversight rather than a decision.

  1. out of date comments in a few places (possibly)
Details **The id-scheme docblocks have not caught up to slots.** For the record, since the test files ask it too: world-local mints *slots* for every new run and keeps ULIDs only for runs that already had them (`drawEventSlot` — "a run whose visible events are ULIDs stays on ULIDs for life"), and slot ids are deliberately ULID-shaped so existing validators still accept them. Two comments predate that:
  • appendUniqueEvents (:495) says "world-local orders by (createdAt, eventId) and deliberately re-mints keys so that the two diverge". eventSortKey returns the eventId itself for a slot run, so that is now true only of pre-slot runs. Keeping receipt order is still correct, just not for the reason given.
  • insertEventByEventId justifies id-ordering with "Event IDs are ULIDs, so lexicographic string order matches commit order", and applies it with no slot gate — where mergeReportedEvents is careful to check maxEventSlot !== undefined first. On a slot run the conclusion happens to hold (fixed-width decimal sorts lexicographically), so this is wording rather than a bug, and only pre-slot runs are exposed.
  1. may be SimStore can mint slots now to validate in the slot scheme.

### Optional: Rejecting a Stale Write

### Optional: The Event Creation Precondition Guard
A World that allocates positions never needs this: a write that named a stale position still commits, at the next free position, and the skipped events go back on the success response. A World whose event IDs are not positions has no such mechanism, and may fence instead.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

A world can't "fence instead" IIUC - since the field to fence on is gone.

Comment thread packages/core/src/runtime/helpers.ts Outdated
* How much of its run's log a replay-context event creation had loaded when it
* decided to write, as the highest slot that log occupies.
*
* One integer says it because slots are dense: a writer that names slot N is

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

"Slots are dense" is true so I think holes are not possible but isSlotGapCheckEnabled (:717): says "A hole is a position claimed by a write that then failed, so most of them stand for an event that never happened."

I think it's talking about something else (a partially loaded log?) so should probably not call it a hole.

slotSnapshot: { eventCount: scheduledAt },
});

// world-local mints ULIDs, so nothing the executor commits reads back as a

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

world-local mints ULIDs

still true?
or does this mean it mints slots (that look like ULIDs)

@VaguelySerious

Copy link
Copy Markdown
Member Author

@shalabhc Fixed outdated comments

VaguelySerious and others added 4 commits August 12, 2026 12:23
…ONDITION_GUARD

Replay-context `events.create` calls now carry `eventCount` (the number of
events the replay had loaded) on every write derived from a loaded log,
including the terminal writes that previously sent no snapshot at all:
`step_completed`, `step_failed`, the suspension writes, and the
replay-timeout `run_failed`.

The ULID-era snapshot triple (`stateUpdatedAt` / `stateEventCount` /
`stateCursor`) and its `WORKFLOW_PRECONDITION_GUARD` kill switch are gone.
A watermark plus a count of events at or below it cannot distinguish a
complete-but-stale view from a current one, and specVersion 6 replaces it
with a dense position the World allocates at commit time.
…` references a function deleted by this PR, leaving a dangling doc reference.

This commit fixes the issue reported at packages/core/src/runtime/helpers.ts:498

## Bug

The JSDoc for `appendUniqueEvents` in `packages/core/src/runtime/helpers.ts` (line 498) contains:

```ts
* the newest event — see {@link latestEventStateUpdatedAt}.
```

This PR removes the `latestEventStateUpdatedAt` function. A repo-wide grep for `latestEventStateUpdatedAt` returns exactly one match — this `{@link}` — meaning the symbol no longer exists anywhere in the codebase. TSDoc/TypeDoc `{@link}` targets must resolve to a declared symbol, so this is a broken/dangling reference.

## Fix

The reasoning the prose points to ("nothing downstream may assume the tail is the newest event — it's a maximum, not the tail/count") now lives in `maxEventSlot`, which is exported from the same file and confirmed to exist (used in `step-executor.ts` and `suspension-handler.test.ts`). I retargeted the link to `{@link maxEventSlot}` so it resolves again.

Documentation-only change; no runtime impact.


Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
Co-authored-by: VaguelySerious <mittgfu@gmail.com>
Collapse the two skipped-slot merge call sites onto a shared
absorbSkippedSlotReport, state the no-retraction principle the merge
guards rest on, correct the stale hole/density docblock, and fix the
stale-write docs section that told a World with no positions to fence.
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for b589460 (AI decision).

This is an architectural change to the stale-write mechanism, not a defect fix: it makes every replay-context write report a slot position and deletes the stateUpdatedAt/stateEventCount/stateCursor triple plus the WORKFLOW_PRECONDITION_GUARD kill switch that stable still ships. It also builds entirely on main-only slot identity — origin/stable has no eventCount/slotEventIds/maxEventSlot support in packages/world and no packages/world-sim at all — so on stable it would remove the only fence in force and replace it with a position no World there allocates. Removing a documented env var and public CreateEventParams fields is exactly the kind of behavior/API change a maintenance line should not take.

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

b589460ce873bad3ddd7bda4a9bff147ddccac49

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants