Skip to content

feat(core): experimental in-process VM continuation for inline replay#2966

Draft
VaguelySerious wants to merge 3 commits into
mainfrom
peter/cache-vm-continuation
Draft

feat(core): experimental in-process VM continuation for inline replay#2966
VaguelySerious wants to merge 3 commits into
mainfrom
peter/cache-vm-continuation

Conversation

@VaguelySerious

@VaguelySerious VaguelySerious commented Jul 16, 2026

Copy link
Copy Markdown
Member

Summary

Adds in-process VM continuationon by default, kill switch WORKFLOW_VM_CONTINUATION=0. When active, the inline replay loop keeps a suspended workflow VM alive across iterations and feeds newly-appended events into it, instead of rebuilding the vm.Context and replaying the event log from event 0 on every pass.

Default-on so CI lanes and benchmarks actually exercise and measure the new path. The kill switch restores the previous rebuild-and-replay behavior exactly.

This is the "cache the VM" idea, scoped to the only place it's sound: within a single invocation's inline loop, where the same process owns the appended writes so the durable event log stays authoritative. It does not cache a VM across process/invocation boundaries (sleeps, hooks, redelivery) — there is no live VM to resume there and a snapshot would drift from the log.

How it works

  1. On a step-only suspension, runWorkflow attaches a continuation handle to the thrown WorkflowSuspension.
  2. The inline loop runs the step(s) inline as today, appending their terminal events to the log.
  3. Next iteration, instead of a fresh runWorkflow, it calls continuation.resume(events): EventsConsumer.resume() adopts the authoritative event array and re-drives the still-registered step consumers, resolving the pending step promise so the same live VM advances to its next checkpoint.

The step consumer already stays registered after hitting end-of-events (it only removes itself on terminal events), so resuming is just feeding it the events a fresh replay would have consumed next — the same deterministic call sequence, not restarted. RNG/clock/correlation-id state advance naturally in the live VM, exactly matching what a from-scratch replay reconstructs.

Safety (why this can't corrupt a run)

  • Scoped to step-only suspensions. Hooks / waits / attribute writes / aborts leave continuation unset → the loop falls back to a full replay. Those paths involve out-of-band resume/parallel invocations and delivery-barrier ordering that the replay path handles specially and that aren't validated for continuation here.
  • Prefix-divergence guard. resume() checks that the events already consumed by the live VM are a byte-stable prefix (by eventId) of the authoritative log. Any mismatch throws ReplayDivergenceError; the loop swallows it and falls back to a fresh replay.
  • Kill switch == today. WORKFLOW_VM_CONTINUATION=0 disables the continuation attach entirely; the suspend/complete/error control flow is byte-identical to before (verified by the full suite passing under the kill switch).
  • Worst case == today. Any bail-out reverts to a from-scratch replay in a fresh VM.

Scope / limitations (intentionally not in this PR)

  • Cross-process/invocation continuation (sleeps, hooks, redelivery) — not possible in-memory; excluded by design.
  • Fan-out where not all pending steps run inline still works (the VM re-suspends on the remaining ones, identical to a fresh replay) but gets no extra benefit beyond the inline batch.
  • Hook/wait/attribute suspensions deliberately fall back.

Verification

  • packages/core full suite green with the default (on) and under the kill switch (=0): 1488 passed, 3 pre-existing expected-fails.
  • vm-continuation.test.ts: proves resume() advances a live two-step workflow across appended events in one VM, plus the divergence guard.
  • vm-continuation-loop.test.ts: drives a 2-step sequential workflow through the real workflowEntrypoint loop and asserts createContext (VM construction) is called once by default (resumed per step) vs >1 under the kill switch (rebuilt per replay), and that the dehydrated run_completed output is byte-identical between the two modes.
  • pnpm --filter @workflow/core build + typecheck clean.

Notes

  • Default-on affects production too, not just CI — this env flag is read at runtime everywhere. WORKFLOW_VM_CONTINUATION=0 is the per-deployment kill switch.
  • The e2e / world-vercel lanes exercise continuation for the first time via this PR's default flip; CI results on those lanes are the real signal for the non-world-local paths.
  • Draft / proof-of-concept: demonstrates the mechanism and the sequential hot-path win while keeping the log authoritative and a kill switch away from the prior behavior.

🤖 Generated with Claude Code

Add an opt-in path (WORKFLOW_VM_CONTINUATION=1, off by default) that keeps a
suspended workflow VM alive across inline-loop iterations instead of rebuilding
the vm.Context and replaying the event log from scratch each pass.

On a step-only suspension, runWorkflow attaches a `continuation` handle to the
WorkflowSuspension. The inline loop, after appending the step's terminal events,
feeds them into the SAME live VM via EventsConsumer.resume(), resolving the
pending step promise so the workflow body advances to its next checkpoint.

Scope and safety:
- Only within a single invocation's inline loop (same process owns the writes,
  so the durable event log stays authoritative).
