Skip to content

feat(world): commit-ordered event positions (event_{seq}) for world-postgres and world-local - #3269

Draft
pranaygp wants to merge 29 commits into
mainfrom
pgp/event-seq
Draft

feat(world): commit-ordered event positions (event_{seq}) for world-postgres and world-local#3269
pranaygp wants to merge 29 commits into
mainfrom
pgp/event-seq

Conversation

@pranaygp

Copy link
Copy Markdown
Contributor

Summary

Implements #3201 (event_{seq}: commit-assigned per-run sequence as canonical log order, with fenced appends) for world-postgres and world-local, plus the SDK reader/fence half, and validates it by running the event-log-race-repro storm locally against both worlds, seq branch vs main (32ac8e7).

Headline: on world-postgres the storm's corruption collapses — hook-storm (the production shape) goes 5 corrupted → 0 corrupted with 24/24 completing, and step-storm goes 20 corrupted / 4 completed → 2 corrupted / 22 completed. On world-local the storage-ordering class is verifiably gone (invariant sweeps below) and hook-storm corruption halves, but the engine wake-order class #3201 explicitly scopes out still dominates there — the in-process queue makes world-local the harshest concurrent-replay amplifier.

This branch combines:

Storm matrix

24 step-storm + 24 hook-storm attempts per condition, concurrency 6, one machine, nextjs-turbopack production server. "slow" = alive and appending but past the harness's 240s per-run cap (reported as stuck), not wedged.

condition world step-storm hook-storm
main local 24 corrupted / 0 completed 18 corrupted / 0 completed / 6 infra
pgp/event-seq local 23 corrupted / 1 slow 9 corrupted / 15 slow
main postgres¹ 20 corrupted / 4 completed 5 corrupted / 11 completed
pgp/event-seq postgres 2 corrupted / 22 completed 0 corrupted / 24 completed ✅

¹ main-postgres landed 40/48 before the launch budget cut it; rates are per-landed.

Storage invariants under storm (seq branch):

  • postgres: 34,722 events — 0 null seq, 0 gaps/duplicates, 0 id-order-vs-seq violations
  • local: 24 runs / 8,590 events — 0 gaps, 0 unpositioned, 0 id-order violations, 0 completions-without-creates

Every corrupted run on the seq branch has a perfectly ordered, dense log: the residual is not a storage problem.

What's new on top of the two merged branches

world-local commit-ordered appends

  • Every append is serialized by a per-run, cross-process on-disk append lock (.locks/runs/{runId}.append.lock, exclusive-create; in-process promise chain in front; stale-break with log rescan).
  • A dense per-run seq and a tail-dominant event key are allocated at the publish point, so (createdAt, eventId) order == filename-ULID order == seq order == commit order == visibility order. Cursor readers can no longer skip a late-committing event.
  • The per-run counter file commits after each publish: a crash can only leave the counter behind the log (healed by the stale-break rescan), never ahead (which would mint a permanent gap).
  • The stateEventCount decision fence is enforced under the same lock, before any side effect.
  • mintRunDominantEventKey (the terminal-event re-mint) is deleted — publish-point allocation under the lock dominates by construction.
  • hook_created crash recovery probes the log by correlationId instead of adopting the crashed writer's stale key (an adopted old key would commit an event below positions readers already passed — the exact hole this design closes).

SDK

  • assertEventSequenceContiguity is re-asserted at the pre-runWorkflow convergence point, covering the two append paths that bypass the paginated loader (inline write-response delta, run_started preload). A World ordering bug now fails fast as WORLD_CONTRACT_ERROR instead of surfacing three replays later as corruption — this gate caught two real implementation bugs during this work, both within minutes.

Fence semantics: fence only decision writes

  • New isDecisionEvent in @workflow/world; both worlds fence/bump decisions only (child-entity creations incl. lazy step_started, terminal transitions, attr_set). Facts (wait_completed, step_completed, hook_received, non-lazy step_started claims) pass unfenced even when the runtime attaches a snapshot: a stale fact is byte-identical to a fresh one and takes its meaning from its commit-assigned position. Fencing facts prevented nothing and converted steady traffic into replay-restart churn (measured: 776 wait_completed + 1,107 step_started rejections in one 48-run storm). This also matches the vercel-world server contract, where wait_completed is guarded but never bumps the marker.

