Skip to content

feat(core): user-registerable workflow lifecycle hooks (registerLifecycleHooks) - #3678

Open
TooTallNate wants to merge 5 commits into
mainfrom
nrajlich/workflow-lifecycle-hooks
Open

feat(core): user-registerable workflow lifecycle hooks (registerLifecycleHooks)#3678
TooTallNate wants to merge 5 commits into
mainfrom
nrajlich/workflow-lifecycle-hooks

Conversation

@TooTallNate

@TooTallNate TooTallNate commented Aug 19, 2026

Copy link
Copy Markdown
Member

Problem

Some failures never reach a try/catch in 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

// instrumentation.ts (Next.js) — or any module that loads at startup
import { registerLifecycleHooks } from 'workflow/api';

registerLifecycleHooks({
  async onRunCompleted({ run }) {
    // `run` is the lazily-hydrated Run instance — accessors only fetch when used
  },
  async onRunFailed({ run, error }) {
    // `error` is a WorkflowRunFailedError: `errorCode` carries the classification,
    // `cause` is the hydrated thrown value — same shape run.returnValue rejects with
    Sentry.captureException(error.cause ?? error, {
      tags: { runId: run.runId, errorCode: error.errorCode },
    });
  },
});

Returns an unregister function; multiple registrations run in order.