- Only for step-only suspensions; hooks/waits/attribute writes fall back to a
  full replay (they involve out-of-band invocations and delivery-barrier
  ordering the replay path handles specially).
- resume() prefix-checks the consumed events against the authoritative log by
  eventId and throws ReplayDivergenceError on any mismatch; the loop swallows
  it and falls back to a fresh replay. Worst case == today's behavior.

Verified: full core suite green with the flag both off and on (1488 passed);
a loop-level test proves the VM is constructed once (resumed per step) with the
flag on vs rebuilt per replay off, producing byte-identical output.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jul 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4c11419

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/builders Patch
@workflow/cli Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/vitest Patch
@workflow/web-shared Patch
@workflow/web Patch
workflow Minor
@workflow/world-testing Patch
@workflow/astro Patch
@workflow/nest Patch
@workflow/nuxt Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite Patch

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

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

@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

Passed Failed Skipped Total
❌ 💻 Local Development 1616 1 219 1836
✅ 📦 Local Production 1617 0 219 1836
✅ 🐘 Local Postgres 1617 0 219 1836
✅ 🪟 Windows 153 0 0 153
✅ 📋 Other 768 0 150 918
Total 5771 1 807 6579

❌ Failed Tests

💻 Local Development (1 failed)

nuxt-stable (1 failed):

  • runClassSerializationWorkflow - Run instances serialize across workflow/step boundaries

Details by Category

❌ 💻 Local Development
App Passed Failed Skipped
✅ astro-stable 128 0 25
✅ express-stable 128 0 25
✅ fastify-stable 128 0 25
✅ hono-stable 128 0 25
✅ nextjs-turbopack-canary 134 0 19
✅ nextjs-turbopack-stable 153 0 0
✅ nextjs-webpack-canary 134 0 19
✅ nextjs-webpack-stable 153 0 0
✅ nitro-stable 128 0 25
❌ nuxt-stable 127 1 25
✅ sveltekit-stable 147 0 6
✅ vite-stable 128 0 25
✅ 📦 Local Production
App Passed Failed Skipped
✅ astro-stable 128 0 25
✅ express-stable 128 0 25
✅ fastify-stable 128 0 25
✅ hono-stable 128 0 25
✅ nextjs-turbopack-canary 134 0 19
✅ nextjs-turbopack-stable 153 0 0
✅ nextjs-webpack-canary 134 0 19
✅ nextjs-webpack-stable 153 0 0
✅ nitro-stable 128 0 25
✅ nuxt-stable 128 0 25
✅ sveltekit-stable 147 0 6
✅ vite-stable 128 0 25
✅ 🐘 Local Postgres
App Passed Failed Skipped
✅ astro-stable 128 0 25
✅ express-stable 128 0 25
✅ fastify-stable 128 0 25
✅ hono-stable 128 0 25
✅ nextjs-turbopack-canary 134 0 19
✅ nextjs-turbopack-stable 153 0 0
✅ nextjs-webpack-canary 134 0 19
✅ nextjs-webpack-stable 153 0 0
✅ nitro-stable 128 0 25
✅ nuxt-stable 128 0 25
✅ sveltekit-stable 147 0 6
✅ vite-stable 128 0 25
✅ 🪟 Windows
App Passed Failed Skipped
✅ nextjs-turbopack 153 0 0
✅ 📋 Other
App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 128 0 25
✅ e2e-local-dev-tanstack-start- 128 0 25
✅ e2e-local-postgres-nest-stable 128 0 25
✅ e2e-local-postgres-tanstack-start- 128 0 25
✅ e2e-local-prod-nest-stable 128 0 25
✅ e2e-local-prod-tanstack-start- 128 0 25

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: failure
  • Local Dev: failure
  • Local Prod: success
  • Local Postgres: success
  • Windows: success

Check the workflow run for details.