Hard-won implementation gotchas (additions to #3201's list)

  1. On a non-transactional store, the fence must run before ANY side effect. Postgres rolls the entity mutation back with the 412; a filesystem write can't be unwound. Fencing at the publish point stranded orphaned step entities (steps that executed with no step_created ever entering the log — an unreplayable completion) and, worse, a 412'd run_completed left the terminal marker behind, rejecting every later hook resume and wedging the run.
  2. Keep directory-scan reads out of the append lock. Holding the per-run serializer across the run_started preload / inline-delta scans starved writers and tripped the stale-lock breaker on a healthy holder — minting duplicate positions. (The contiguity gate caught this immediately as seq 36 followed by 36.)
  3. Break stale locks late, not early. Breaking a healthy-but-slow holder corrupts (duplicate positions); breaking late merely stalls a crashed run's writers.

The residual class, isolated

With storage ordering proven clean, the remaining CORRUPTED_EVENT_LOG failures have one signature: Replay could not consume event: step_created(...). Forensics on a live capture: two invocations with complete views of the same log derived different ULID-draw assignments (one writer's finalizeStep ordinals at …KWSS, the other's at …KWSX+ — a 4-draw skew) and both flushed, interleaving two derivations into one perfectly-ordered log. The fence cannot reject either writer — neither is stale. This is precisely #3201's "What this does NOT fix": wake-order nondeterminism within a replay, which needs #3179 (call-site correlation ids) and/or Phase D (deliver live resolutions in commit order).

Two observations for that follow-up work:

  • The divergent writer flushed its decisions before its deferred unconsumed-event check fired. A pre-flush assertion that every already-committed decision event in the loaded view was consumed (Temporal's UNHANDLED_COMMAND analogue) would convert the mixture-write into a clean divergence retry.
  • The postgres/local delta in the matrix is an admission-control datum: postgres's queue latency naturally spaces concurrent replays (2/24 corrupted), while world-local's zero-latency in-process queue maximizes overlap (23/24). Reducing concurrent replay rate is worth as much as any fence.

Validation

  • world-local: 501 tests (incl. new event-ordering.test.ts: dense positions under concurrent + cross-instance appends, cursor-skip impossibility, terminal ordering, pre-seq adoption, fence semantics)
  • world-postgres: all tests incl. event-ordering.test.ts
  • core: 1694 passed, 3 pre-existing expected fails
  • world: all tests

Part of #3201.

VaguelySerious and others added 29 commits July 27, 2026 19:04
…process

A replay-context event creation previously described its snapshot with a
single watermark, which only proves no event landed above it. It cannot
detect a *missing* event below it, so a replay working from a log with a
hole still committed events derived from that hole — and because
correlation IDs are positional ordinals of one seeded sequence, a
one-event difference renames every downstream entity and corrupts the log.

Creations now also send the snapshot's event count and its cursor, and a
rejection restarts the replay inside the same invocation instead of
re-posting the rejected payload (whose IDs the corrected log invalidates)
or paying a queue round trip. A world may attach the missing events to
its 412, in which case the first restart needs no event-log request.

Also guards the suspension `attr_set` write, and re-sorts a merged event
log by event ID when an append arrives out of order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Also makes the v4 event tests derive their mock origin from the override
like the rest of the file already does, so a non-empty override does not
fail unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The matching world-vercel guard shipped and is live in production, so the e2e
lanes exercise both halves against the default endpoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resolves the overlap with #3110, which introduced the same event-log merge
consolidation this branch had added as `mergeEvents`: `appendUniqueEvents`
now carries the optional id set from main plus the out-of-order re-sort and
warning, and `mergeEvents` is gone. Main's `withPreconditionRetry` edit drops
out with the function itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The event-log merge no longer re-sorts by event id. A World's canonical
order is its own: world-vercel orders by event id, but world-local orders
by (createdAt, eventId) and deliberately re-mints keys (dominant-event and
claim canonicalization) so the two diverge. Re-sorting by event id there
produced an order no ordered load would ever return, reordering a terminal
event ahead of an accepted hook and breaking concurrent hook-token
arbitration.

The merge was only sorting so the snapshot could read its watermark off the
tail, so read the maximum ULID time across the log instead. That removes
the ordering dependency entirely and is exact rather than merely safe:
every loaded event is at or below the maximum, so stateEventCount is still
events.length whatever order the World returned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…p/autoincrement-event-sort-ids

* origin/peter/event-count-guard:
  Derive the precondition watermark from the log's maximum, not its tail
  Remove the temporary backend URL override
  Temporarily point CI at the backend branch preview
  Gate event creation on the loaded event count and restart replays in-process
…tirement

Four failing tests against the barrier primitives in private.ts:

- the idle safety net (private.ts:461) retires a barrier whose delivery is
  still parked inside awaitEarlierDeliveries, because pendingDeliveries only
  counts the hydration window (released at step.ts:322, before the detached
  continuation at step.ts:324);
- one idle tick retires EVERY live barrier, not just abandoned ones;
- a later-in-log delivery consequently computes an empty deferral set, skips
  the macrotask yield at private.ts:392, and is handed to workflow code before
  an earlier-in-log one;
- the same inversion without the idle net at all: markDelivered() runs one
  statement before resolve(), so the registry stops mentioning a delivery
  while the branch it woke is still hops away from its next useStep.

Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
End-to-end reproduction on current main, from the event log alone — no
artificial hydration latency, no shared ReplayPayloadCache, failing at every
consumer hop count from 0 to 16 with the production error shape:

  Replay divergence: step event step_created for step_<ULID> belongs to
  "afterHook", but the current step consumer is "afterStep"

An unclaimed buffered hook payload makes resolvesOnItsOwn (private.ts:296-319)
report false for itself, and transitively for the armed wait barrier that
defers behind it. A later step result therefore skips BOTH via the
kind === 'step' escape at private.ts:368-371, computes an empty deferral set,
and resolves on microtasks while the wait is still parked — so the step branch
draws the ULID the log assigns to the hook branch.

Verified mechanism: neutralising that skip makes all six cases pass (and keeps
the 72 existing delivery-ordering assertions green), so the skip is the cause.
No existing suite covers this because every hook case in
step-delivery-ordering / step-delivery-hop-count / delivery-barrier-coverage
registers its awaiter before the drain, taking the armed path in hook.ts
rather than claim().

Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
…ivery traffic

Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
The 12 tests that fail on main are right-reason failures documenting the
delivery-barrier idle collapse/starvation windows and the buffered-hook
claim-ordering race. Mark them it.fails so the suites merge as executable
documentation (the #3137 -> #3139 convention); the fix PR flips the
markers back to it. The 4 controls that pass on main keep plain it.

Also adds an empty changeset (test-only change).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
…le writers

Events gain an optional dense per-run seq (EventSchema.seq) assigned at the
commit point. world-postgres serializes every append behind a run-scoped
advisory lock taken as the first lock of a single transaction that spans the
entity mutation and the event insert; event ids are re-minted inside that
critical section to dominate the run's tail, so seq order == id order ==
commit order == visibility order and a cursor reader can never skip a
late-committing event (the CORRUPTED_EVENT_LOG storage class).

Creates that carry the precondition snapshot (stateEventCount/stateCursor,
from the merged event-count-guard SDK machinery) are checked against a
transactional currency fence: a snapshot the log has moved past is rejected
with 412 and the whole transaction — entity mutation included — rolls back,
so a replay derived from a superseded view can never commit a decision.
Sibling creates of one suspension share a snapshot and are credited through
(writerSnapshot/writerBaseCount on the run row) instead of fencing each
other. The runtime asserts seq contiguity on every event load so any
remaining hole fails loudly instead of surfacing as a replay divergence
hundreds of events later.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The tail-based currency fence livelocked runs under steady inbound load:
every hook_received landing between a replay's load and its next write
412'd the write and forced a full replay restart, and the facts kept
coming (measured ~41 restarts/run in the step-storm repro; every attempt
timed out as stuck while zero corrupted).

Mirror Temporal's buffered-events model instead: facts (creates without a
precondition snapshot) get a commit-ordered position but never invalidate
a decision; only a foreign *decision* — a snapshot-carrying create from a
different snapshot — fences. The run row tracks lastFencedSeq (the last
decision's position); a fenced create is rejected iff a foreign decision
sits past its snapshot and the sibling credit doesn't match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Storm forensics (fence_count audit column) attributed every corrupted run
to a credit hole, not engine nondeterminism: two invocations that loaded
the identical prefix present byte-identical stateCursor+stateEventCount,
so the snapshot-keyed credit admitted both writers' decision batches and
their interleaved sets baked an order no replay derives (correlation
ordinals inverted against commit order at seqs the writers' fence_counts
prove they never saw).

The runtime now mints a random writerId per invocation delivery and sends
it with every precondition snapshot; the world's credit compares writerId
(falling back to stateCursor only for runtimes that don't send one). A
second writer with the same snapshot 412s and restarts against the
corrected log. Also adds the fence_count forensics column and a
WORKFLOW_POSTGRES_EVENT_FENCE=tail|decision experiment switch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three defects in the delivery-barrier registry could hand branch-deciding
deliveries to workflow code out of event-log order, surfacing as
CORRUPTED_EVENT_LOG on replay. All three are fixed here, and the twelve
expected-fail repros that documented them are flipped back to plain `it`.

Barriers no longer retire on a global idle tick. #3198 stopped the idle CHECK
from observing idle while a committed delivery is parked; this stops the same
predicate from retiring the barriers themselves. `pendingDeliveries` tracks
only the host-side hydration window — released inside the promiseQueue slot,
before the detached continuation that hands the value over, and never touched
at all by a wait_completed — so one idle tick used to retire every live
barrier while their deliveries were still parked in awaitEarlierDeliveries.
Retirement moves to its own poll, which retires only an UNARMED barrier: an
unclaimed buffered hook payload, the one delivery that can be abandoned at the
root. Every other kind is committed and retires from its own chain.

Both polls get a deadline. Without one neither has any escape from continuous
unrelated traffic: an abandoned barrier under a stream of deliveries to a
never-read hook starved indefinitely, observed live as a run making no
progress for 3m19s across 228 pokes. The barrier deadline is counted in raw
ticks, so the traffic keeping the system busy cannot stretch it;
scheduleWhenIdle's is counted in poll rounds, each of which waits out a full
promiseQueue drain, because that function also schedules suspensions, where
firing early preempts data delivery.

A retired delivery stays visible to the registry for one more macrotask.
markDelivered() resolved a barrier one statement before the resolve() that
wakes the branch, so anything reading the registry in between — a delivery
consumed in a later drain window, or a buffered payload's claim() — computed
an empty deferral set and overtook the branch it was meant to follow. The live
registry still drops the entry immediately; a second short-lived map carries
ordering visibility across the gap.

A step result's buffered-hook skip is narrowed to the payload itself. A step
still never gates on an unclaimed payload, where the claim commonly sits
downstream of the step result, but it now gates on an earlier armed wait or
hook whatever that delivery is itself waiting for. Skipping those as well, via
the transitive resolvesOnItsOwn walk, was the buffered-claim corruption: in
Promise.all([step, sleep-then-read-hook]) the wait completion is precisely what
wakes the branch that goes on to claim the payload. awaitEarlierDeliveries no
longer consults that walk, leaving the per-delivery path a single linear pass;
the walk itself survives only in hasParkedCommittedDelivery, which gates
suspensions rather than deliveries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
…nt-sort-ids

* origin/main:
  [benchmarks] Split STSO by inline vs queue-hop steps, add distribution diffs vs main (#3213)
  feat(core): emit faas.instance span attribute for compute instance identity (#2989)
  [world-local] Retry transient EPERM unlink failures on Windows (#3215)
  [world-vercel] Raise H2 receive windows on the events agent (#3212)
  Version Packages (beta) (#3185)
  Align Streams UI with trace viewer (#3197)
  fix(core): don't observe idle while a committed delivery is parked behind its deferral (#3198)
  [world-vercel] Make HTTP/2 actually multiplex on the events path (#3190)
  [RFC] feat(nitro): embed observability dashboard in-process at /_workflow (#2548)
…rked deliveries as in-flight

Follow-up to the previous commit, from review of #3196 and from an e2e
regression that review work exposed.

Retirement by the safety net is now UNARMED-only. The abandon deadline used to
fire for any barrier, which reintroduced the collapse at a longer timescale:
production hydration (object-storage fetch plus decrypt) runs 10-500ms, far
past any tick budget worth setting, so the slowest deliveries would have been
exactly the ones force-retired mid-flight. Only an unclaimed buffered hook
payload — the one delivery that can be abandoned at the root — is retirable by
the net; every other barrier leaves the registry from its own delivery chain.
All five call sites were audited against that invariant: step_completed,
step_failed, wait_completed, the waiting-consumer and claim() hook paths, and
the abort hook all attach their chain unconditionally.

Deliveries parked in awaitEarlierDeliveries are now counted, and the count
gates scheduleWhenIdle. A delivery whose only remaining gate is a
recentlyDeliveredBarriers entry is invisible to both pendingDeliveries (already
released in its hydration slot) and to a scan of the live registry (already
empty), so a suspension armed in that window preempted it and the run suspended
carrying none of the work the delivery was about to create. That is how the
second payload of the hookWithSleepWorkflow e2e went missing. Guarded by a unit
test that fails without the counter.

Review fixes:

- Starvation suite: the poke storm held pendingDeliveries above zero with a
  single unmatched increment, so it modelled a stuck hydration rather than
  overlapping traffic. It now overlaps genuinely, leaks nothing, and asserts
  both properties. The header no longer attributes the observed 3m19s stall to
  this mechanism, which the suite does not establish.
- Added the liveness guard asked for in review: 228 unclaimed payloads gating a
  later delivery still drain in a handful of ticks.
- Added the mirrored-log control asked for in review: the interleaving where
  the step result legitimately precedes the wait completion must keep replaying
  cleanly, so the ordering assertions cannot be satisfied by flipping the bias.
- Below-watermark suite: the read shape is not a world-postgres class.
  world-vercel paginates DynamoDB by event ULID with an `eid:` cursor over a
  strictly-after range, and workflow-server documents its IDs as monotonic only
  intra-instance. Recorded in the header; the second test is renamed to say it
  characterizes current behavior rather than pinning a contract.
- Call-site comments that documented idle retirement of parked barriers as a
  deliberate tradeoff now describe what the net actually does.
- Stale private.ts / step.ts line references replaced with symbol names.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
…imed

An unarmed delivery barrier is retired on the inference that no consumer
has claimed the payload. That inference is not available until a consumer
could have: `claim()` cannot hand anything over until the payload's own
hydration resolves, so while it is still hydrating, "nobody has claimed
it" carries no information.

Left unguarded, the two retirement conditions conspire during that window.
The payload's own hydration holds `pendingDeliveries` above zero, so the
idle route cannot fire, and the abandon deadline retires the barrier
because its hydration was slow. Against a production hydration (object
storage fetch plus decrypt, 10-500ms) and a deadline of a few dozen
`setTimeout(0)` ticks, that is the common case for a buffered payload
rather than an edge one.

`registerDeliveryBarrier` now takes `abandonableAfter` and does not start
the retirement poll until it settles; `workflow/hook.ts` passes the
buffered payload's own hydration. The waiting-consumer path is unaffected:
it registers armed, and an armed barrier leaves the poll on its first
check either way.

This is the same slow-hydration trap b58fdcf had on the armed side,
found by re-auditing the deadline against production latency after that
commit's storm run. The regression test holds hydration open well past the
deadline and asserts both that the barrier survives and that a later step
result still orders behind the claim.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Counted flat, `IDLE_POLL_DEADLINE_ROUNDS` is not a deadline but a cap on
how many events one drain window may deliver.

Delivering a window in log order costs one macrotask per deferring
delivery, so a window of K events needs K poll rounds to clear. Past the
budget `scheduleWhenIdle` fires anyway, into the middle of the batch — and
for a hook consumer that callback raises a `WorkflowSuspension`, so the run
suspends carrying none of the work the remaining deliveries were about to
create. A new test in `delivery-barrier-idle-starvation.test.ts` puts 40
deliveries in one window and shows the callback landing after exactly 16 of
them.

The budget now restarts whenever a delivery reaches workflow code, tracked
as `deliveryProgress` and incremented by `markDelivered()` only. Excluding
the abandonment safety net's retirements is what keeps the escape hatch
working: a stream of pokes to a hook nobody reads retires barrier after
barrier and delivers nothing, so the budget still runs out and the
suspension still fires. That is the 3m19s stall the deadline was added for,
and its test still passes.

Ordering machinery that slows delivery down is only safe if the liveness
timers around it measure progress rather than elapsed rounds; this is the
one that did not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
* pgp/autoincrement-event-sort-ids:
  Key the fence's sibling credit on a per-invocation writerId
  Fence decisions on foreign decisions, not on facts
  Assign commit-ordered event positions in world-postgres and fence stale writers
  Derive the precondition watermark from the log's maximum, not its tail
  Remove the temporary backend URL override
  Temporarily point CI at the backend branch preview
  Gate event creation on the loaded event count and restart replays in-process

# Conflicts:
#	packages/core/src/runtime/helpers.test.ts
#	packages/core/src/runtime/step-executor.ts
#	packages/world-vercel/src/events.ts
…sts' into pgp/event-seq

* origin/pgp/replay-engine-determinism-tests:
  fix(core): reset the idle poll's budget on delivery progress
  fix(core): do not start the abandon clock before a payload can be claimed
  fix(core): never retire a committed delivery on a timer, and count parked deliveries as in-flight
  fix(core): make delivery-barrier retirement deterministic
  test(core): mark known-failing determinism repros as expected failures
  test(core): reproduce delivery-barrier starvation under unrelated delivery traffic
  test(core): characterize divergence from a below-watermark event
  test(core): reproduce ReplayDivergenceError from a buffered hook payload
  test(core): reproduce delivery-barrier idle collapse and premature retirement
…s append lock

Assign every event a dense per-run seq and a tail-dominant event key at
its publish point, under an on-disk per-run append serializer (the
filesystem analogue of world-postgres's advisory transaction lock), and
enforce the stateEventCount decision fence under the same lock. Extend
the SDK's contiguity assertion to the pre-replay convergence point so
the inline write-response delta and run_started preload are covered too.

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

A fenced create whose snapshot is stale was rejected at the publish
point — after entity writes, claim files, and the terminal marker had
already landed. Unlike postgres, those writes cannot roll back: a
rejected step_created stranded a step entity whose step executes with
no step_created in the log (an unreplayable completion), and a rejected
run_completed left the terminal marker behind, rejecting every later
hook resume. Admit or reject the session while the append is still a
pure no-op.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The run_started preload and the step-terminal inline delta are
events-directory scans whose latency grows with the directory; holding
the per-run append serializer across them starved the run's other
writers and, under load, tripped the stale-lock breaker on a healthy
holder — minting duplicate positions. Attach both read pages after the
lock releases (their point-in-time semantics are unchanged), and err
the stale threshold far to the late side: breaking early corrupts,
breaking late merely stalls a crashed run's writers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add isDecisionEvent and gate the stateEventCount fence on it in
world-local and world-postgres: facts (completions, receipts, non-lazy
step_started claims) pass unfenced and never bump the fence even when
the runtime attaches a snapshot. A stale fact is byte-identical to a
fresh one and takes its meaning from its commit-assigned log position,
so fencing it prevents nothing and converts steady traffic into
replay-restart churn (measured: 776 wait_completed + 1107 step_started
rejections in one 48-run storm).

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

vercel Bot commented Jul 31, 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 Jul 31, 2026 9:25pm
example-nextjs-workflow-webpack Ready Ready Preview Jul 31, 2026 9:25pm
example-workflow Ready Ready Preview Jul 31, 2026 9:25pm
workbench-astro-workflow Ready Ready Preview Jul 31, 2026 9:25pm
workbench-express-workflow Ready Ready Preview Jul 31, 2026 9:25pm
workbench-fastify-workflow Ready Ready Preview Jul 31, 2026 9:25pm
workbench-hono-workflow Ready Ready Preview Jul 31, 2026 9:25pm
workbench-nestjs-workflow Ready Ready Preview Jul 31, 2026 9:25pm
workbench-nitro-workflow Ready Ready Preview Jul 31, 2026 9:25pm
workbench-nuxt-workflow Ready Ready Preview Jul 31, 2026 9:25pm
workbench-sveltekit-workflow Ready Ready Preview Jul 31, 2026 9:25pm
workbench-tanstack-start-workflow Ready Ready Preview Jul 31, 2026 9:25pm
workbench-vite-workflow Ready Ready Preview Jul 31, 2026 9:25pm
workflow-swc-playground Ready Ready Preview Jul 31, 2026 9:25pm
workflow-tarballs Ready Ready Preview Jul 31, 2026 9:25pm
workflow-web Ready Ready Preview Jul 31, 2026 9:25pm

@changeset-bot

changeset-bot Bot commented Jul 31, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 94943d2

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

This PR includes changesets to release 21 packages
Name Type
@workflow/world Minor
@workflow/world-postgres Minor
@workflow/core Minor
@workflow/web-shared Patch
workflow Minor
@workflow/world-vercel Minor
@workflow/errors Minor
@workflow/world-local Minor
@workflow/cli Patch
@workflow/vitest Patch
@workflow/web Patch
@workflow/world-testing Patch
@workflow/builders Patch
@workflow/next Patch
@workflow/nitro 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

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