Implementation

  • Registry (packages/core/src/runtime/lifecycle-hooks.ts): lives on globalThis under Symbol.for('@workflow/core//lifecycleHooks') so every bundled copy of @workflow/core shares one list (same pattern as the error-class registry and World cache).
  • Hydrated error: the thrown value a run_failed writer holds is often a VM-realm object (instanceof Error false on the host) with possible VM-realm exotics in its cause chain. The dispatcher round-trips it through dehydrateRunError/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.
  • Dispatch safety: fire-and-forget via 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.
  • Wiring: every terminal writer dispatches only after its run_completed/run_failed write actually landed (never on EntityConflictError/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.
  • Exports: workflow/api (host); the workflow-VM condition build gets the standard throwing stub.
  • Documented caveat: transitions recorded outside app compute (e.g. CLI/dashboard cancel) don't fire handlers.

Tests

  • Unit (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.
  • Wiring (replay-budget.test.ts): a real terminal writer dispatches on successful write and not on write failure.
  • e2e (gated to the Next.js workbenches): instrumentation.ts registers 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 read run.workflowName/run.returnValue and onRunFailed sees errorCode: USER_ERROR + hydrated FatalError cause.

Verified locally: full packages/core unit suite (2208 passed) and e2e against a local nextjs-turbopack dev server (lifecycle + FatalError slices green).

Docs Preview

Page Preview
Observability → Lifecycle Hooks (guide) /v5/docs/observability/lifecycle-hooks
API Reference → workflow/api → registerLifecycleHooks /v5/docs/api-reference/workflow-api/register-lifecycle-hooks

(The preview deployment sits behind deployment protection, so the links require Vercel team access.)

Follow-ups

  • onRunCancelled is intentionally out of scope: the runtime never writes run_cancelled (cancellation is client/world-initiated), so a symmetric hook needs a different mechanism.

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.
Copilot AI lite review requested due to automatic review settings August 19, 2026 22:01
@TooTallNate
TooTallNate requested review from a team, fantix and msullivan as code owners August 19, 2026 22:01
@changeset-bot

changeset-bot Bot commented Aug 19, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f635d02

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

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

@vercel

vercel Bot commented Aug 19, 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 20, 2026 8:08pm
example-nextjs-workflow-webpack Ready Ready Preview Aug 20, 2026 8:08pm
example-workflow Ready Ready Preview Aug 20, 2026 8:08pm
workbench-astro-workflow Ready Ready Preview Aug 20, 2026 8:08pm
workbench-express-workflow Ready Ready Preview Aug 20, 2026 8:08pm
workbench-fastify-workflow Ready Ready Preview Aug 20, 2026 8:08pm
workbench-hono-workflow Ready Ready Preview Aug 20, 2026 8:08pm
workbench-nestjs-workflow Ready Ready Preview Aug 20, 2026 8:08pm
workbench-nitro-workflow Ready Ready Preview Aug 20, 2026 8:08pm
workbench-nuxt-workflow Ready Ready Preview Aug 20, 2026 8:08pm
workbench-python-workflow Ready Ready Preview Aug 20, 2026 8:08pm
workbench-sveltekit-workflow Ready Ready Preview Aug 20, 2026 8:08pm
workbench-tanstack-start-workflow Ready Ready Preview Aug 20, 2026 8:08pm
workbench-vite-workflow Ready Ready Preview Aug 20, 2026 8:08pm
workflow-docs Ready Ready Preview, v0 Aug 20, 2026 8:08pm
workflow-swc-playground Ready Ready Preview Aug 20, 2026 8:08pm
workflow-tarballs Ready Ready Preview Aug 20, 2026 8:08pm
workflow-web Ready Ready Preview Aug 20, 2026 8:08pm

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit f635d02 · Thu, 20 Aug 2026 20:25:20 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1516 (+61%) 🔻 1651 🔴 (+41%) 🔻 1690 🔴 (+37%) 🔻 1743 🔴 (+23%) 🔻 30
TTFS stream 324 (-69%) 💚 1638 🔴 (+40%) 🔻 1669 🔴 (+36%) 🔻 1864 🔴 (+44%) 🔻 30
TTFS hook + stream 434 (-68%) 💚 1970 🔴 (+30%) 🔻 2168 🔴 (+39%) 🔻 2603 🔴 (+61%) 🔻 30
Fan-out TTFS Promise.all(100 steps) 486 (-28%) 💚 2282 (+20%) 🔻 2284 (+19%) 🔻 2385 (+20%) 🔻 10
Fan-out TTLS Promise.all(100 steps) 4420 (-30%) 💚 7102 (-25%) 💚 10389 (-14%) 13307 (-9.4%) 10
STSO 1020 steps (inline) 107 (-34%) 💚 200 (-29%) 💚 272 (-18%) 💚 399 (-40%) 💚 1019
WO 1020 steps 185436 (-34%) 💚 185436 (-34%) 💚 185436 (-34%) 💚 185436 (-34%) 💚 1
CRTT first chunk (pooled) 95 (-19%) 💚 148 (-8.6%) 196 (-40%) 💚 273 (-65%) 💚 28

Streams

Scenario CRTT 1st p75 p90 p99 CDV max iters
paced control (100/s, 60B) 131 (-8%) 158 (-23%) 253 (-58%) 449 (-69%) 126 (-45%) 10
size sweep (100/s, 160B-12KB) 136 (-9%) 165 (-18%) 290 (+20%) 747 (-61%) 131 (-11%) 10
replay gateway-gpt-5.4-nano-2000t (1x) 136 (-7%) 146 (-47%) 179 (-70%) 640 (-41%) 240 (-43%) 3
replay eve-gpt-5.6-sol-2000t (1x) 119 (-22%) 214 (+1%) 902 (+136%) 4228 (+420%) 2315 (+328%) 2
replay eve-gpt-5.6-sol-2000t (2x) 134 (-15%) 192 (-23%) 294 (-25%) 465 (-40%) 262 (-36%) 3
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 278970ms → this run 185259ms (Δ -93711ms, -34%)

  100-150 ms  ░░░░░░░░░░░░░░░░░░░░░┃    main   0  this 434  +434
  150-200 ms  █░░░░░░░░░░░░░░░┃         main  26  this 329  +303
  200-250 ms  █████┃██████████████████  main 472  this 114  -358
  250-300 ms  ███┃█████████████         main 335  this  74  -261
  300-350 ms  █┃███                     main 104  this  41   -63
  350-400 ms  ┃█                        main  34  this  17   -17
  400-450 ms  ┃                         main  20  this   4   -16
  450-500 ms  ┃                         main   7  this   4    -3
  500-550 ms  ┃                         main   5  this   0    -5
  550-600 ms  ┃                         main   4  this   1    -3
  600-650 ms  ┃                         main   1  this   1    +0
  650-700 ms  ┃                         main   4  this   0    -4
  700-750 ms  ┃                         main   1  this   0    -1
  750-800 ms  ┃                         main   2  this   0    -2
  900-950 ms  ┃                         main   1  this   0    -1
1050-1100 ms  ┃                         main   1  this   0    -1
1600-1650 ms  ┃                         main   1  this   0    -1
3600-3650 ms  ┃                         main   1  this   0    -1
📈 CRTT drill-down vs main (RTT distributions & profiles)
variant  RTT 1ms→5s+             avg         p50          p90           p99     n
control  ······▃█▁····  135.8 (-34%)  125 (-16%)   253 (-58%)    449 (-69%)  3000
sweep    ······▃█▁▁···  135.5 (-38%)  115 (-23%)   290 (+20%)    747 (-61%)  3000
gw 1x    ·····▁▅█▁▁···  123.3 (-37%)  111 (-26%)   179 (-70%)    640 (-41%)  5295
eve 1x   ·····▁▅█▃▁▁▁·  295.4 (+63%)  124 (-11%)  902 (+136%)  4228 (+420%)  5186
eve 2x   ·····▁▃█▃▁···  154.6 (-25%)  137 (-19%)   294 (-25%)    465 (-40%)  7779

RTT over stream progress (avg per tenth of stream, bars scaled min→max):

control  ▆▃▁▃▃▇█▇▇▃  121–150ms
sweep    ▃▂▁▁▁▃█▅▆▂  115–181ms
gw 1x    █▄▂▂▄▂▂▂▁▃  106–166ms
eve 1x   ▂▇█▁▁▁▂▂▁▁  123–862ms
eve 2x   ▂▁▃▃▁▃▆█▆▄  124–202ms

RTT by chunk size (avg per log size bin, ~160B → ~12KB serialized, bars scaled min→max):

sweep  █▃▁▁▃▆▇  133–138ms

Delivery jitter over stream progress (avg positive CDV per tenth of stream, bars scaled min→max):

control  ▁▁▁▁▆▆█▁▄█  37–46ms
sweep    ▂▁▂▃▄█▃▁▇▁  45–67ms
gw 1x    █▃▃▂▆▄▃▃▁▅  30–47ms
eve 1x   ▂█▁▁▃▂▃▄▁▂  23–67ms
eve 2x   ▅▅▄▄▅▃▃▁▆█  19–29ms
ℹ️ Metric definitions & methodology

Streams: 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). = main, = this run, = fill.

The collapsed CRTT drill-down: per-variant RTT histograms (fixed log bins, · = empty) and mean RTT/positive-CDV profile lines over stream progress and chunk size. Histograms, avgs, and profiles merge exactly across runs; p50–p99 are percentile-of-percentiles. Per-index rows live in the artifacts.

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) · 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 eaf22f5946e7c61f3c65c7006d550df180cfabd4e706254a09f22aec0cfb420d · gateway-gpt-5.4-nano-2000t 6f24ac518b6b83ff1d0e85a5fe78230db192716d66a7fc6b2fe022752001d041