Flip the flag to default-on so CI and benchmarks exercise and measure the
continuation path. `WORKFLOW_VM_CONTINUATION=0` reverts to rebuilding the VM
and replaying from scratch. Updates the loop test to drive the baseline via the
kill switch and the ON case via the (now default) unset env.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread packages/core/src/workflow.ts Outdated
…` or the `'completed'` queue drain) bypass the `'failed'` drain, silently dropping pending fire-and-forget queue items — a regression from the pre-PR behavior in both continuation and non-continuation modes.

This commit fixes the issue reported at packages/core/src/workflow.ts:993

## Bug

In the pre-PR code (`HEAD~1:packages/core/src/workflow.ts` ~lines 877-928), the completion logic placed `dehydrateWorkflowReturnValue` and `drainPendingQueueItems(..., 'completed', ...)` **inside** a `try`, with a `catch` that ran `drainPendingQueueItems(..., 'failed', ...)` (after passing through `WorkflowSuspension`/`ReplayDivergenceError` untouched) before rethrowing.

The refactor split this into two helpers:

*   `finalizeCompleted` — dehydrate + `drain('completed')`
*   `finalizeFailed` — suspension/divergence passthrough + `drain('failed')` + rethrow

But in `pump()`, the `try/catch` only wrapped the `Promise.race`. The completion branch:

```ts
if (outcome.kind === 'done') {
  return await finalizeCompleted(outcome.value); // OUTSIDE try/catch
}
```

was awaited **outside** that try/catch, so a throw during `dehydrateWorkflowReturnValue` or `drainPendingQueueItems('completed')` propagated straight out of `pump()`/`runWorkflow` without ever running the `'failed'` drain.

## Trigger

A workflow that completes successfully but whose return value fails serialization in `dehydrateWorkflowReturnValue` (e.g. a non-serializable value), while `workflowContext.invocationsQueue` holds pending fire-and-forget invocations. Pre-PR those items were drained as `'failed'`; post-PR they are silently dropped/leaked and the error path diverges. This is not gated behind `enableContinuation`, so it also affects the default path — contradicting the "byte-identical when `enableContinuation=false`" claim.

## Fix

Wrap the completion call so a throw is routed through `finalizeFailed`, restoring the original try/catch semantics:

```ts
if (outcome.kind === 'done') {
  try {
    return await finalizeCompleted(outcome.value);
  } catch (err) {
    return await finalizeFailed(err);
  }
}
```

`finalizeFailed` already re-throws `WorkflowSuspension`/`ReplayDivergenceError` untouched and otherwise runs `drain('failed')` then rethrows — exactly matching the pre-PR catch block.


Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
Co-authored-by: VaguelySerious <mittgfu@gmail.com>
@vercel

vercel Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Deployment failed with the following error:

Creating the Deployment Timed Out.

@vercel

vercel Bot commented Jul 16, 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, Comment, Open in v0 Jul 17, 2026 12:16am
example-nextjs-workflow-webpack Error Error Open in v0 Jul 17, 2026 12:16am
example-workflow Ready Ready Preview, Comment, Open in v0 Jul 17, 2026 12:16am
workbench-astro-workflow Ready Ready Preview, Comment, Open in v0 Jul 17, 2026 12:16am
workbench-express-workflow Ready Ready Preview, Comment, Open in v0 Jul 17, 2026 12:16am
workbench-fastify-workflow Ready Ready Preview, Comment, Open in v0 Jul 17, 2026 12:16am
workbench-hono-workflow Ready Ready Preview, Comment, Open in v0 Jul 17, 2026 12:16am
workbench-nitro-workflow Error Error Open in v0 Jul 17, 2026 12:16am
workbench-nuxt-workflow Ready Ready Preview, Comment, Open in v0 Jul 17, 2026 12:16am
workbench-sveltekit-workflow Ready Ready Preview, Comment, Open in v0 Jul 17, 2026 12:16am
workbench-tanstack-start-workflow Ready Ready Preview, Comment, Open in v0 Jul 17, 2026 12:16am
workbench-vite-workflow Ready Ready Preview, Comment, Open in v0 Jul 17, 2026 12:16am
workflow-docs Ready Ready Preview, Comment, Open in v0 Jul 17, 2026 12:16am
workflow-swc-playground Ready Ready Preview, Comment, Open in v0 Jul 17, 2026 12:16am
workflow-tarballs Ready Ready Preview, Comment, Open in v0 Jul 17, 2026 12:16am
workflow-web Ready Ready Preview, Comment, Open in v0 Jul 17, 2026 12:16am

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.

1 participant