Repository navigation
[world-sim] Start Hook crash scenario book - #5013
alangenfeld wants to merge 26 commits into
Conversation
Adds the start({ hook }) wire contract: StartHookSchema on run_created,
resilient run_started and the queued runInput; the atomicStartHook
capability; the run.startHook echo; WorkflowStartError and
START_HOOK_ADMISSION_REJECTED; and world-vercel's startHook frame meta,
409 start-hook-conflict / 400 start-hook-* error mapping, and the
WORKFLOW_VERCEL_EXPERIMENTAL_START_HOOK opt-in.
Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
…es TBD Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
Adds a shared atomic-start-hook conformance suite and implements it in world-local (reservation on the token claim file, run created under its lock, refusals recorded per candidate) and world-postgres (decision and run in one transaction, serialized per token with an advisory lock shared with createHook; migration 0028). Both let the owner's createHook adopt the reservation and move it with a force-claimed Hook. The run detail panel shows the start Hook token. Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
start() publishes the candidate first and then decides it with run_created; the queued copy's run_started carries the same startHook, skips turbo, and acks a refusal (HookConflictError or START_HOOK_ADMISSION_REJECTED) without running code. Unconfirmed queue or admission throws WorkflowStartError. Stream arguments pipe only once the candidate is admitted. Cross-deployment starts check the target's probed capabilities (health checks now carry them, parsed tolerantly), keeping the no-probe-channel guard. run_started now forwards encryptionPublicKey. WorkflowStartError round-trips through the host, VM and QuickJS serdes; re-runs never forward a source run's start Hook. Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
Replaces the idempotency callout promising native atomic start with the
start({ hook }) API, a start-or-route-to-owner pattern, WorkflowStartError,
the World contract, and WORKFLOW_VERCEL_EXPERIMENTAL_START_HOOK.
Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
…ntion Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
…nsitions and re-adoption An adopted Hook's retention now extends the reservation itself, so disposing it and adopting again with a shorter one cannot shorten the promise. world-local also carried run.startHook through none of its field-by-field run rewrites, so a run_created retry after run_started found the run 'without this start Hook'. Both are now conformance cases. Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
… reviver fixture Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
A by-token lookup that lands on a start reservation its run has not adopted answers HookReservedError (425 hook-reserved on the wire), retryable and never a HookNotFoundError. startHook carries optional serialized metadata the adopting Hook inherits when it brings none. WorkflowStartError is documented and marked retryable. Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
…n and carry start Hook metadata Lookups of an unadopted reservation of a live run throw HookReservedError, and not found once the run ends without adopting it. Adoption inherits the reservation's metadata unless the Hook brings its own. Postgres tracks adoption on the admission row (migration 0028), and its timezone test counts the new table. Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
…retry rules hook.metadata is serialized like createHook metadata. Without an explicit experimental_minRetention a start Hook keeps its token for 24 hours from start() where the World supports Hook retention (0 opts out). Inside a workflow a refusal, permanent rejection or contract error is final for the start() step, while WorkflowStartError stays retryable. HookReservedError round-trips through every serde. Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
…t retention Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
…ugh an interrupted adoption A lookup between the adopting createHook's claim write and its hook_created publish answered not found, so callers retrying on HookReservedError gave up; it now stays HookReservedError while the owner runs. A retried adoption whose first attempt died before publishing now still inherits the reservation's metadata. The e2e routing test now actually passes metadata to start(). Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
…s lost A lookup that finds the claim naming the adopting Hook but no entity now rebuilds the Hook from its published hook_created before answering HookReservedError, so a crash between the publish and the entity write does not leave the token reserved forever. Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
Points world-vercel at the server branch alias and advertises
atomicStartHook unconditionally, so the Vercel e2e lanes run the
start({ hook }) tests against the server's admission. Reverted after
pairing.
Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
…created lands Holds the direct run_created back so the queued copy's run_started admits the candidate through the resilient start against the real backend, then checks a duplicate is refused naming it, the late direct write replays the decision, and the log has one run_created. Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
Pairing passed: the start({ hook }) e2e tests ran and passed on the
node and quickjs Vercel lanes against the server preview.
Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
The conformance suite takes an injectable clock and an optional orphan stager: past the 24h bound a candidate with no run is refused with START_HOOK_ADMISSION_REJECTED through either copy (run_created now uses that code instead of the bare timestamp error), and a staged orphan stops holding its token. world-local stages the orphan its claim-then-write leaves, and a fault-injected crash between the two recovers; world-postgres shows a failure mid-transaction leaves no reservation. Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
…lease plus margin A writer stalled past the 30s token lock lease could still write its run beside a contender that took the token, so a reservation whose run was never written now holds for START_HOOK_ORPHAN_GRACE_MS (twice the lock's stale timeout, from the same constant) before a contender may release it, and lookups answer HookReservedError meanwhile. Claims record reservedAt. The conformance suite takes a per-World orphan hold and checks the token is held just inside it and released just past it; the crash test covers a contender on both sides of the grace. Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
…ause Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
🧪 E2E Test Results✅ All tests passed
|
| Passed | Failed | Skipped | Total | |
|---|---|---|---|---|
| ✅ ▲ Vercel Production | 3982 | 0 | 986 | 4968 |
| ✅ 💻 Local Development | 4785 | 0 | 551 | 5336 |
| ✅ 📦 Local Production | 4785 | 0 | 551 | 5336 |
| ✅ 🐘 Local Postgres | 4785 | 0 | 551 | 5336 |
| ✅ 🪟 Windows | 356 | 0 | 12 | 368 |
| ✅ 🌐 Cross-language Conformance | 68 | 0 | 91 | 159 |
| ✅ dynamic-runs | 0 | 0 | 0 | 0 |
| ✅ vercel-http-transport | 897 | 0 | 207 | 1104 |
| ✅ vercel-multi-region | 27 | 0 | 0 | 27 |
| ✅ vercel-ws-transport | 607 | 0 | 129 | 736 |
| Total | 20292 | 0 | 3078 | 23370 |
Details by Category
✅ ▲ Vercel Production
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ astro-node | 145 | 0 | 39 |
| ✅ astro-quickjs | 145 | 0 | 39 |
| ✅ example-node | 145 | 0 | 39 |
| ✅ example-quickjs | 145 | 0 | 39 |
| ✅ express-node | 145 | 0 | 39 |
| ✅ express-quickjs | 145 | 0 | 39 |
| ✅ fastify-node | 145 | 0 | 39 |
| ✅ fastify-quickjs | 145 | 0 | 39 |
| ✅ hono-node | 145 | 0 | 39 |
| ✅ hono-quickjs | 145 | 0 | 39 |
| ✅ nest-node | 145 | 0 | 39 |
| ✅ nest-quickjs | 145 | 0 | 39 |
| ✅ nextjs-turbopack-node | 172 | 0 | 12 |
| ✅ nextjs-turbopack-quickjs | 172 | 0 | 12 |
| ✅ nextjs-webpack-node | 172 | 0 | 12 |
| ✅ nextjs-webpack-quickjs | 172 | 0 | 12 |
| ✅ nitro-node | 145 | 0 | 39 |
| ✅ nitro-quickjs | 145 | 0 | 39 |
| ✅ nuxt-node | 145 | 0 | 39 |
| ✅ nuxt-quickjs | 145 | 0 | 39 |
| ✅ python-node | 66 | 0 | 118 |
| ✅ sveltekit-node | 164 | 0 | 20 |
| ✅ sveltekit-quickjs | 164 | 0 | 20 |
| ✅ tanstack-start-node | 145 | 0 | 39 |
| ✅ tanstack-start-quickjs | 145 | 0 | 39 |
| ✅ vite-node | 145 | 0 | 39 |
| ✅ vite-quickjs | 145 | 0 | 39 |
✅ 💻 Local Development
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ astro-stable-node | 155 | 0 | 29 |
| ✅ astro-stable-quickjs | 155 | 0 | 29 |
| ✅ express-stable-node | 155 | 0 | 29 |
| ✅ express-stable-quickjs | 155 | 0 | 29 |
| ✅ fastify-stable-node | 155 | 0 | 29 |
| ✅ fastify-stable-quickjs | 155 | 0 | 29 |
| ✅ hono-stable-node | 155 | 0 | 29 |
| ✅ hono-stable-quickjs | 155 | 0 | 29 |
| ✅ nest-stable-node | 155 | 0 | 29 |
| ✅ nest-stable-quickjs | 155 | 0 | 29 |
| ✅ nextjs-turbopack-canary-node | 183 | 0 | 1 |
| ✅ nextjs-turbopack-canary-quickjs | 183 | 0 | 1 |
| ✅ nextjs-turbopack-quickjs-snapshot | 183 | 0 | 1 |
| ✅ nextjs-turbopack-stable-node | 183 | 0 | 1 |
| ✅ nextjs-turbopack-stable-quickjs | 183 | 0 | 1 |
| ✅ nextjs-webpack-canary-node | 183 | 0 | 1 |
| ✅ nextjs-webpack-canary-quickjs | 183 | 0 | 1 |
| ✅ nextjs-webpack-stable-node | 183 | 0 | 1 |
| ✅ nextjs-webpack-stable-quickjs | 183 | 0 | 1 |
| ✅ nitro-stable-node | 155 | 0 | 29 |
| ✅ nitro-stable-quickjs | 155 | 0 | 29 |
| ✅ nuxt-stable-node | 155 | 0 | 29 |
| ✅ nuxt-stable-quickjs | 155 | 0 | 29 |
| ✅ sveltekit-stable-node | 174 | 0 | 10 |
| ✅ sveltekit-stable-quickjs | 174 | 0 | 10 |
| ✅ tanstack-start-node | 155 | 0 | 29 |
| ✅ tanstack-start-quickjs | 155 | 0 | 29 |
| ✅ vite-stable-node | 155 | 0 | 29 |
| ✅ vite-stable-quickjs | 155 | 0 | 29 |
✅ 📦 Local Production
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ astro-stable-node | 155 | 0 | 29 |
| ✅ astro-stable-quickjs | 155 | 0 | 29 |
| ✅ express-stable-node | 155 | 0 | 29 |
| ✅ express-stable-quickjs | 155 | 0 | 29 |
| ✅ fastify-stable-node | 155 | 0 | 29 |
| ✅ fastify-stable-quickjs | 155 | 0 | 29 |
| ✅ hono-stable-node | 155 | 0 | 29 |
| ✅ hono-stable-quickjs | 155 | 0 | 29 |
| ✅ nest-stable-node | 155 | 0 | 29 |
| ✅ nest-stable-quickjs | 155 | 0 | 29 |
| ✅ nextjs-turbopack-canary-node | 183 | 0 | 1 |
| ✅ nextjs-turbopack-canary-quickjs | 183 | 0 | 1 |
| ✅ nextjs-turbopack-quickjs-snapshot | 183 | 0 | 1 |
| ✅ nextjs-turbopack-stable-node | 183 | 0 | 1 |
| ✅ nextjs-turbopack-stable-quickjs | 183 | 0 | 1 |
| ✅ nextjs-webpack-canary-node | 183 | 0 | 1 |
| ✅ nextjs-webpack-canary-quickjs | 183 | 0 | 1 |
| ✅ nextjs-webpack-stable-node | 183 | 0 | 1 |
| ✅ nextjs-webpack-stable-quickjs | 183 | 0 | 1 |
| ✅ nitro-stable-node | 155 | 0 | 29 |
| ✅ nitro-stable-quickjs | 155 | 0 | 29 |
| ✅ nuxt-stable-node | 155 | 0 | 29 |
| ✅ nuxt-stable-quickjs | 155 | 0 | 29 |
| ✅ sveltekit-stable-node | 174 | 0 | 10 |
| ✅ sveltekit-stable-quickjs | 174 | 0 | 10 |
| ✅ tanstack-start-node | 155 | 0 | 29 |
| ✅ tanstack-start-quickjs | 155 | 0 | 29 |
| ✅ vite-stable-node | 155 | 0 | 29 |
| ✅ vite-stable-quickjs | 155 | 0 | 29 |
✅ 🐘 Local Postgres
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ astro-stable-node | 155 | 0 | 29 |
| ✅ astro-stable-quickjs | 155 | 0 | 29 |
| ✅ express-stable-node | 155 | 0 | 29 |
| ✅ express-stable-quickjs | 155 | 0 | 29 |
| ✅ fastify-stable-node | 155 | 0 | 29 |
| ✅ fastify-stable-quickjs | 155 | 0 | 29 |
| ✅ hono-stable-node | 155 | 0 | 29 |
| ✅ hono-stable-quickjs | 155 | 0 | 29 |
| ✅ nest-stable-node | 155 | 0 | 29 |
| ✅ nest-stable-quickjs | 155 | 0 | 29 |
| ✅ nextjs-turbopack-canary-node | 183 | 0 | 1 |
| ✅ nextjs-turbopack-canary-quickjs | 183 | 0 | 1 |
| ✅ nextjs-turbopack-quickjs-snapshot | 183 | 0 | 1 |
| ✅ nextjs-turbopack-stable-node | 183 | 0 | 1 |
| ✅ nextjs-turbopack-stable-quickjs | 183 | 0 | 1 |
| ✅ nextjs-webpack-canary-node | 183 | 0 | 1 |
| ✅ nextjs-webpack-canary-quickjs | 183 | 0 | 1 |
| ✅ nextjs-webpack-stable-node | 183 | 0 | 1 |
| ✅ nextjs-webpack-stable-quickjs | 183 | 0 | 1 |
| ✅ nitro-stable-node | 155 | 0 | 29 |
| ✅ nitro-stable-quickjs | 155 | 0 | 29 |
| ✅ nuxt-stable-node | 155 | 0 | 29 |
| ✅ nuxt-stable-quickjs | 155 | 0 | 29 |
| ✅ sveltekit-stable-node | 174 | 0 | 10 |
| ✅ sveltekit-stable-quickjs | 174 | 0 | 10 |
| ✅ tanstack-start-node | 155 | 0 | 29 |
| ✅ tanstack-start-quickjs | 155 | 0 | 29 |
| ✅ vite-stable-node | 155 | 0 | 29 |
| ✅ vite-stable-quickjs | 155 | 0 | 29 |
✅ 🪟 Windows
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ nextjs-turbopack-node | 178 | 0 | 6 |
| ✅ nextjs-turbopack-quickjs | 178 | 0 | 6 |
✅ 🌐 Cross-language Conformance
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ python | 68 | 0 | 91 |
✅ dynamic-runs
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ astro-vercel | 0 | 0 | 0 |
| ✅ example-vercel | 0 | 0 | 0 |
| ✅ express-vercel | 0 | 0 | 0 |
| ✅ fastify-vercel | 0 | 0 | 0 |
| ✅ hono-vercel | 0 | 0 | 0 |
| ✅ nest-vercel | 0 | 0 | 0 |
| ✅ nextjs-turbopack-vercel | 0 | 0 | 0 |
| ✅ nextjs-webpack-vercel | 0 | 0 | 0 |
| ✅ nitro-vercel | 0 | 0 | 0 |
| ✅ nuxt-vercel | 0 | 0 | 0 |
| ✅ sveltekit-vercel | 0 | 0 | 0 |
| ✅ tanstack-start-vercel | 0 | 0 | 0 |
| ✅ vite-vercel | 0 | 0 | 0 |
✅ vercel-http-transport
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ example | 145 | 0 | 39 |
| ✅ express | 145 | 0 | 39 |
| ✅ hono | 145 | 0 | 39 |
| ✅ nextjs-turbopack | 172 | 0 | 12 |
| ✅ nitro | 145 | 0 | 39 |
| ✅ vite | 145 | 0 | 39 |
✅ vercel-multi-region
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ nextjs-turbopack | 27 | 0 | 0 |
✅ vercel-ws-transport
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ example | 145 | 0 | 39 |
| ✅ express | 145 | 0 | 39 |
| ✅ nextjs-turbopack | 172 | 0 | 12 |
| ✅ vite | 145 | 0 | 39 |
|
Sim WorldSimulated world deterministic testing for races. Traces 🟢 world-sim scenario book — 0 fail of 65 total
Full trace: |
A refused start({ hook }) looks up the run its HookConflictError names.
If that run is still pending more than a minute after its creation (its
first message most likely lost, so nothing would ever start it), it
re-publishes the run's plain orchestrator message to the run's own
deployment, namespace and spec version, under an idempotency key
bucketed per minute so concurrent refusals coalesce. Best effort: the
conflict is still thrown, and a holder that is not a run (an orphaned
candidate) publishes nothing. Documents both states.
Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
…k the start Hook properties The store keeps the atomic start Hook contract in its cross-region shape (decision, then run, with a checkpoint between them), passes @workflow/world's conformance suite, and adds force claims and Hook retention. Faults kill a delivery or a script caller at any call point, lose a response, or stop a write between its halves. Scenarios can start their own clients, and every run is replayed and checked. Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
Twenty-two scenarios kill or stall a client, a delivery, or the store at each point between start()'s enqueue and the token's release: queued copy first, start() dying after its enqueue, a lost run_created response, concurrent candidates, refused candidates under turbo and redelivery, in-step starts, adoption crashes, Eve's force-claim handoff, orphans, and retention. Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
The holder is admitted and its message dropped. A duplicate refused inside a minute wakes nothing; two refused past it re-publish one message between them, the holder runs, and the token is free once its retention passes. Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
Two attempts for one accepted candidate could both pass the post-checkpoint check before either created the run, so the run is now re-confirmed in the turn that creates it. A call held while its delivery died no longer resumes. The owner check reads a run's end in commit order rather than by timestamp, and a retryable World error no longer counts as a start's final answer. Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
ccd00aa to
63a0514
Compare
c59fc83 to
cc849a0
Compare
Summary & Motivation
Follow-up to #2376. The atomic start Hook design was model-checked only for the server's storage logic. Nothing crash-tested the SDK runtime end to end: real
start(),/flowdeliveries, turbo, step retries, replay and user code, with thestart()caller, a flow delivery or a writer dying mid-admission. This PR is based on #4996, so the diff contains only the sim work.@workflow/world's atomic start Hook conformance suite.startedBy: 'script', a scenario starts as many clients as it likes against one token, and every run the World created is replayed and checked.starthook.*rules read every run's log, the store's decisions, the script's start requests and the effects that step bodies recorded.@workflow/core: a delivery that dies shares a Node process with its redelivery, so the sim clears the dead run's entries from core's step single-flight registry (theglobalSingletonnamed@workflow/core//stepSingleFlight, shape version 1; seeforgetProcessMemoryinpackages/world-sim/src/drive.ts). Renaming that singleton or changing its shape makesstart-hook-in-step-start-step-dieshang until its deadline, so update the sim alongside it.start-hook-*scenarios kill or stall a client, a delivery or the store at each point betweenstart()'s enqueue and the token's release.start-hook-lost-start-messagecovers the refused start's wake of a holder stuck pending: a duplicate inside a minute wakes nothing, and two past it re-publish one message between them.Test Plan
starthook.no-loser-execution.starthook.refusal-not-acked.