feat(core): user-registerable workflow lifecycle hooks (registerLifecycleHooks) - #3678
feat(core): user-registerable workflow lifecycle hooks (registerLifecycleHooks)#3678TooTallNate wants to merge 5 commits into
Conversation
Adds registerLifecycleHooks (exported from workflow/api) so apps can observe run terminal transitions from one central place — e.g. report every failed run to Sentry from instrumentation.ts — without wrapping each workflow body. - onRunCompleted/onRunFailed handlers receive the lazily-hydrated Run instance; onRunFailed additionally receives a WorkflowRunFailedError whose errorCode carries the classification and whose cause is the hydrated thrown value (round-tripped through the run-error serialization pipeline, so VM-realm throws surface as host-realm Errors with class identity preserved — same shape run.returnValue rejects with). - Registry lives on globalThis under Symbol.for so every bundled copy of @workflow/core shares one list; multiple registrations allowed, handlers run in registration order, and registration returns an unregister function. - Dispatch is fire-and-forget via safeWaitUntil: handlers can't delay or change the run outcome, failures are logged and swallowed, and serverless invocations stay alive while handlers finish. - Wired into every terminal writer that lands a run_completed or run_failed event: the happy-path completion, the terminal catch, the suspension-commit failure, recordFatalRunError, the max-deliveries gate, replay-budget exhaustion, the deployment guard, and both QuickJS entrypoint writers — and only on the invocation whose write actually succeeded (never on EntityConflict/RunExpired). - e2e coverage registers handlers in the Next.js workbenches' instrumentation.ts and reports observations by resuming a durable hook, so the channel works across serverless instances. - Docs: observability guide with a Sentry example + workflow/api reference page.
🦋 Changeset detectedLatest commit: f635d02 The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
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 |
📊 Workflow Benchmarkscommit Backend:
Streams
📈 STSO distribution vs main (inline / queue-hop histograms)1020 steps (inline) Cumulative STSO time: main 278970ms → this run 185259ms (Δ -93711ms, -34%) 📈 CRTT drill-down vs main (RTT distributions & profiles)RTT over stream progress (avg per tenth of stream, bars scaled min→max): RTT by chunk size (avg per log size bin, ~160B → ~12KB serialized, bars scaled min→max): Delivery jitter over stream progress (avg positive CDV per tenth of stream, bars scaled min→max): ℹ️ Metric definitions & methodologyStreams: first-chunk RTT (the stream-open path, before any buffering/backpressure), CRTT percentiles, and worst delivery stall (CDV max). Cells are medians across iterations; per-run values in the artifacts. No 🔴/🟢 marks until targets attach. The collapsed STSO distribution section above buckets every step gap, split inline (same warm process — pure framework overhead) vs queue-hop (fresh process — dispatch, reinit, replay). The collapsed CRTT drill-down: per-variant RTT histograms (fixed log bins, Best/P75/P90/P99 deltas compare against the most recent benchmark run on Metrics — TTFS: time to first step body (in-deployment start() → first step body) · Fan-out TTFS: fan-out time to first step (in-deployment start() → first of the parallel step bodies to complete) · Fan-out TTLS: fan-out time to last step (in-deployment start() → last of the parallel step bodies to complete, i.e. when the Promise.all resolves) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · CRTT: chunk round-trip time (per-chunk write → read latency, one clock domain: deployment → stream backend → same deployment) · CDV: chunk delay variation / delivery jitter (inter-arrival gap minus inter-write gap per seq-adjacent pair; skew-free; the row is each run's MAX positive value, so one stall moves it) 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 · Promise.all(100 steps): 100 trivial no-op steps started together in a single Promise.all; Fan-out TTFS is the first of them to complete and Fan-out TTLS the last, both from the in-deployment clientStart, so their gap is the spread the runtime adds across the fan-out · paced control (100/s, 60B): the control: 300 tiny (~60B) deltas metronome-paced at 100/s — zero workload structure, so it reads the transport floor and flush cadence, and disambiguates transport-wide vs workload-specific when a replay row moves · size sweep (100/s, 160B-12KB): same pacing as the control with deltas padded in rotation across seven log-spaced sizes (~160B–12KB) — rotation decouples size from stream position, so it isolates whether chunk size causes latency · replay gateway-gpt-5.4-nano-2000t (1x): raw provider SSE cadence captured at the AI gateway boundary (gpt-5.4-nano, the most popular gateway model; per-token deltas p50 208B = the modal production chunk size), replayed exactly as measured — the typical customer's workload; its CDV is the typical customer's real delivery jitter · replay eve-gpt-5.6-sol-2000t (1x): a captured eve turn (gpt-5.6-sol, the most-used demanding eve model; ~2000 output tokens = production p50 turn length) replayed exactly as measured — eve's envelope protocol re-ships the cumulative message so sizes ramp 142B→13KB; the demanding outlier tenant's reality · replay eve-gpt-5.6-sol-2000t (2x): the same eve capture at 2x — the headroom/stress row; real fast-tier models emit the same chunk sizes at proportionally higher rate, so time compression is a faithful speed model · first chunk (pooled): every run's seq-0 RTT pooled across all stream scenarios — the first chunk precedes any workload differentiation, so pooling samples one shared stream-open path with exact percentiles Replay cadences (semantic sha256) — eve-gpt-5.6-sol-2000t 🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 All timestamps are deployment-side; runs are triggered in-deployment, so the CI runner and api.vercel.com sit outside every measured window. TTFS = Cold starts stay in the numbers (real bursty-workload latency, inflates P75+); Best is the warm floor. |
🧪 E2E Test Results✅ All tests passed
|
| Passed | Failed | Skipped | Total | |
|---|---|---|---|---|
| ✅ ▲ Vercel Production | 3586 | 0 | 788 | 4374 |
| ✅ 💻 Local Development | 3938 | 0 | 598 | 4536 |
| ✅ 📦 Local Production | 3938 | 0 | 598 | 4536 |
| ✅ 🐘 Local Postgres | 3938 | 0 | 598 | 4536 |
| ✅ 🪟 Windows | 162 | 0 | 0 | 162 |
| ✅ 🌐 Cross-language Conformance | 9 | 0 | 134 | 143 |
| ✅ vercel-multi-region | 27 | 0 | 0 | 27 |
| Total | 15598 | 0 | 2716 | 18314 |
Details by Category
✅ ▲ Vercel Production
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ astro-node | 132 | 0 | 30 |
| ✅ astro-quickjs | 132 | 0 | 30 |
| ✅ example-node | 132 | 0 | 30 |
| ✅ example-quickjs | 132 | 0 | 30 |
| ✅ express-node | 132 | 0 | 30 |
| ✅ express-quickjs | 132 | 0 | 30 |
| ✅ fastify-node | 132 | 0 | 30 |
| ✅ fastify-quickjs | 132 | 0 | 30 |
| ✅ hono-node | 132 | 0 | 30 |
| ✅ hono-quickjs | 132 | 0 | 30 |
| ✅ nest-node | 132 | 0 | 30 |
| ✅ nest-quickjs | 132 | 0 | 30 |
| ✅ nextjs-turbopack-node | 159 | 0 | 3 |
| ✅ nextjs-turbopack-quickjs | 159 | 0 | 3 |
| ✅ nextjs-webpack-node | 159 | 0 | 3 |
| ✅ nextjs-webpack-quickjs | 159 | 0 | 3 |
| ✅ nitro-node | 132 | 0 | 30 |
| ✅ nitro-quickjs | 132 | 0 | 30 |
| ✅ nuxt-node | 132 | 0 | 30 |
| ✅ nuxt-quickjs | 132 | 0 | 30 |
| ✅ python-node | 8 | 0 | 154 |
| ✅ sveltekit-node | 151 | 0 | 11 |
| ✅ sveltekit-quickjs | 151 | 0 | 11 |
| ✅ tanstack-start-node | 132 | 0 | 30 |
| ✅ tanstack-start-quickjs | 132 | 0 | 30 |
| ✅ vite-node | 132 | 0 | 30 |
| ✅ vite-quickjs | 132 | 0 | 30 |
✅ 💻 Local Development
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ astro-stable-node | 134 | 0 | 28 |
| ✅ astro-stable-quickjs | 134 | 0 | 28 |
| ✅ express-stable-node | 134 | 0 | 28 |
| ✅ express-stable-quickjs | 134 | 0 | 28 |
| ✅ fastify-stable-node | 134 | 0 | 28 |
| ✅ fastify-stable-quickjs | 134 | 0 | 28 |
| ✅ hono-stable-node | 134 | 0 | 28 |
| ✅ hono-stable-quickjs | 134 | 0 | 28 |
| ✅ nest-stable-node | 134 | 0 | 28 |
| ✅ nest-stable-quickjs | 134 | 0 | 28 |
| ✅ nextjs-turbopack-canary-node | 143 | 0 | 19 |
| ✅ nextjs-turbopack-canary-quickjs | 143 | 0 | 19 |
| ✅ nextjs-turbopack-stable-node | 162 | 0 | 0 |
| ✅ nextjs-turbopack-stable-quickjs | 162 | 0 | 0 |
| ✅ nextjs-webpack-canary-node | 143 | 0 | 19 |
| ✅ nextjs-webpack-canary-quickjs | 143 | 0 | 19 |
| ✅ nextjs-webpack-stable-node | 162 | 0 | 0 |
| ✅ nextjs-webpack-stable-quickjs | 162 | 0 | 0 |
| ✅ nitro-stable-node | 134 | 0 | 28 |
| ✅ nitro-stable-quickjs | 134 | 0 | 28 |
| ✅ nuxt-stable-node | 134 | 0 | 28 |
| ✅ nuxt-stable-quickjs | 134 | 0 | 28 |
| ✅ sveltekit-stable-node | 153 | 0 | 9 |
| ✅ sveltekit-stable-quickjs | 153 | 0 | 9 |
| ✅ tanstack-start-node | 134 | 0 | 28 |
| ✅ tanstack-start-quickjs | 134 | 0 | 28 |
| ✅ vite-stable-node | 134 | 0 | 28 |
| ✅ vite-stable-quickjs | 134 | 0 | 28 |
✅ 📦 Local Production
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ astro-stable-node | 134 | 0 | 28 |
| ✅ astro-stable-quickjs | 134 | 0 | 28 |
| ✅ express-stable-node | 134 | 0 | 28 |
| ✅ express-stable-quickjs | 134 | 0 | 28 |
| ✅ fastify-stable-node | 134 | 0 | 28 |
| ✅ fastify-stable-quickjs | 134 | 0 | 28 |
| ✅ hono-stable-node | 134 | 0 | 28 |
| ✅ hono-stable-quickjs | 134 | 0 | 28 |
| ✅ nest-stable-node | 134 | 0 | 28 |
| ✅ nest-stable-quickjs | 134 | 0 | 28 |
| ✅ nextjs-turbopack-canary-node | 143 | 0 | 19 |
| ✅ nextjs-turbopack-canary-quickjs | 143 | 0 | 19 |
| ✅ nextjs-turbopack-stable-node | 162 | 0 | 0 |
| ✅ nextjs-turbopack-stable-quickjs | 162 | 0 | 0 |
| ✅ nextjs-webpack-canary-node | 143 | 0 | 19 |
| ✅ nextjs-webpack-canary-quickjs | 143 | 0 | 19 |
| ✅ nextjs-webpack-stable-node | 162 | 0 | 0 |
| ✅ nextjs-webpack-stable-quickjs | 162 | 0 | 0 |
| ✅ nitro-stable-node | 134 | 0 | 28 |
| ✅ nitro-stable-quickjs | 134 | 0 | 28 |
| ✅ nuxt-stable-node | 134 | 0 | 28 |
| ✅ nuxt-stable-quickjs | 134 | 0 | 28 |
| ✅ sveltekit-stable-node | 153 | 0 | 9 |
| ✅ sveltekit-stable-quickjs | 153 | 0 | 9 |
| ✅ tanstack-start-node | 134 | 0 | 28 |
| ✅ tanstack-start-quickjs | 134 | 0 | 28 |
| ✅ vite-stable-node | 134 | 0 | 28 |
| ✅ vite-stable-quickjs | 134 | 0 | 28 |
✅ 🐘 Local Postgres
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ astro-stable-node | 134 | 0 | 28 |
| ✅ astro-stable-quickjs | 134 | 0 | 28 |
| ✅ express-stable-node | 134 | 0 | 28 |
| ✅ express-stable-quickjs | 134 | 0 | 28 |
| ✅ fastify-stable-node | 134 | 0 | 28 |
| ✅ fastify-stable-quickjs | 134 | 0 | 28 |
| ✅ hono-stable-node | 134 | 0 | 28 |
| ✅ hono-stable-quickjs | 134 | 0 | 28 |
| ✅ nest-stable-node | 134 | 0 | 28 |
| ✅ nest-stable-quickjs | 134 | 0 | 28 |
| ✅ nextjs-turbopack-canary-node | 143 | 0 | 19 |
| ✅ nextjs-turbopack-canary-quickjs | 143 | 0 | 19 |
| ✅ nextjs-turbopack-stable-node | 162 | 0 | 0 |
| ✅ nextjs-turbopack-stable-quickjs | 162 | 0 | 0 |
| ✅ nextjs-webpack-canary-node | 143 | 0 | 19 |
| ✅ nextjs-webpack-canary-quickjs | 143 | 0 | 19 |
| ✅ nextjs-webpack-stable-node | 162 | 0 | 0 |
| ✅ nextjs-webpack-stable-quickjs | 162 | 0 | 0 |
| ✅ nitro-stable-node | 134 | 0 | 28 |
| ✅ nitro-stable-quickjs | 134 | 0 | 28 |
| ✅ nuxt-stable-node | 134 | 0 | 28 |
| ✅ nuxt-stable-quickjs | 134 | 0 | 28 |
| ✅ sveltekit-stable-node | 153 | 0 | 9 |
| ✅ sveltekit-stable-quickjs | 153 | 0 | 9 |
| ✅ tanstack-start-node | 134 | 0 | 28 |
| ✅ tanstack-start-quickjs | 134 | 0 | 28 |
| ✅ vite-stable-node | 134 | 0 | 28 |
| ✅ vite-stable-quickjs | 134 | 0 | 28 |
✅ 🪟 Windows
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ nextjs-turbopack-quickjs | 162 | 0 | 0 |
✅ 🌐 Cross-language Conformance
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ python | 9 | 0 | 134 |
✅ vercel-multi-region
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ nextjs-turbopack | 27 | 0 | 0 |
There was a problem hiding this comment.
Pull request overview
Adds a new host-side lifecycle hook mechanism to the Workflow SDK so applications can register global handlers to observe workflow runs completing or failing (including runtime-level failures that never reach workflow-code try/catch). This introduces a shared per-process registry, wires dispatch into all terminal writers, exposes the API via workflow/api, and adds unit + e2e coverage plus v5 docs.
Changes:
- Add
registerLifecycleHooksregistry + safe dispatch (dispatchRunCompletedHooks/dispatchRunFailedHooks) with host-realm error hydration. - Wire lifecycle dispatch into terminal runtime paths (core runtime, QuickJS entrypoint, replay budget, deployment guard) and export via
workflow/api. - Add tests (unit + wiring + e2e fixtures) and documentation pages for the new API.
Reviewed changes
Copilot reviewed 21 out of 21 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| workbench/nextjs-webpack/instrumentation.ts | Registers e2e lifecycle hooks (but currently imports a missing module). |
| workbench/nextjs-turbopack/lifecycle-hooks-e2e.ts | Adds e2e hook registration that reports outcomes via resuming a durable hook. |
| workbench/nextjs-turbopack/instrumentation.ts | Registers the e2e lifecycle hooks in Node.js runtime only. |
| workbench/example/workflows/99_e2e.ts | Adds lifecycle-hook target workflows + observer workflow used by Next.js e2e tests. |
| packages/workflow/src/api.ts | Exposes registerLifecycleHooks and related types from workflow/api (host API). |
| packages/workflow/src/api-workflow.ts | Adds workflow-VM stub export for registerLifecycleHooks (throws in workflow context). |
| packages/core/src/runtime/replay-budget.ts | Dispatches onRunFailed hooks after replay budget exhaustion terminal write lands. |
| packages/core/src/runtime/replay-budget.test.ts | Adds wiring test that dispatch happens only after successful terminal write. |
| packages/core/src/runtime/quickjs-entrypoint.ts | Dispatches lifecycle hooks after QuickJS terminal writers succeed. |
| packages/core/src/runtime/lifecycle-hooks.ts | Implements the global Symbol registry, dispatch machinery, and error hydration for handlers. |
| packages/core/src/runtime/lifecycle-hooks.test.ts | Adds unit tests for registry semantics, ordering, isolation, and hydration behavior. |
| packages/core/src/runtime/deployment-guard.ts | Dispatches onRunFailed after deployment-mismatch terminal failure write. |
| packages/core/src/runtime.ts | Wires dispatch into core terminal paths (completed + multiple failed paths). |
| packages/core/package.json | Adds export entry for ./runtime/lifecycle-hooks. |
| packages/core/e2e/e2e.test.ts | Adds Next.js-only e2e tests validating hook parameters and hydration. |
| docs/content/docs/v5/observability/meta.json | Adds the new lifecycle hooks guide to the Observability section nav. |
| docs/content/docs/v5/observability/lifecycle-hooks.mdx | Adds the lifecycle hooks guide (usage, semantics, Sentry example). |
| docs/content/docs/v5/api-reference/workflow-api/register-lifecycle-hooks.mdx | Adds API reference page for registerLifecycleHooks. |
| docs/content/docs/v5/api-reference/workflow-api/index.mdx | Adds the new API card to the workflow/api index. |
| .changeset/workflow-lifecycle-hooks.md | Changeset for @workflow/core + workflow minor releases. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| import { registerOTel } from '@vercel/otel'; | ||
| import { registerE2eLifecycleHooks } from './lifecycle-hooks-e2e'; | ||
|
|
||
| export function register() { | ||
| if (process.env.NEXT_RUNTIME === 'nodejs') { | ||
| // Workflow lifecycle hooks are host-only; skip the edge runtime's | ||
| // instrumentation pass. | ||
| registerE2eLifecycleHooks(); | ||
| } |
There was a problem hiding this comment.
The file does exist — workbench/nextjs-webpack/lifecycle-hooks-e2e.ts is a symlink to ../nextjs-turbopack/lifecycle-hooks-e2e.ts (mode 120000; same convention as the symlinked workflows/ files), and the failing lanes' logs show it resolving fine — the import trace runs through ./lifecycle-hooks-e2e.ts and fails deeper, at fs.
That deeper failure was the real webpack breakage, fixed in c262506: the static top-level import in instrumentation.ts pulled workflow/api → world-init → @workflow/world-local → proper-lockfile → fs into every compile target of instrumentation.ts, and the non-node ones can't resolve fs — 500ing every request in the nextjs-webpack lanes. The NEXT_RUNTIME guard only helps at runtime, so the registration is now loaded via a dynamic import inside the guard (the canonical Next.js pattern for node-only instrumentation), letting each compile target DCE the branch. Verified locally: lifecycle + pages-router e2e slices green against both nextjs-webpack and nextjs-turbopack dev servers.
| * E2E coverage for `registerLifecycleHooks` (see the "lifecycle hooks" | ||
| * describe in packages/core/e2e/e2e.test.ts and the fixtures in | ||
| * workflows/99_e2e.ts). |
There was a problem hiding this comment.
Not a typo — it refers to the vitest describe block by that name — but reworded to "describe block" in c262506 so it reads unambiguously.
| // The dispatcher resolves a dynamic import before handing the promise to | ||
| // waitUntil, so yield to the microtask queue until the capture settles. | ||
| for (let i = 0; i < 10 && waitUntilPromises.length === 0; i++) { | ||
| await new Promise((resolve) => setImmediate(resolve)); | ||
| } |
There was a problem hiding this comment.
Good catch — fixed in c262506. The comment now says it yields macrotask (check-phase) turns via setImmediate, noting that each turn also drains the intervening microtasks (which is why it reliably outwaits the dispatcher's dynamic-import hop).
Sim WorldSimulated world deterministic testing for races. Traces 🟠 world-sim scenario book — 1 fail of 41 total
Full trace: |
Per the Vercel technical writing guidelines: em dashes create ambiguity for agents parsing sentence boundaries. Replaced with periods, commas, parentheses, or colons across the new docs pages, the changeset, and the code comments this branch adds. Pre-existing em dashes elsewhere are left for the repo-wide docs audit.
Beyond the em-dash pass: the guide's intro now leads with what
lifecycle hooks let you do (the first sentence is parseable as the
page's purpose) instead of opening with the failure mode; passive
constructions are active ('the runtime keeps the invocation alive
with waitUntil', 'the runtime logs and swallows a throwing handler',
'you can register multiple hook sets', 'the backend writes that
transition'); the one-word 'Semantics' heading is now the standalone
statement 'How handlers behave'; and the key lazy-hydration sentence
names the Run instance instead of leading with a pronoun so it reads
correctly when extracted alone.
Problem
Some failures never reach a
try/catchin workflow code — the run can fail in the runtime itself, after the workflow function has parked (replay timeout, max deliveries, deployment mismatch, …). Today the only observability for those is runtime error logs, OTel spans, and the observability tab. Apps have no supported way to centrally observe run outcomes from application code, e.g. to report failed runs to Sentry.Follow-up to #3675 (which made serialization errors catchable inside workflow code; this PR covers the runtime-level failures that can't be).
API
Returns an unregister function; multiple registrations run in order.
Implementation
packages/core/src/runtime/lifecycle-hooks.ts): lives onglobalThisunderSymbol.for('@workflow/core//lifecycleHooks')so every bundled copy of@workflow/coreshares one list (same pattern as the error-class registry and World cache).run_failedwriter holds is often a VM-realm object (instanceof Errorfalse on the host) with possible VM-realm exotics in its cause chain. The dispatcher round-trips it throughdehydrateRunError/hydrateRunError(unencrypted — the bytes never leave the process), so handlers always receive host-realm hydrated Errors with class identity preserved, falling back to the original value if the round-trip fails.safeWaitUntil— handlers cannot delay or change the run outcome, a throwing handler is logged and swallowed, later handlers still run, and serverless invocations stay alive while handlers finish. Zero cost when nothing is registered.run_completed/run_failedwrite actually landed (never onEntityConflictError/RunExpiredError— the winning invocation fires instead): happy-path completion, terminal catch, suspension-commit failure,recordFatalRunError, max-deliveries gate, replay-budget exhaustion, deployment guard, and both QuickJS entrypoint writers.workflow/api(host); the workflow-VM condition build gets the standard throwing stub.Tests
lifecycle-hooks.test.ts, 9 tests): registration/unregistration, Run + WorkflowRunFailedError param shapes, VM-realm error hydration, handler-throw isolation, ordering, shared Symbol registry, non-Error cause round-trip, zero-cost empty registry.replay-budget.test.ts): a real terminal writer dispatches on successful write and not on write failure.instrumentation.tsregisters handlers that report each target run's terminal transition by resuming a durable hook — deliberately, since on a deployed app the terminal write happens on a different instance than the one serving the test's HTTP requests, so an in-memory buffer would not travel. Asserts onRunCompleted can readrun.workflowName/run.returnValueand onRunFailed seeserrorCode: USER_ERROR+ hydratedFatalErrorcause.Verified locally: full
packages/coreunit suite (2208 passed) and e2e against a localnextjs-turbopackdev server (lifecycle+FatalErrorslices green).Docs Preview
(The preview deployment sits behind deployment protection, so the links require Vercel team access.)
Follow-ups
onRunCancelledis intentionally out of scope: the runtime never writesrun_cancelled(cancellation is client/world-initiated), so a symmetric hook needs a different mechanism.