🔴 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 = start() → first step body (includes dispatch + any cold start); Fan-out TTFS/TTLS = first/last step completion of one Promise.all from the same anchor (the gap is the runtime’s fan-out spread); STSO/WO between step bodies; CRTT inside the workflow (excludes the api.vercel.com read path).

Cold starts stay in the numbers (real bursty-workload latency, inflates P75+); Best is the warm floor.

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

⚠️ Flaky E2E Tests (passed on retry)

These tests failed at least once and passed on a retry. A recurring entry here is a real race worth investigating.

  • addTenWorkflow (nitro)
  • runClassSerializationWorkflow - Run instances serialize across workflow/step boundaries (nextjs-webpack)
  • sleepWinsRaceWorkflow (vite)

🛠 Infra Events (absorbed by the harness)

Platform anomalies the e2e harness detected and worked around (e.g. a run the queue never picked up, replaced by a fresh run). Clustered timestamps indicate a backend blip; a steady drip indicates a platform issue worth escalating.

  • cold-start-warmup · suite warmup (tanstack-start) · at 20:11:50Z · abandoned wrun_01M0GCSEVEDZM8TMTVKD18WB0N
  • run-pickup-stall · AllInOneService.processNumber - static workflow method using sibling static step methods (nitro) · at 20:13:27Z · abandoned wrun_01M0GCWSMPJ9SYRGGWTMHYMAG0
  • run-pickup-stall · startFromWorkflow - calling start() directly inside a workflow function with hook communication (nextjs-webpack) · at 20:17:55Z · abandoned wrun_01M0GD4ZB6B5GMV11MFADQFR0C

E2E Test Summary

Summary
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

📋 View full workflow run

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

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 registerLifecycleHooks registry + 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.

Comment on lines 1 to +9
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();
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

Comment on lines +4 to +6
* E2E coverage for `registerLifecycleHooks` (see the "lifecycle hooks"
* describe in packages/core/e2e/e2e.test.ts and the fixtures in
* workflows/99_e2e.ts).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not a typo — it refers to the vitest describe block by that name — but reworded to "describe block" in c262506 so it reads unambiguously.

Comment on lines +24 to +28
// 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));
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch — 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).

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 world-sim scenario book — 1 fail of 41 total

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

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