Skip to content

fix(core): make step-argument serialization failures catchable in workflow code - #3675

Merged
TooTallNate merged 6 commits into
mainfrom
nrajlich/catchable-step-arg-serialization-errors
Aug 20, 2026
Merged

fix(core): make step-argument serialization failures catchable in workflow code#3675
TooTallNate merged 6 commits into
mainfrom
nrajlich/catchable-step-arg-serialization-errors

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Aug 19, 2026

Copy link
Copy Markdown
Member

Problem

A customer reported that passing an unserializable value to a step produced

Queue callback error: Error [WorkflowRuntimeError]: Failed to serialize step arguments at path …
[Workflow] Workflow handler exceeded max deliveries (49/48)

The failure happens in the host after the workflow function has parked, so a try/catch in the "use workflow" body never sees it — the run just redelivers until max-deliveries exhaustion (on v4; on main since #1849 it fails fast, but is still unobservable from workflow code).

This is asymmetric with step return-value serialization failures, which already write a step_failed event and reject into the workflow catchably.

Fix

handleSuspension now catches the SerializationError from dehydrateStepArguments per-step and, instead of rejecting the whole suspension:

  • writes step_created with a placeholder input (every World requires the step entity to exist before a terminal step event — and the real input is precisely what refused to serialize), then step_failed carrying the dehydrated SerializationError — the same two-sequential-createGuarded shape as the hook token-group ops
  • drops the step out of the lazy-inline batch and the createBatch fan-out fold (which only accepts creation events); healthy sibling steps proceed normally
  • reports the step in a new failedStepCorrelationIds result field; the runtime then forces an in-process replay over the reloaded log (nothing else would re-invoke the run when the failed step was the only pending work), demoting any retained VM session to a cold replay
  • tolerates EntityConflictError / RunExpiredError on both writes (concurrent handlers race toward the same deterministic failure)
  • non-SerializationError failures (e.g. RuntimeDecryptionError) keep their current behavior and RUNTIME_ERROR identity

Resulting semantics:

  • Caught: the replay rejects the step's promise via the existing step_failed consumer; a try/catch around the await step(...) observes the SerializationError (e.g. to report to Sentry from application code)
  • Uncaught: the error propagates out of the workflow body and the run fails immediately as a fatal USER_ERROR — no queue-redelivery retry loop

Tests

  • Unit (suspension-handler.test.ts): single bad step, bad + healthy siblings, batched fan-out drop-out, error payload round-trip via hydrateStepError, concurrent-finalization conflict tolerance, run-expired skip
  • e2e (new fixtures in 99_e2e.ts, symlinked to all workbenches): caught/uncaught × step-args/step-return-value — the return-value side already worked but had no e2e coverage. The uncaught-args test is the regression check for the production failure mode (asserts USER_ERROR, not MAX_DELIVERIES_EXCEEDED, well within a 60s timeout)

Verified locally: full packages/core unit suite (2185 passed), and e2e against a local nextjs-turbopack dev server (serialization failure, FatalError, and parallel slices all green).

Backport

This is a stability fix and should backport to stable (v4) — that's the branch the reporting customer is on. Stable has drifted here (no SerializationError class, older suspension handler), so the backport will likely need conflict resolution; if the catchable behavior doesn't port cleanly, the minimal stable fix is to treat serialization errors as fatal in stable's suspension catch so runs fail fast instead of burning 48 deliveries.

Follow-up

A separate PR will add user-registerable workflow lifecycle hooks (onRunCompleted / onRunFailed passing the lazily-hydrated Run instance and, for failures, the hydrated fatal error with its error code) so runtime-level failures can be reported to e.g. Sentry from a central place such as instrumentation.ts.

Docs Preview

PagePreview
Errors → serialization-failed ("Where the Error Surfaces")/v5/docs/errors/serialization-failed#where-the-error-surfaces
Foundations → Errors and Retries ("Serialization Failures")/v5/docs/foundations/errors-and-retries#serialization-failures

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

…kflow code
A step whose arguments fail to serialize is now finalized by the
suspension handler as step_created + step_failed (mirroring a step-body
failure) instead of rejecting the whole suspension. The next replay —
forced in-process, since no step message is dispatched for the failed
step — rejects the step's promise with the SerializationError, so a
try/catch around the step call observes it. Uncaught, the error
propagates out of the workflow body and fails the run as a fatal
USER_ERROR immediately, instead of redelivering the orchestrator
message until max deliveries (49/48) as reported in production on v4.
@TooTallNate
TooTallNate requested a review from a team as a code ownerAugust 19, 2026 21:08
CopilotAI lite review requested due to automatic review settings August 19, 2026 21:08
@changeset-bot

changeset-botBot commented Aug 19, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 00b30cf

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

This PR includes changesets to release 16 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

vercelBot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewAug 19, 2026 11:17pm
example-nextjs-workflow-webpackReadyReadyPreviewAug 19, 2026 11:17pm
example-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-astro-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-express-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-fastify-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-hono-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-nestjs-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-nitro-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-nuxt-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-python-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-sveltekit-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-tanstack-start-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-vite-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workflow-docsReadyReadyPreview, v0Aug 19, 2026 11:17pm
workflow-swc-playgroundReadyReadyPreviewAug 19, 2026 11:17pm
workflow-tarballsReadyReadyPreviewAug 19, 2026 11:17pm
workflow-webReadyReadyPreviewAug 19, 2026 11:17pm

CopilotAI 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

This PR fixes a workflow-runtime observability gap where step argument serialization failures occurred after the workflow parked, causing repeated redeliveries and making the failure uncatchable from "use workflow" code. It changes suspension handling so step-argument serialization failures are finalized as step failures (step_created + step_failed), allowing workflows to catch SerializationError the same way they can catch step-body failures and step return-value serialization failures.

Changes:

  • Catch SerializationError from step-argument dehydration in handleSuspension, finalize the step as failed, and report failed step correlation IDs.
  • Force an in-process cold replay when any step-argument serialization failures were finalized, ensuring the workflow observes the step_failed event.
  • Add unit + e2e coverage for caught/uncaught step-argument and step return-value serialization failures, plus a changeset entry.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
FileDescription
workbench/example/workflows/99_e2e.tsAdds workflow/step fixtures used by the e2e tests to trigger step-arg and step-return serialization failures (caught and uncaught variants).
packages/core/src/runtime/suspension-handler.tsImplements per-step handling of step-argument serialization failures by writing step_created (placeholder input) + step_failed, and returns failedStepCorrelationIds.
packages/core/src/runtime/suspension-handler.test.tsAdds unit tests covering single/batched scenarios, payload round-trip, and conflict/run-expired tolerances.
packages/core/src/runtime.tsForces an in-process replay when failedStepCorrelationIds is non-empty so workflow code can observe the failure deterministically.
packages/core/e2e/e2e.test.tsAdds end-to-end regression coverage for the new catchable behavior and for “fail fast as USER_ERROR” semantics.
.changeset/catchable-step-arg-serialization-errors.mdDocuments the user-facing behavior change as a patch bump for @workflow/core.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment threadpackages/core/src/runtime/suspension-handler.ts
…geset
Addresses review feedback: dehydrateStepError in
finalizeUnserializableStep now receives suspension.globalThis like every
other dehydration in this file. Error detection is realm-independent, so
the host-created SerializationError serializes identically, but VM-realm
values guest code threw into the cause chain are now detected by the
realm-sensitive reducers.
…ep-arg-serialization-errors
# Conflicts:
#	packages/core/src/runtime/suspension-handler.test.ts
Comment threadpackages/core/src/runtime/suspension-handler.ts
- A crash or transient failure between finalization's two durable
writes leaves a lone placeholder step_created, and redelivery then
dispatches the step through normal crash recovery — previously
running user code with the placeholder arguments. The placeholder
now carries a structural flag on the input triple's top level (which
user code never controls, so no false positives), and the step
executor checks it after hydration: instead of running the body, it
throws the intended fatal SerializationError, completing the
interrupted finalization as step_failed. Applies to both engines
(they share the placeholder and the executor).
- Regression tests: executor fails a placeholder-input step without
running the body (and doesn't trip on a genuine argument equal to
the display marker); handleSuspension rejects for redelivery when
step_failed can't be written after step_created landed, leaving the
recoverable placeholder behind; mixed bad-step + large fan-out
returns the failure set alongside still-pending deferredBatchWork
whose rejection surfaces — the contract the runtime's failed-step
join (added previously) relies on.
- Docs: the two new code samples are now self-contained so the docs
code-sample typecheck passes.

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

AI Review: Re-reviewed the latest head. The interrupted-finalization placeholder is now safely recovered without executing user code, deferred batch work is joined before replay, QuickJS has equivalent behavior, and targeted regression coverage is present. No blocking issues remain.

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI review: no blocking issues

// finalizeUnserializableStep. In the terminal drain
// (finalizeUnserializableSteps unset), skip entirely — see the
// param docs.
if (step.serializationError) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Verified this round, against the head SHA:

  • CI is green on 00b30cf — all 163 checks pass, E2E Required Check included, and all 57 QuickJS lanes (Local Dev / Postgres / Prod, Windows, Vercel Prod, Multi-Region) that were red last round now pass.
  • Locally, nextjs-turbopack dev server with WORKFLOW_VM=quickjs: the four serialization e2e tests pass, and the dev log shows this path firing (Step arguments failed to serialize; failing the step so the workflow can observe the error, three runs) — so the QuickJS engine really is exercising the new finalize rather than passing by luck.
  • Added a fan-out fixture the current e2e fixtures don't cover: Promise.allSettled([badStep, ...five healthy steps]), so the bad step coexists with pair-folded inline claims and eager batched creates. Green on both engines, and the event log is exactly what the design predicts:
6 step_created (5 healthy + 1 placeholder)
5 step_started (one per healthy step — no double claim)
5 step_completed
1 step_failed
1 run_completed run status: completed, ~4.6s wall, no stall

with the workflow observing ['rejected','fulfilled','fulfilled','fulfilled','fulfilled','fulfilled'] and SerializationError on the rejected one. That settles what was behind my earlier point (2): healthy siblings are neither stranded nor executed twice, and the pass costs one extra replay rather than a stall. Not committed.

* Absent when {@link serializationError} is set — the input is precisely
* what refused to serialize.
*/
input?: Uint8Array;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Nit

Making input optional doesn't get you a compiler guard at either deref site: encrypt(data: Uint8Array | unknown, …) returns its argument unchanged when it isn't a Uint8Array, so an undefined input would sail through encryptSerializedData(step.input, …) in dispatchPendingOps (and through lazyStepInput on the inline path) as undefined rather than failing to typecheck. Both sites are correct today — the serializationError early return covers one, healthySteps the other — but the invariant rests on those two filters, not on the type. A narrowing throw at the deref would turn a future caller's mistake into a build error instead of a step created with no input.

@github-actionsgithub-actionsBot mentioned this pull request Aug 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Backport PR opened against stable: #3687. Merge conflicts were resolved by AI — please review carefully. (backport job run)

Sign up for freeto 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.

4 participants

@TooTallNate@VaguelySerious@karthikscale3
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(core): make step-argument serialization failures catchable in workflow code by TooTallNate · Pull Request #3675 · vercel/workflow · GitHub
Skip to content

fix(core): make step-argument serialization failures catchable in workflow code - #3675

Merged
TooTallNate merged 6 commits into
mainfrom
nrajlich/catchable-step-arg-serialization-errors
Aug 20, 2026
Merged

fix(core): make step-argument serialization failures catchable in workflow code#3675
TooTallNate merged 6 commits into
mainfrom
nrajlich/catchable-step-arg-serialization-errors

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Aug 19, 2026

Copy link
Copy Markdown
Member

Problem

A customer reported that passing an unserializable value to a step produced

Queue callback error: Error [WorkflowRuntimeError]: Failed to serialize step arguments at path …
[Workflow] Workflow handler exceeded max deliveries (49/48)

The failure happens in the host after the workflow function has parked, so a try/catch in the "use workflow" body never sees it — the run just redelivers until max-deliveries exhaustion (on v4; on main since #1849 it fails fast, but is still unobservable from workflow code).

This is asymmetric with step return-value serialization failures, which already write a step_failed event and reject into the workflow catchably.

Fix

handleSuspension now catches the SerializationError from dehydrateStepArguments per-step and, instead of rejecting the whole suspension:

  • writes step_created with a placeholder input (every World requires the step entity to exist before a terminal step event — and the real input is precisely what refused to serialize), then step_failed carrying the dehydrated SerializationError — the same two-sequential-createGuarded shape as the hook token-group ops
  • drops the step out of the lazy-inline batch and the createBatch fan-out fold (which only accepts creation events); healthy sibling steps proceed normally
  • reports the step in a new failedStepCorrelationIds result field; the runtime then forces an in-process replay over the reloaded log (nothing else would re-invoke the run when the failed step was the only pending work), demoting any retained VM session to a cold replay
  • tolerates EntityConflictError / RunExpiredError on both writes (concurrent handlers race toward the same deterministic failure)
  • non-SerializationError failures (e.g. RuntimeDecryptionError) keep their current behavior and RUNTIME_ERROR identity

Resulting semantics:

  • Caught: the replay rejects the step's promise via the existing step_failed consumer; a try/catch around the await step(...) observes the SerializationError (e.g. to report to Sentry from application code)
  • Uncaught: the error propagates out of the workflow body and the run fails immediately as a fatal USER_ERROR — no queue-redelivery retry loop

Tests

  • Unit (suspension-handler.test.ts): single bad step, bad + healthy siblings, batched fan-out drop-out, error payload round-trip via hydrateStepError, concurrent-finalization conflict tolerance, run-expired skip
  • e2e (new fixtures in 99_e2e.ts, symlinked to all workbenches): caught/uncaught × step-args/step-return-value — the return-value side already worked but had no e2e coverage. The uncaught-args test is the regression check for the production failure mode (asserts USER_ERROR, not MAX_DELIVERIES_EXCEEDED, well within a 60s timeout)

Verified locally: full packages/core unit suite (2185 passed), and e2e against a local nextjs-turbopack dev server (serialization failure, FatalError, and parallel slices all green).

Backport

This is a stability fix and should backport to stable (v4) — that's the branch the reporting customer is on. Stable has drifted here (no SerializationError class, older suspension handler), so the backport will likely need conflict resolution; if the catchable behavior doesn't port cleanly, the minimal stable fix is to treat serialization errors as fatal in stable's suspension catch so runs fail fast instead of burning 48 deliveries.

Follow-up

A separate PR will add user-registerable workflow lifecycle hooks (onRunCompleted / onRunFailed passing the lazily-hydrated Run instance and, for failures, the hydrated fatal error with its error code) so runtime-level failures can be reported to e.g. Sentry from a central place such as instrumentation.ts.

Docs Preview

PagePreview
Errors → serialization-failed ("Where the Error Surfaces")/v5/docs/errors/serialization-failed#where-the-error-surfaces
Foundations → Errors and Retries ("Serialization Failures")/v5/docs/foundations/errors-and-retries#serialization-failures

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

…kflow code
A step whose arguments fail to serialize is now finalized by the
suspension handler as step_created + step_failed (mirroring a step-body
failure) instead of rejecting the whole suspension. The next replay —
forced in-process, since no step message is dispatched for the failed
step — rejects the step's promise with the SerializationError, so a
try/catch around the step call observes it. Uncaught, the error
propagates out of the workflow body and fails the run as a fatal
USER_ERROR immediately, instead of redelivering the orchestrator
message until max deliveries (49/48) as reported in production on v4.
@TooTallNate
TooTallNate requested a review from a team as a code ownerAugust 19, 2026 21:08
CopilotAI lite review requested due to automatic review settings August 19, 2026 21:08
@changeset-bot

changeset-botBot commented Aug 19, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 00b30cf

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

This PR includes changesets to release 16 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

vercelBot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewAug 19, 2026 11:17pm
example-nextjs-workflow-webpackReadyReadyPreviewAug 19, 2026 11:17pm
example-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-astro-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-express-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-fastify-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-hono-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-nestjs-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-nitro-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-nuxt-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-python-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-sveltekit-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-tanstack-start-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-vite-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workflow-docsReadyReadyPreview, v0Aug 19, 2026 11:17pm
workflow-swc-playgroundReadyReadyPreviewAug 19, 2026 11:17pm
workflow-tarballsReadyReadyPreviewAug 19, 2026 11:17pm
workflow-webReadyReadyPreviewAug 19, 2026 11:17pm

CopilotAI 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

This PR fixes a workflow-runtime observability gap where step argument serialization failures occurred after the workflow parked, causing repeated redeliveries and making the failure uncatchable from "use workflow" code. It changes suspension handling so step-argument serialization failures are finalized as step failures (step_created + step_failed), allowing workflows to catch SerializationError the same way they can catch step-body failures and step return-value serialization failures.

Changes:

  • Catch SerializationError from step-argument dehydration in handleSuspension, finalize the step as failed, and report failed step correlation IDs.
  • Force an in-process cold replay when any step-argument serialization failures were finalized, ensuring the workflow observes the step_failed event.
  • Add unit + e2e coverage for caught/uncaught step-argument and step return-value serialization failures, plus a changeset entry.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
FileDescription
workbench/example/workflows/99_e2e.tsAdds workflow/step fixtures used by the e2e tests to trigger step-arg and step-return serialization failures (caught and uncaught variants).
packages/core/src/runtime/suspension-handler.tsImplements per-step handling of step-argument serialization failures by writing step_created (placeholder input) + step_failed, and returns failedStepCorrelationIds.
packages/core/src/runtime/suspension-handler.test.tsAdds unit tests covering single/batched scenarios, payload round-trip, and conflict/run-expired tolerances.
packages/core/src/runtime.tsForces an in-process replay when failedStepCorrelationIds is non-empty so workflow code can observe the failure deterministically.
packages/core/e2e/e2e.test.tsAdds end-to-end regression coverage for the new catchable behavior and for “fail fast as USER_ERROR” semantics.
.changeset/catchable-step-arg-serialization-errors.mdDocuments the user-facing behavior change as a patch bump for @workflow/core.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment threadpackages/core/src/runtime/suspension-handler.ts
…geset
Addresses review feedback: dehydrateStepError in
finalizeUnserializableStep now receives suspension.globalThis like every
other dehydration in this file. Error detection is realm-independent, so
the host-created SerializationError serializes identically, but VM-realm
values guest code threw into the cause chain are now detected by the
realm-sensitive reducers.
…ep-arg-serialization-errors
# Conflicts:
#	packages/core/src/runtime/suspension-handler.test.ts
Comment threadpackages/core/src/runtime/suspension-handler.ts
- A crash or transient failure between finalization's two durable
writes leaves a lone placeholder step_created, and redelivery then
dispatches the step through normal crash recovery — previously
running user code with the placeholder arguments. The placeholder
now carries a structural flag on the input triple's top level (which
user code never controls, so no false positives), and the step
executor checks it after hydration: instead of running the body, it
throws the intended fatal SerializationError, completing the
interrupted finalization as step_failed. Applies to both engines
(they share the placeholder and the executor).
- Regression tests: executor fails a placeholder-input step without
running the body (and doesn't trip on a genuine argument equal to
the display marker); handleSuspension rejects for redelivery when
step_failed can't be written after step_created landed, leaving the
recoverable placeholder behind; mixed bad-step + large fan-out
returns the failure set alongside still-pending deferredBatchWork
whose rejection surfaces — the contract the runtime's failed-step
join (added previously) relies on.
- Docs: the two new code samples are now self-contained so the docs
code-sample typecheck passes.

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

AI Review: Re-reviewed the latest head. The interrupted-finalization placeholder is now safely recovered without executing user code, deferred batch work is joined before replay, QuickJS has equivalent behavior, and targeted regression coverage is present. No blocking issues remain.

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI review: no blocking issues

// finalizeUnserializableStep. In the terminal drain
// (finalizeUnserializableSteps unset), skip entirely — see the
// param docs.
if (step.serializationError) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Verified this round, against the head SHA:

  • CI is green on 00b30cf — all 163 checks pass, E2E Required Check included, and all 57 QuickJS lanes (Local Dev / Postgres / Prod, Windows, Vercel Prod, Multi-Region) that were red last round now pass.
  • Locally, nextjs-turbopack dev server with WORKFLOW_VM=quickjs: the four serialization e2e tests pass, and the dev log shows this path firing (Step arguments failed to serialize; failing the step so the workflow can observe the error, three runs) — so the QuickJS engine really is exercising the new finalize rather than passing by luck.
  • Added a fan-out fixture the current e2e fixtures don't cover: Promise.allSettled([badStep, ...five healthy steps]), so the bad step coexists with pair-folded inline claims and eager batched creates. Green on both engines, and the event log is exactly what the design predicts:
6 step_created (5 healthy + 1 placeholder)
5 step_started (one per healthy step — no double claim)
5 step_completed
1 step_failed
1 run_completed run status: completed, ~4.6s wall, no stall

with the workflow observing ['rejected','fulfilled','fulfilled','fulfilled','fulfilled','fulfilled'] and SerializationError on the rejected one. That settles what was behind my earlier point (2): healthy siblings are neither stranded nor executed twice, and the pass costs one extra replay rather than a stall. Not committed.

* Absent when {@link serializationError} is set — the input is precisely
* what refused to serialize.
*/
input?: Uint8Array;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Nit

Making input optional doesn't get you a compiler guard at either deref site: encrypt(data: Uint8Array | unknown, …) returns its argument unchanged when it isn't a Uint8Array, so an undefined input would sail through encryptSerializedData(step.input, …) in dispatchPendingOps (and through lazyStepInput on the inline path) as undefined rather than failing to typecheck. Both sites are correct today — the serializationError early return covers one, healthySteps the other — but the invariant rests on those two filters, not on the type. A narrowing throw at the deref would turn a future caller's mistake into a build error instead of a step created with no input.

@github-actionsgithub-actionsBot mentioned this pull request Aug 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Backport PR opened against stable: #3687. Merge conflicts were resolved by AI — please review carefully. (backport job run)

Sign up for freeto 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.

4 participants

@TooTallNate@VaguelySerious@karthikscale3
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(core): make step-argument serialization failures catchable in workflow code by TooTallNate · Pull Request #3675 · vercel/workflow · GitHub
Skip to content

fix(core): make step-argument serialization failures catchable in workflow code - #3675

Merged
TooTallNate merged 6 commits into
mainfrom
nrajlich/catchable-step-arg-serialization-errors
Aug 20, 2026
Merged

fix(core): make step-argument serialization failures catchable in workflow code#3675
TooTallNate merged 6 commits into
mainfrom
nrajlich/catchable-step-arg-serialization-errors

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Aug 19, 2026

Copy link
Copy Markdown
Member

Problem

A customer reported that passing an unserializable value to a step produced

Queue callback error: Error [WorkflowRuntimeError]: Failed to serialize step arguments at path …
[Workflow] Workflow handler exceeded max deliveries (49/48)

The failure happens in the host after the workflow function has parked, so a try/catch in the "use workflow" body never sees it — the run just redelivers until max-deliveries exhaustion (on v4; on main since #1849 it fails fast, but is still unobservable from workflow code).

This is asymmetric with step return-value serialization failures, which already write a step_failed event and reject into the workflow catchably.

Fix

handleSuspension now catches the SerializationError from dehydrateStepArguments per-step and, instead of rejecting the whole suspension:

  • writes step_created with a placeholder input (every World requires the step entity to exist before a terminal step event — and the real input is precisely what refused to serialize), then step_failed carrying the dehydrated SerializationError — the same two-sequential-createGuarded shape as the hook token-group ops
  • drops the step out of the lazy-inline batch and the createBatch fan-out fold (which only accepts creation events); healthy sibling steps proceed normally
  • reports the step in a new failedStepCorrelationIds result field; the runtime then forces an in-process replay over the reloaded log (nothing else would re-invoke the run when the failed step was the only pending work), demoting any retained VM session to a cold replay
  • tolerates EntityConflictError / RunExpiredError on both writes (concurrent handlers race toward the same deterministic failure)
  • non-SerializationError failures (e.g. RuntimeDecryptionError) keep their current behavior and RUNTIME_ERROR identity

Resulting semantics:

  • Caught: the replay rejects the step's promise via the existing step_failed consumer; a try/catch around the await step(...) observes the SerializationError (e.g. to report to Sentry from application code)
  • Uncaught: the error propagates out of the workflow body and the run fails immediately as a fatal USER_ERROR — no queue-redelivery retry loop

Tests

  • Unit (suspension-handler.test.ts): single bad step, bad + healthy siblings, batched fan-out drop-out, error payload round-trip via hydrateStepError, concurrent-finalization conflict tolerance, run-expired skip
  • e2e (new fixtures in 99_e2e.ts, symlinked to all workbenches): caught/uncaught × step-args/step-return-value — the return-value side already worked but had no e2e coverage. The uncaught-args test is the regression check for the production failure mode (asserts USER_ERROR, not MAX_DELIVERIES_EXCEEDED, well within a 60s timeout)

Verified locally: full packages/core unit suite (2185 passed), and e2e against a local nextjs-turbopack dev server (serialization failure, FatalError, and parallel slices all green).

Backport

This is a stability fix and should backport to stable (v4) — that's the branch the reporting customer is on. Stable has drifted here (no SerializationError class, older suspension handler), so the backport will likely need conflict resolution; if the catchable behavior doesn't port cleanly, the minimal stable fix is to treat serialization errors as fatal in stable's suspension catch so runs fail fast instead of burning 48 deliveries.

Follow-up

A separate PR will add user-registerable workflow lifecycle hooks (onRunCompleted / onRunFailed passing the lazily-hydrated Run instance and, for failures, the hydrated fatal error with its error code) so runtime-level failures can be reported to e.g. Sentry from a central place such as instrumentation.ts.

Docs Preview

PagePreview
Errors → serialization-failed ("Where the Error Surfaces")/v5/docs/errors/serialization-failed#where-the-error-surfaces
Foundations → Errors and Retries ("Serialization Failures")/v5/docs/foundations/errors-and-retries#serialization-failures

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

…kflow code
A step whose arguments fail to serialize is now finalized by the
suspension handler as step_created + step_failed (mirroring a step-body
failure) instead of rejecting the whole suspension. The next replay —
forced in-process, since no step message is dispatched for the failed
step — rejects the step's promise with the SerializationError, so a
try/catch around the step call observes it. Uncaught, the error
propagates out of the workflow body and fails the run as a fatal
USER_ERROR immediately, instead of redelivering the orchestrator
message until max deliveries (49/48) as reported in production on v4.
@TooTallNate
TooTallNate requested a review from a team as a code ownerAugust 19, 2026 21:08
CopilotAI lite review requested due to automatic review settings August 19, 2026 21:08
@changeset-bot

changeset-botBot commented Aug 19, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 00b30cf

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

This PR includes changesets to release 16 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

vercelBot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewAug 19, 2026 11:17pm
example-nextjs-workflow-webpackReadyReadyPreviewAug 19, 2026 11:17pm
example-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-astro-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-express-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-fastify-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-hono-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-nestjs-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-nitro-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-nuxt-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-python-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-sveltekit-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-tanstack-start-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-vite-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workflow-docsReadyReadyPreview, v0Aug 19, 2026 11:17pm
workflow-swc-playgroundReadyReadyPreviewAug 19, 2026 11:17pm
workflow-tarballsReadyReadyPreviewAug 19, 2026 11:17pm
workflow-webReadyReadyPreviewAug 19, 2026 11:17pm

CopilotAI 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

This PR fixes a workflow-runtime observability gap where step argument serialization failures occurred after the workflow parked, causing repeated redeliveries and making the failure uncatchable from "use workflow" code. It changes suspension handling so step-argument serialization failures are finalized as step failures (step_created + step_failed), allowing workflows to catch SerializationError the same way they can catch step-body failures and step return-value serialization failures.

Changes:

  • Catch SerializationError from step-argument dehydration in handleSuspension, finalize the step as failed, and report failed step correlation IDs.
  • Force an in-process cold replay when any step-argument serialization failures were finalized, ensuring the workflow observes the step_failed event.
  • Add unit + e2e coverage for caught/uncaught step-argument and step return-value serialization failures, plus a changeset entry.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
FileDescription
workbench/example/workflows/99_e2e.tsAdds workflow/step fixtures used by the e2e tests to trigger step-arg and step-return serialization failures (caught and uncaught variants).
packages/core/src/runtime/suspension-handler.tsImplements per-step handling of step-argument serialization failures by writing step_created (placeholder input) + step_failed, and returns failedStepCorrelationIds.
packages/core/src/runtime/suspension-handler.test.tsAdds unit tests covering single/batched scenarios, payload round-trip, and conflict/run-expired tolerances.
packages/core/src/runtime.tsForces an in-process replay when failedStepCorrelationIds is non-empty so workflow code can observe the failure deterministically.
packages/core/e2e/e2e.test.tsAdds end-to-end regression coverage for the new catchable behavior and for “fail fast as USER_ERROR” semantics.
.changeset/catchable-step-arg-serialization-errors.mdDocuments the user-facing behavior change as a patch bump for @workflow/core.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment threadpackages/core/src/runtime/suspension-handler.ts
…geset
Addresses review feedback: dehydrateStepError in
finalizeUnserializableStep now receives suspension.globalThis like every
other dehydration in this file. Error detection is realm-independent, so
the host-created SerializationError serializes identically, but VM-realm
values guest code threw into the cause chain are now detected by the
realm-sensitive reducers.
…ep-arg-serialization-errors
# Conflicts:
#	packages/core/src/runtime/suspension-handler.test.ts
Comment threadpackages/core/src/runtime/suspension-handler.ts
- A crash or transient failure between finalization's two durable
writes leaves a lone placeholder step_created, and redelivery then
dispatches the step through normal crash recovery — previously
running user code with the placeholder arguments. The placeholder
now carries a structural flag on the input triple's top level (which
user code never controls, so no false positives), and the step
executor checks it after hydration: instead of running the body, it
throws the intended fatal SerializationError, completing the
interrupted finalization as step_failed. Applies to both engines
(they share the placeholder and the executor).
- Regression tests: executor fails a placeholder-input step without
running the body (and doesn't trip on a genuine argument equal to
the display marker); handleSuspension rejects for redelivery when
step_failed can't be written after step_created landed, leaving the
recoverable placeholder behind; mixed bad-step + large fan-out
returns the failure set alongside still-pending deferredBatchWork
whose rejection surfaces — the contract the runtime's failed-step
join (added previously) relies on.
- Docs: the two new code samples are now self-contained so the docs
code-sample typecheck passes.

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

AI Review: Re-reviewed the latest head. The interrupted-finalization placeholder is now safely recovered without executing user code, deferred batch work is joined before replay, QuickJS has equivalent behavior, and targeted regression coverage is present. No blocking issues remain.

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI review: no blocking issues

// finalizeUnserializableStep. In the terminal drain
// (finalizeUnserializableSteps unset), skip entirely — see the
// param docs.
if (step.serializationError) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Verified this round, against the head SHA:

  • CI is green on 00b30cf — all 163 checks pass, E2E Required Check included, and all 57 QuickJS lanes (Local Dev / Postgres / Prod, Windows, Vercel Prod, Multi-Region) that were red last round now pass.
  • Locally, nextjs-turbopack dev server with WORKFLOW_VM=quickjs: the four serialization e2e tests pass, and the dev log shows this path firing (Step arguments failed to serialize; failing the step so the workflow can observe the error, three runs) — so the QuickJS engine really is exercising the new finalize rather than passing by luck.
  • Added a fan-out fixture the current e2e fixtures don't cover: Promise.allSettled([badStep, ...five healthy steps]), so the bad step coexists with pair-folded inline claims and eager batched creates. Green on both engines, and the event log is exactly what the design predicts:
6 step_created (5 healthy + 1 placeholder)
5 step_started (one per healthy step — no double claim)
5 step_completed
1 step_failed
1 run_completed run status: completed, ~4.6s wall, no stall

with the workflow observing ['rejected','fulfilled','fulfilled','fulfilled','fulfilled','fulfilled'] and SerializationError on the rejected one. That settles what was behind my earlier point (2): healthy siblings are neither stranded nor executed twice, and the pass costs one extra replay rather than a stall. Not committed.

* Absent when {@link serializationError} is set — the input is precisely
* what refused to serialize.
*/
input?: Uint8Array;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Nit

Making input optional doesn't get you a compiler guard at either deref site: encrypt(data: Uint8Array | unknown, …) returns its argument unchanged when it isn't a Uint8Array, so an undefined input would sail through encryptSerializedData(step.input, …) in dispatchPendingOps (and through lazyStepInput on the inline path) as undefined rather than failing to typecheck. Both sites are correct today — the serializationError early return covers one, healthySteps the other — but the invariant rests on those two filters, not on the type. A narrowing throw at the deref would turn a future caller's mistake into a build error instead of a step created with no input.

@github-actionsgithub-actionsBot mentioned this pull request Aug 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Backport PR opened against stable: #3687. Merge conflicts were resolved by AI — please review carefully. (backport job run)

Sign up for freeto 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.

4 participants

@TooTallNate@VaguelySerious@karthikscale3
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(core): make step-argument serialization failures catchable in workflow code by TooTallNate · Pull Request #3675 · vercel/workflow · GitHub
Skip to content

fix(core): make step-argument serialization failures catchable in workflow code - #3675

Merged
TooTallNate merged 6 commits into
mainfrom
nrajlich/catchable-step-arg-serialization-errors
Aug 20, 2026
Merged

fix(core): make step-argument serialization failures catchable in workflow code#3675
TooTallNate merged 6 commits into
mainfrom
nrajlich/catchable-step-arg-serialization-errors

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Aug 19, 2026

Copy link
Copy Markdown
Member

Problem

A customer reported that passing an unserializable value to a step produced

Queue callback error: Error [WorkflowRuntimeError]: Failed to serialize step arguments at path …
[Workflow] Workflow handler exceeded max deliveries (49/48)

The failure happens in the host after the workflow function has parked, so a try/catch in the "use workflow" body never sees it — the run just redelivers until max-deliveries exhaustion (on v4; on main since #1849 it fails fast, but is still unobservable from workflow code).

This is asymmetric with step return-value serialization failures, which already write a step_failed event and reject into the workflow catchably.

Fix

handleSuspension now catches the SerializationError from dehydrateStepArguments per-step and, instead of rejecting the whole suspension:

  • writes step_created with a placeholder input (every World requires the step entity to exist before a terminal step event — and the real input is precisely what refused to serialize), then step_failed carrying the dehydrated SerializationError — the same two-sequential-createGuarded shape as the hook token-group ops
  • drops the step out of the lazy-inline batch and the createBatch fan-out fold (which only accepts creation events); healthy sibling steps proceed normally
  • reports the step in a new failedStepCorrelationIds result field; the runtime then forces an in-process replay over the reloaded log (nothing else would re-invoke the run when the failed step was the only pending work), demoting any retained VM session to a cold replay
  • tolerates EntityConflictError / RunExpiredError on both writes (concurrent handlers race toward the same deterministic failure)
  • non-SerializationError failures (e.g. RuntimeDecryptionError) keep their current behavior and RUNTIME_ERROR identity

Resulting semantics:

  • Caught: the replay rejects the step's promise via the existing step_failed consumer; a try/catch around the await step(...) observes the SerializationError (e.g. to report to Sentry from application code)
  • Uncaught: the error propagates out of the workflow body and the run fails immediately as a fatal USER_ERROR — no queue-redelivery retry loop

Tests

  • Unit (suspension-handler.test.ts): single bad step, bad + healthy siblings, batched fan-out drop-out, error payload round-trip via hydrateStepError, concurrent-finalization conflict tolerance, run-expired skip
  • e2e (new fixtures in 99_e2e.ts, symlinked to all workbenches): caught/uncaught × step-args/step-return-value — the return-value side already worked but had no e2e coverage. The uncaught-args test is the regression check for the production failure mode (asserts USER_ERROR, not MAX_DELIVERIES_EXCEEDED, well within a 60s timeout)

Verified locally: full packages/core unit suite (2185 passed), and e2e against a local nextjs-turbopack dev server (serialization failure, FatalError, and parallel slices all green).

Backport

This is a stability fix and should backport to stable (v4) — that's the branch the reporting customer is on. Stable has drifted here (no SerializationError class, older suspension handler), so the backport will likely need conflict resolution; if the catchable behavior doesn't port cleanly, the minimal stable fix is to treat serialization errors as fatal in stable's suspension catch so runs fail fast instead of burning 48 deliveries.

Follow-up

A separate PR will add user-registerable workflow lifecycle hooks (onRunCompleted / onRunFailed passing the lazily-hydrated Run instance and, for failures, the hydrated fatal error with its error code) so runtime-level failures can be reported to e.g. Sentry from a central place such as instrumentation.ts.

Docs Preview

PagePreview
Errors → serialization-failed ("Where the Error Surfaces")/v5/docs/errors/serialization-failed#where-the-error-surfaces
Foundations → Errors and Retries ("Serialization Failures")/v5/docs/foundations/errors-and-retries#serialization-failures

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

…kflow code
A step whose arguments fail to serialize is now finalized by the
suspension handler as step_created + step_failed (mirroring a step-body
failure) instead of rejecting the whole suspension. The next replay —
forced in-process, since no step message is dispatched for the failed
step — rejects the step's promise with the SerializationError, so a
try/catch around the step call observes it. Uncaught, the error
propagates out of the workflow body and fails the run as a fatal
USER_ERROR immediately, instead of redelivering the orchestrator
message until max deliveries (49/48) as reported in production on v4.
@TooTallNate
TooTallNate requested a review from a team as a code ownerAugust 19, 2026 21:08
CopilotAI lite review requested due to automatic review settings August 19, 2026 21:08
@changeset-bot

changeset-botBot commented Aug 19, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 00b30cf

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

This PR includes changesets to release 16 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

vercelBot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewAug 19, 2026 11:17pm
example-nextjs-workflow-webpackReadyReadyPreviewAug 19, 2026 11:17pm
example-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-astro-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-express-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-fastify-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-hono-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-nestjs-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-nitro-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-nuxt-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-python-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-sveltekit-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-tanstack-start-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-vite-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workflow-docsReadyReadyPreview, v0Aug 19, 2026 11:17pm
workflow-swc-playgroundReadyReadyPreviewAug 19, 2026 11:17pm
workflow-tarballsReadyReadyPreviewAug 19, 2026 11:17pm
workflow-webReadyReadyPreviewAug 19, 2026 11:17pm

CopilotAI 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

This PR fixes a workflow-runtime observability gap where step argument serialization failures occurred after the workflow parked, causing repeated redeliveries and making the failure uncatchable from "use workflow" code. It changes suspension handling so step-argument serialization failures are finalized as step failures (step_created + step_failed), allowing workflows to catch SerializationError the same way they can catch step-body failures and step return-value serialization failures.

Changes:

  • Catch SerializationError from step-argument dehydration in handleSuspension, finalize the step as failed, and report failed step correlation IDs.
  • Force an in-process cold replay when any step-argument serialization failures were finalized, ensuring the workflow observes the step_failed event.
  • Add unit + e2e coverage for caught/uncaught step-argument and step return-value serialization failures, plus a changeset entry.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
FileDescription
workbench/example/workflows/99_e2e.tsAdds workflow/step fixtures used by the e2e tests to trigger step-arg and step-return serialization failures (caught and uncaught variants).
packages/core/src/runtime/suspension-handler.tsImplements per-step handling of step-argument serialization failures by writing step_created (placeholder input) + step_failed, and returns failedStepCorrelationIds.
packages/core/src/runtime/suspension-handler.test.tsAdds unit tests covering single/batched scenarios, payload round-trip, and conflict/run-expired tolerances.
packages/core/src/runtime.tsForces an in-process replay when failedStepCorrelationIds is non-empty so workflow code can observe the failure deterministically.
packages/core/e2e/e2e.test.tsAdds end-to-end regression coverage for the new catchable behavior and for “fail fast as USER_ERROR” semantics.
.changeset/catchable-step-arg-serialization-errors.mdDocuments the user-facing behavior change as a patch bump for @workflow/core.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment threadpackages/core/src/runtime/suspension-handler.ts
…geset
Addresses review feedback: dehydrateStepError in
finalizeUnserializableStep now receives suspension.globalThis like every
other dehydration in this file. Error detection is realm-independent, so
the host-created SerializationError serializes identically, but VM-realm
values guest code threw into the cause chain are now detected by the
realm-sensitive reducers.
…ep-arg-serialization-errors
# Conflicts:
#	packages/core/src/runtime/suspension-handler.test.ts
Comment threadpackages/core/src/runtime/suspension-handler.ts
- A crash or transient failure between finalization's two durable
writes leaves a lone placeholder step_created, and redelivery then
dispatches the step through normal crash recovery — previously
running user code with the placeholder arguments. The placeholder
now carries a structural flag on the input triple's top level (which
user code never controls, so no false positives), and the step
executor checks it after hydration: instead of running the body, it
throws the intended fatal SerializationError, completing the
interrupted finalization as step_failed. Applies to both engines
(they share the placeholder and the executor).
- Regression tests: executor fails a placeholder-input step without
running the body (and doesn't trip on a genuine argument equal to
the display marker); handleSuspension rejects for redelivery when
step_failed can't be written after step_created landed, leaving the
recoverable placeholder behind; mixed bad-step + large fan-out
returns the failure set alongside still-pending deferredBatchWork
whose rejection surfaces — the contract the runtime's failed-step
join (added previously) relies on.
- Docs: the two new code samples are now self-contained so the docs
code-sample typecheck passes.

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

AI Review: Re-reviewed the latest head. The interrupted-finalization placeholder is now safely recovered without executing user code, deferred batch work is joined before replay, QuickJS has equivalent behavior, and targeted regression coverage is present. No blocking issues remain.

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI review: no blocking issues

// finalizeUnserializableStep. In the terminal drain
// (finalizeUnserializableSteps unset), skip entirely — see the
// param docs.
if (step.serializationError) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Verified this round, against the head SHA:

  • CI is green on 00b30cf — all 163 checks pass, E2E Required Check included, and all 57 QuickJS lanes (Local Dev / Postgres / Prod, Windows, Vercel Prod, Multi-Region) that were red last round now pass.
  • Locally, nextjs-turbopack dev server with WORKFLOW_VM=quickjs: the four serialization e2e tests pass, and the dev log shows this path firing (Step arguments failed to serialize; failing the step so the workflow can observe the error, three runs) — so the QuickJS engine really is exercising the new finalize rather than passing by luck.
  • Added a fan-out fixture the current e2e fixtures don't cover: Promise.allSettled([badStep, ...five healthy steps]), so the bad step coexists with pair-folded inline claims and eager batched creates. Green on both engines, and the event log is exactly what the design predicts:
6 step_created (5 healthy + 1 placeholder)
5 step_started (one per healthy step — no double claim)
5 step_completed
1 step_failed
1 run_completed run status: completed, ~4.6s wall, no stall

with the workflow observing ['rejected','fulfilled','fulfilled','fulfilled','fulfilled','fulfilled'] and SerializationError on the rejected one. That settles what was behind my earlier point (2): healthy siblings are neither stranded nor executed twice, and the pass costs one extra replay rather than a stall. Not committed.

* Absent when {@link serializationError} is set — the input is precisely
* what refused to serialize.
*/
input?: Uint8Array;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Nit

Making input optional doesn't get you a compiler guard at either deref site: encrypt(data: Uint8Array | unknown, …) returns its argument unchanged when it isn't a Uint8Array, so an undefined input would sail through encryptSerializedData(step.input, …) in dispatchPendingOps (and through lazyStepInput on the inline path) as undefined rather than failing to typecheck. Both sites are correct today — the serializationError early return covers one, healthySteps the other — but the invariant rests on those two filters, not on the type. A narrowing throw at the deref would turn a future caller's mistake into a build error instead of a step created with no input.

@github-actionsgithub-actionsBot mentioned this pull request Aug 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Backport PR opened against stable: #3687. Merge conflicts were resolved by AI — please review carefully. (backport job run)

Sign up for freeto 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.

4 participants

@TooTallNate@VaguelySerious@karthikscale3
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(core): make step-argument serialization failures catchable in workflow code by TooTallNate · Pull Request #3675 · vercel/workflow · GitHub
Skip to content

fix(core): make step-argument serialization failures catchable in workflow code - #3675

Merged
TooTallNate merged 6 commits into
mainfrom
nrajlich/catchable-step-arg-serialization-errors
Aug 20, 2026
Merged

fix(core): make step-argument serialization failures catchable in workflow code#3675
TooTallNate merged 6 commits into
mainfrom
nrajlich/catchable-step-arg-serialization-errors

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Aug 19, 2026

Copy link
Copy Markdown
Member

Problem

A customer reported that passing an unserializable value to a step produced

Queue callback error: Error [WorkflowRuntimeError]: Failed to serialize step arguments at path …
[Workflow] Workflow handler exceeded max deliveries (49/48)

The failure happens in the host after the workflow function has parked, so a try/catch in the "use workflow" body never sees it — the run just redelivers until max-deliveries exhaustion (on v4; on main since #1849 it fails fast, but is still unobservable from workflow code).

This is asymmetric with step return-value serialization failures, which already write a step_failed event and reject into the workflow catchably.

Fix

handleSuspension now catches the SerializationError from dehydrateStepArguments per-step and, instead of rejecting the whole suspension:

  • writes step_created with a placeholder input (every World requires the step entity to exist before a terminal step event — and the real input is precisely what refused to serialize), then step_failed carrying the dehydrated SerializationError — the same two-sequential-createGuarded shape as the hook token-group ops
  • drops the step out of the lazy-inline batch and the createBatch fan-out fold (which only accepts creation events); healthy sibling steps proceed normally
  • reports the step in a new failedStepCorrelationIds result field; the runtime then forces an in-process replay over the reloaded log (nothing else would re-invoke the run when the failed step was the only pending work), demoting any retained VM session to a cold replay
  • tolerates EntityConflictError / RunExpiredError on both writes (concurrent handlers race toward the same deterministic failure)
  • non-SerializationError failures (e.g. RuntimeDecryptionError) keep their current behavior and RUNTIME_ERROR identity

Resulting semantics:

  • Caught: the replay rejects the step's promise via the existing step_failed consumer; a try/catch around the await step(...) observes the SerializationError (e.g. to report to Sentry from application code)
  • Uncaught: the error propagates out of the workflow body and the run fails immediately as a fatal USER_ERROR — no queue-redelivery retry loop

Tests

  • Unit (suspension-handler.test.ts): single bad step, bad + healthy siblings, batched fan-out drop-out, error payload round-trip via hydrateStepError, concurrent-finalization conflict tolerance, run-expired skip
  • e2e (new fixtures in 99_e2e.ts, symlinked to all workbenches): caught/uncaught × step-args/step-return-value — the return-value side already worked but had no e2e coverage. The uncaught-args test is the regression check for the production failure mode (asserts USER_ERROR, not MAX_DELIVERIES_EXCEEDED, well within a 60s timeout)

Verified locally: full packages/core unit suite (2185 passed), and e2e against a local nextjs-turbopack dev server (serialization failure, FatalError, and parallel slices all green).

Backport

This is a stability fix and should backport to stable (v4) — that's the branch the reporting customer is on. Stable has drifted here (no SerializationError class, older suspension handler), so the backport will likely need conflict resolution; if the catchable behavior doesn't port cleanly, the minimal stable fix is to treat serialization errors as fatal in stable's suspension catch so runs fail fast instead of burning 48 deliveries.

Follow-up

A separate PR will add user-registerable workflow lifecycle hooks (onRunCompleted / onRunFailed passing the lazily-hydrated Run instance and, for failures, the hydrated fatal error with its error code) so runtime-level failures can be reported to e.g. Sentry from a central place such as instrumentation.ts.

Docs Preview

PagePreview
Errors → serialization-failed ("Where the Error Surfaces")/v5/docs/errors/serialization-failed#where-the-error-surfaces
Foundations → Errors and Retries ("Serialization Failures")/v5/docs/foundations/errors-and-retries#serialization-failures

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

…kflow code
A step whose arguments fail to serialize is now finalized by the
suspension handler as step_created + step_failed (mirroring a step-body
failure) instead of rejecting the whole suspension. The next replay —
forced in-process, since no step message is dispatched for the failed
step — rejects the step's promise with the SerializationError, so a
try/catch around the step call observes it. Uncaught, the error
propagates out of the workflow body and fails the run as a fatal
USER_ERROR immediately, instead of redelivering the orchestrator
message until max deliveries (49/48) as reported in production on v4.
@TooTallNate
TooTallNate requested a review from a team as a code ownerAugust 19, 2026 21:08
CopilotAI lite review requested due to automatic review settings August 19, 2026 21:08
@changeset-bot

changeset-botBot commented Aug 19, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 00b30cf

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

This PR includes changesets to release 16 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

vercelBot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewAug 19, 2026 11:17pm
example-nextjs-workflow-webpackReadyReadyPreviewAug 19, 2026 11:17pm
example-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-astro-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-express-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-fastify-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-hono-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-nestjs-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-nitro-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-nuxt-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-python-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-sveltekit-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-tanstack-start-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-vite-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workflow-docsReadyReadyPreview, v0Aug 19, 2026 11:17pm
workflow-swc-playgroundReadyReadyPreviewAug 19, 2026 11:17pm
workflow-tarballsReadyReadyPreviewAug 19, 2026 11:17pm
workflow-webReadyReadyPreviewAug 19, 2026 11:17pm

CopilotAI 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

This PR fixes a workflow-runtime observability gap where step argument serialization failures occurred after the workflow parked, causing repeated redeliveries and making the failure uncatchable from "use workflow" code. It changes suspension handling so step-argument serialization failures are finalized as step failures (step_created + step_failed), allowing workflows to catch SerializationError the same way they can catch step-body failures and step return-value serialization failures.

Changes:

  • Catch SerializationError from step-argument dehydration in handleSuspension, finalize the step as failed, and report failed step correlation IDs.
  • Force an in-process cold replay when any step-argument serialization failures were finalized, ensuring the workflow observes the step_failed event.
  • Add unit + e2e coverage for caught/uncaught step-argument and step return-value serialization failures, plus a changeset entry.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
FileDescription
workbench/example/workflows/99_e2e.tsAdds workflow/step fixtures used by the e2e tests to trigger step-arg and step-return serialization failures (caught and uncaught variants).
packages/core/src/runtime/suspension-handler.tsImplements per-step handling of step-argument serialization failures by writing step_created (placeholder input) + step_failed, and returns failedStepCorrelationIds.
packages/core/src/runtime/suspension-handler.test.tsAdds unit tests covering single/batched scenarios, payload round-trip, and conflict/run-expired tolerances.
packages/core/src/runtime.tsForces an in-process replay when failedStepCorrelationIds is non-empty so workflow code can observe the failure deterministically.
packages/core/e2e/e2e.test.tsAdds end-to-end regression coverage for the new catchable behavior and for “fail fast as USER_ERROR” semantics.
.changeset/catchable-step-arg-serialization-errors.mdDocuments the user-facing behavior change as a patch bump for @workflow/core.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment threadpackages/core/src/runtime/suspension-handler.ts
…geset
Addresses review feedback: dehydrateStepError in
finalizeUnserializableStep now receives suspension.globalThis like every
other dehydration in this file. Error detection is realm-independent, so
the host-created SerializationError serializes identically, but VM-realm
values guest code threw into the cause chain are now detected by the
realm-sensitive reducers.
…ep-arg-serialization-errors
# Conflicts:
#	packages/core/src/runtime/suspension-handler.test.ts
Comment threadpackages/core/src/runtime/suspension-handler.ts
- A crash or transient failure between finalization's two durable
writes leaves a lone placeholder step_created, and redelivery then
dispatches the step through normal crash recovery — previously
running user code with the placeholder arguments. The placeholder
now carries a structural flag on the input triple's top level (which
user code never controls, so no false positives), and the step
executor checks it after hydration: instead of running the body, it
throws the intended fatal SerializationError, completing the
interrupted finalization as step_failed. Applies to both engines
(they share the placeholder and the executor).
- Regression tests: executor fails a placeholder-input step without
running the body (and doesn't trip on a genuine argument equal to
the display marker); handleSuspension rejects for redelivery when
step_failed can't be written after step_created landed, leaving the
recoverable placeholder behind; mixed bad-step + large fan-out
returns the failure set alongside still-pending deferredBatchWork
whose rejection surfaces — the contract the runtime's failed-step
join (added previously) relies on.
- Docs: the two new code samples are now self-contained so the docs
code-sample typecheck passes.

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

AI Review: Re-reviewed the latest head. The interrupted-finalization placeholder is now safely recovered without executing user code, deferred batch work is joined before replay, QuickJS has equivalent behavior, and targeted regression coverage is present. No blocking issues remain.

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI review: no blocking issues

// finalizeUnserializableStep. In the terminal drain
// (finalizeUnserializableSteps unset), skip entirely — see the
// param docs.
if (step.serializationError) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Verified this round, against the head SHA:

  • CI is green on 00b30cf — all 163 checks pass, E2E Required Check included, and all 57 QuickJS lanes (Local Dev / Postgres / Prod, Windows, Vercel Prod, Multi-Region) that were red last round now pass.
  • Locally, nextjs-turbopack dev server with WORKFLOW_VM=quickjs: the four serialization e2e tests pass, and the dev log shows this path firing (Step arguments failed to serialize; failing the step so the workflow can observe the error, three runs) — so the QuickJS engine really is exercising the new finalize rather than passing by luck.
  • Added a fan-out fixture the current e2e fixtures don't cover: Promise.allSettled([badStep, ...five healthy steps]), so the bad step coexists with pair-folded inline claims and eager batched creates. Green on both engines, and the event log is exactly what the design predicts:
6 step_created (5 healthy + 1 placeholder)
5 step_started (one per healthy step — no double claim)
5 step_completed
1 step_failed
1 run_completed run status: completed, ~4.6s wall, no stall

with the workflow observing ['rejected','fulfilled','fulfilled','fulfilled','fulfilled','fulfilled'] and SerializationError on the rejected one. That settles what was behind my earlier point (2): healthy siblings are neither stranded nor executed twice, and the pass costs one extra replay rather than a stall. Not committed.

* Absent when {@link serializationError} is set — the input is precisely
* what refused to serialize.
*/
input?: Uint8Array;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Nit

Making input optional doesn't get you a compiler guard at either deref site: encrypt(data: Uint8Array | unknown, …) returns its argument unchanged when it isn't a Uint8Array, so an undefined input would sail through encryptSerializedData(step.input, …) in dispatchPendingOps (and through lazyStepInput on the inline path) as undefined rather than failing to typecheck. Both sites are correct today — the serializationError early return covers one, healthySteps the other — but the invariant rests on those two filters, not on the type. A narrowing throw at the deref would turn a future caller's mistake into a build error instead of a step created with no input.

@github-actionsgithub-actionsBot mentioned this pull request Aug 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Backport PR opened against stable: #3687. Merge conflicts were resolved by AI — please review carefully. (backport job run)

Sign up for freeto 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.

4 participants

@TooTallNate@VaguelySerious@karthikscale3
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(core): make step-argument serialization failures catchable in workflow code by TooTallNate · Pull Request #3675 · vercel/workflow · GitHub
Skip to content

fix(core): make step-argument serialization failures catchable in workflow code - #3675

Merged
TooTallNate merged 6 commits into
mainfrom
nrajlich/catchable-step-arg-serialization-errors
Aug 20, 2026
Merged

fix(core): make step-argument serialization failures catchable in workflow code#3675
TooTallNate merged 6 commits into
mainfrom
nrajlich/catchable-step-arg-serialization-errors

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Aug 19, 2026

Copy link
Copy Markdown
Member

Problem

A customer reported that passing an unserializable value to a step produced

Queue callback error: Error [WorkflowRuntimeError]: Failed to serialize step arguments at path …
[Workflow] Workflow handler exceeded max deliveries (49/48)

The failure happens in the host after the workflow function has parked, so a try/catch in the "use workflow" body never sees it — the run just redelivers until max-deliveries exhaustion (on v4; on main since #1849 it fails fast, but is still unobservable from workflow code).

This is asymmetric with step return-value serialization failures, which already write a step_failed event and reject into the workflow catchably.

Fix

handleSuspension now catches the SerializationError from dehydrateStepArguments per-step and, instead of rejecting the whole suspension:

  • writes step_created with a placeholder input (every World requires the step entity to exist before a terminal step event — and the real input is precisely what refused to serialize), then step_failed carrying the dehydrated SerializationError — the same two-sequential-createGuarded shape as the hook token-group ops
  • drops the step out of the lazy-inline batch and the createBatch fan-out fold (which only accepts creation events); healthy sibling steps proceed normally
  • reports the step in a new failedStepCorrelationIds result field; the runtime then forces an in-process replay over the reloaded log (nothing else would re-invoke the run when the failed step was the only pending work), demoting any retained VM session to a cold replay
  • tolerates EntityConflictError / RunExpiredError on both writes (concurrent handlers race toward the same deterministic failure)
  • non-SerializationError failures (e.g. RuntimeDecryptionError) keep their current behavior and RUNTIME_ERROR identity

Resulting semantics:

  • Caught: the replay rejects the step's promise via the existing step_failed consumer; a try/catch around the await step(...) observes the SerializationError (e.g. to report to Sentry from application code)
  • Uncaught: the error propagates out of the workflow body and the run fails immediately as a fatal USER_ERROR — no queue-redelivery retry loop

Tests

  • Unit (suspension-handler.test.ts): single bad step, bad + healthy siblings, batched fan-out drop-out, error payload round-trip via hydrateStepError, concurrent-finalization conflict tolerance, run-expired skip
  • e2e (new fixtures in 99_e2e.ts, symlinked to all workbenches): caught/uncaught × step-args/step-return-value — the return-value side already worked but had no e2e coverage. The uncaught-args test is the regression check for the production failure mode (asserts USER_ERROR, not MAX_DELIVERIES_EXCEEDED, well within a 60s timeout)

Verified locally: full packages/core unit suite (2185 passed), and e2e against a local nextjs-turbopack dev server (serialization failure, FatalError, and parallel slices all green).

Backport

This is a stability fix and should backport to stable (v4) — that's the branch the reporting customer is on. Stable has drifted here (no SerializationError class, older suspension handler), so the backport will likely need conflict resolution; if the catchable behavior doesn't port cleanly, the minimal stable fix is to treat serialization errors as fatal in stable's suspension catch so runs fail fast instead of burning 48 deliveries.

Follow-up

A separate PR will add user-registerable workflow lifecycle hooks (onRunCompleted / onRunFailed passing the lazily-hydrated Run instance and, for failures, the hydrated fatal error with its error code) so runtime-level failures can be reported to e.g. Sentry from a central place such as instrumentation.ts.

Docs Preview

PagePreview
Errors → serialization-failed ("Where the Error Surfaces")/v5/docs/errors/serialization-failed#where-the-error-surfaces
Foundations → Errors and Retries ("Serialization Failures")/v5/docs/foundations/errors-and-retries#serialization-failures

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

…kflow code
A step whose arguments fail to serialize is now finalized by the
suspension handler as step_created + step_failed (mirroring a step-body
failure) instead of rejecting the whole suspension. The next replay —
forced in-process, since no step message is dispatched for the failed
step — rejects the step's promise with the SerializationError, so a
try/catch around the step call observes it. Uncaught, the error
propagates out of the workflow body and fails the run as a fatal
USER_ERROR immediately, instead of redelivering the orchestrator
message until max deliveries (49/48) as reported in production on v4.
@TooTallNate
TooTallNate requested a review from a team as a code ownerAugust 19, 2026 21:08
CopilotAI lite review requested due to automatic review settings August 19, 2026 21:08
@changeset-bot

changeset-botBot commented Aug 19, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 00b30cf

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

This PR includes changesets to release 16 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

vercelBot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewAug 19, 2026 11:17pm
example-nextjs-workflow-webpackReadyReadyPreviewAug 19, 2026 11:17pm
example-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-astro-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-express-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-fastify-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-hono-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-nestjs-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-nitro-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-nuxt-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-python-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-sveltekit-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-tanstack-start-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-vite-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workflow-docsReadyReadyPreview, v0Aug 19, 2026 11:17pm
workflow-swc-playgroundReadyReadyPreviewAug 19, 2026 11:17pm
workflow-tarballsReadyReadyPreviewAug 19, 2026 11:17pm
workflow-webReadyReadyPreviewAug 19, 2026 11:17pm

CopilotAI 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

This PR fixes a workflow-runtime observability gap where step argument serialization failures occurred after the workflow parked, causing repeated redeliveries and making the failure uncatchable from "use workflow" code. It changes suspension handling so step-argument serialization failures are finalized as step failures (step_created + step_failed), allowing workflows to catch SerializationError the same way they can catch step-body failures and step return-value serialization failures.

Changes:

  • Catch SerializationError from step-argument dehydration in handleSuspension, finalize the step as failed, and report failed step correlation IDs.
  • Force an in-process cold replay when any step-argument serialization failures were finalized, ensuring the workflow observes the step_failed event.
  • Add unit + e2e coverage for caught/uncaught step-argument and step return-value serialization failures, plus a changeset entry.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
FileDescription
workbench/example/workflows/99_e2e.tsAdds workflow/step fixtures used by the e2e tests to trigger step-arg and step-return serialization failures (caught and uncaught variants).
packages/core/src/runtime/suspension-handler.tsImplements per-step handling of step-argument serialization failures by writing step_created (placeholder input) + step_failed, and returns failedStepCorrelationIds.
packages/core/src/runtime/suspension-handler.test.tsAdds unit tests covering single/batched scenarios, payload round-trip, and conflict/run-expired tolerances.
packages/core/src/runtime.tsForces an in-process replay when failedStepCorrelationIds is non-empty so workflow code can observe the failure deterministically.
packages/core/e2e/e2e.test.tsAdds end-to-end regression coverage for the new catchable behavior and for “fail fast as USER_ERROR” semantics.
.changeset/catchable-step-arg-serialization-errors.mdDocuments the user-facing behavior change as a patch bump for @workflow/core.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment threadpackages/core/src/runtime/suspension-handler.ts
…geset
Addresses review feedback: dehydrateStepError in
finalizeUnserializableStep now receives suspension.globalThis like every
other dehydration in this file. Error detection is realm-independent, so
the host-created SerializationError serializes identically, but VM-realm
values guest code threw into the cause chain are now detected by the
realm-sensitive reducers.
…ep-arg-serialization-errors
# Conflicts:
#	packages/core/src/runtime/suspension-handler.test.ts
Comment threadpackages/core/src/runtime/suspension-handler.ts
- A crash or transient failure between finalization's two durable
writes leaves a lone placeholder step_created, and redelivery then
dispatches the step through normal crash recovery — previously
running user code with the placeholder arguments. The placeholder
now carries a structural flag on the input triple's top level (which
user code never controls, so no false positives), and the step
executor checks it after hydration: instead of running the body, it
throws the intended fatal SerializationError, completing the
interrupted finalization as step_failed. Applies to both engines
(they share the placeholder and the executor).
- Regression tests: executor fails a placeholder-input step without
running the body (and doesn't trip on a genuine argument equal to
the display marker); handleSuspension rejects for redelivery when
step_failed can't be written after step_created landed, leaving the
recoverable placeholder behind; mixed bad-step + large fan-out
returns the failure set alongside still-pending deferredBatchWork
whose rejection surfaces — the contract the runtime's failed-step
join (added previously) relies on.
- Docs: the two new code samples are now self-contained so the docs
code-sample typecheck passes.

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

AI Review: Re-reviewed the latest head. The interrupted-finalization placeholder is now safely recovered without executing user code, deferred batch work is joined before replay, QuickJS has equivalent behavior, and targeted regression coverage is present. No blocking issues remain.

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI review: no blocking issues

// finalizeUnserializableStep. In the terminal drain
// (finalizeUnserializableSteps unset), skip entirely — see the
// param docs.
if (step.serializationError) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Verified this round, against the head SHA:

  • CI is green on 00b30cf — all 163 checks pass, E2E Required Check included, and all 57 QuickJS lanes (Local Dev / Postgres / Prod, Windows, Vercel Prod, Multi-Region) that were red last round now pass.
  • Locally, nextjs-turbopack dev server with WORKFLOW_VM=quickjs: the four serialization e2e tests pass, and the dev log shows this path firing (Step arguments failed to serialize; failing the step so the workflow can observe the error, three runs) — so the QuickJS engine really is exercising the new finalize rather than passing by luck.
  • Added a fan-out fixture the current e2e fixtures don't cover: Promise.allSettled([badStep, ...five healthy steps]), so the bad step coexists with pair-folded inline claims and eager batched creates. Green on both engines, and the event log is exactly what the design predicts:
6 step_created (5 healthy + 1 placeholder)
5 step_started (one per healthy step — no double claim)
5 step_completed
1 step_failed
1 run_completed run status: completed, ~4.6s wall, no stall

with the workflow observing ['rejected','fulfilled','fulfilled','fulfilled','fulfilled','fulfilled'] and SerializationError on the rejected one. That settles what was behind my earlier point (2): healthy siblings are neither stranded nor executed twice, and the pass costs one extra replay rather than a stall. Not committed.

* Absent when {@link serializationError} is set — the input is precisely
* what refused to serialize.
*/
input?: Uint8Array;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Nit

Making input optional doesn't get you a compiler guard at either deref site: encrypt(data: Uint8Array | unknown, …) returns its argument unchanged when it isn't a Uint8Array, so an undefined input would sail through encryptSerializedData(step.input, …) in dispatchPendingOps (and through lazyStepInput on the inline path) as undefined rather than failing to typecheck. Both sites are correct today — the serializationError early return covers one, healthySteps the other — but the invariant rests on those two filters, not on the type. A narrowing throw at the deref would turn a future caller's mistake into a build error instead of a step created with no input.

@github-actionsgithub-actionsBot mentioned this pull request Aug 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Backport PR opened against stable: #3687. Merge conflicts were resolved by AI — please review carefully. (backport job run)

Sign up for freeto 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.

4 participants

@TooTallNate@VaguelySerious@karthikscale3
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(core): make step-argument serialization failures catchable in workflow code by TooTallNate · Pull Request #3675 · vercel/workflow · GitHub
Skip to content

fix(core): make step-argument serialization failures catchable in workflow code - #3675

Merged
TooTallNate merged 6 commits into
mainfrom
nrajlich/catchable-step-arg-serialization-errors
Aug 20, 2026
Merged

fix(core): make step-argument serialization failures catchable in workflow code#3675
TooTallNate merged 6 commits into
mainfrom
nrajlich/catchable-step-arg-serialization-errors

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Aug 19, 2026

Copy link
Copy Markdown
Member

Problem

A customer reported that passing an unserializable value to a step produced

Queue callback error: Error [WorkflowRuntimeError]: Failed to serialize step arguments at path …
[Workflow] Workflow handler exceeded max deliveries (49/48)

The failure happens in the host after the workflow function has parked, so a try/catch in the "use workflow" body never sees it — the run just redelivers until max-deliveries exhaustion (on v4; on main since #1849 it fails fast, but is still unobservable from workflow code).

This is asymmetric with step return-value serialization failures, which already write a step_failed event and reject into the workflow catchably.

Fix

handleSuspension now catches the SerializationError from dehydrateStepArguments per-step and, instead of rejecting the whole suspension:

  • writes step_created with a placeholder input (every World requires the step entity to exist before a terminal step event — and the real input is precisely what refused to serialize), then step_failed carrying the dehydrated SerializationError — the same two-sequential-createGuarded shape as the hook token-group ops
  • drops the step out of the lazy-inline batch and the createBatch fan-out fold (which only accepts creation events); healthy sibling steps proceed normally
  • reports the step in a new failedStepCorrelationIds result field; the runtime then forces an in-process replay over the reloaded log (nothing else would re-invoke the run when the failed step was the only pending work), demoting any retained VM session to a cold replay
  • tolerates EntityConflictError / RunExpiredError on both writes (concurrent handlers race toward the same deterministic failure)
  • non-SerializationError failures (e.g. RuntimeDecryptionError) keep their current behavior and RUNTIME_ERROR identity

Resulting semantics:

  • Caught: the replay rejects the step's promise via the existing step_failed consumer; a try/catch around the await step(...) observes the SerializationError (e.g. to report to Sentry from application code)
  • Uncaught: the error propagates out of the workflow body and the run fails immediately as a fatal USER_ERROR — no queue-redelivery retry loop

Tests

  • Unit (suspension-handler.test.ts): single bad step, bad + healthy siblings, batched fan-out drop-out, error payload round-trip via hydrateStepError, concurrent-finalization conflict tolerance, run-expired skip
  • e2e (new fixtures in 99_e2e.ts, symlinked to all workbenches): caught/uncaught × step-args/step-return-value — the return-value side already worked but had no e2e coverage. The uncaught-args test is the regression check for the production failure mode (asserts USER_ERROR, not MAX_DELIVERIES_EXCEEDED, well within a 60s timeout)

Verified locally: full packages/core unit suite (2185 passed), and e2e against a local nextjs-turbopack dev server (serialization failure, FatalError, and parallel slices all green).

Backport

This is a stability fix and should backport to stable (v4) — that's the branch the reporting customer is on. Stable has drifted here (no SerializationError class, older suspension handler), so the backport will likely need conflict resolution; if the catchable behavior doesn't port cleanly, the minimal stable fix is to treat serialization errors as fatal in stable's suspension catch so runs fail fast instead of burning 48 deliveries.

Follow-up

A separate PR will add user-registerable workflow lifecycle hooks (onRunCompleted / onRunFailed passing the lazily-hydrated Run instance and, for failures, the hydrated fatal error with its error code) so runtime-level failures can be reported to e.g. Sentry from a central place such as instrumentation.ts.

Docs Preview

PagePreview
Errors → serialization-failed ("Where the Error Surfaces")/v5/docs/errors/serialization-failed#where-the-error-surfaces
Foundations → Errors and Retries ("Serialization Failures")/v5/docs/foundations/errors-and-retries#serialization-failures

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

…kflow code
A step whose arguments fail to serialize is now finalized by the
suspension handler as step_created + step_failed (mirroring a step-body
failure) instead of rejecting the whole suspension. The next replay —
forced in-process, since no step message is dispatched for the failed
step — rejects the step's promise with the SerializationError, so a
try/catch around the step call observes it. Uncaught, the error
propagates out of the workflow body and fails the run as a fatal
USER_ERROR immediately, instead of redelivering the orchestrator
message until max deliveries (49/48) as reported in production on v4.
@TooTallNate
TooTallNate requested a review from a team as a code ownerAugust 19, 2026 21:08
CopilotAI lite review requested due to automatic review settings August 19, 2026 21:08
@changeset-bot

changeset-botBot commented Aug 19, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 00b30cf

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

This PR includes changesets to release 16 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

vercelBot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewAug 19, 2026 11:17pm
example-nextjs-workflow-webpackReadyReadyPreviewAug 19, 2026 11:17pm
example-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-astro-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-express-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-fastify-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-hono-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-nestjs-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-nitro-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-nuxt-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-python-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-sveltekit-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-tanstack-start-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-vite-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workflow-docsReadyReadyPreview, v0Aug 19, 2026 11:17pm
workflow-swc-playgroundReadyReadyPreviewAug 19, 2026 11:17pm
workflow-tarballsReadyReadyPreviewAug 19, 2026 11:17pm
workflow-webReadyReadyPreviewAug 19, 2026 11:17pm

CopilotAI 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

This PR fixes a workflow-runtime observability gap where step argument serialization failures occurred after the workflow parked, causing repeated redeliveries and making the failure uncatchable from "use workflow" code. It changes suspension handling so step-argument serialization failures are finalized as step failures (step_created + step_failed), allowing workflows to catch SerializationError the same way they can catch step-body failures and step return-value serialization failures.

Changes:

  • Catch SerializationError from step-argument dehydration in handleSuspension, finalize the step as failed, and report failed step correlation IDs.
  • Force an in-process cold replay when any step-argument serialization failures were finalized, ensuring the workflow observes the step_failed event.
  • Add unit + e2e coverage for caught/uncaught step-argument and step return-value serialization failures, plus a changeset entry.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
FileDescription
workbench/example/workflows/99_e2e.tsAdds workflow/step fixtures used by the e2e tests to trigger step-arg and step-return serialization failures (caught and uncaught variants).
packages/core/src/runtime/suspension-handler.tsImplements per-step handling of step-argument serialization failures by writing step_created (placeholder input) + step_failed, and returns failedStepCorrelationIds.
packages/core/src/runtime/suspension-handler.test.tsAdds unit tests covering single/batched scenarios, payload round-trip, and conflict/run-expired tolerances.
packages/core/src/runtime.tsForces an in-process replay when failedStepCorrelationIds is non-empty so workflow code can observe the failure deterministically.
packages/core/e2e/e2e.test.tsAdds end-to-end regression coverage for the new catchable behavior and for “fail fast as USER_ERROR” semantics.
.changeset/catchable-step-arg-serialization-errors.mdDocuments the user-facing behavior change as a patch bump for @workflow/core.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment threadpackages/core/src/runtime/suspension-handler.ts
…geset
Addresses review feedback: dehydrateStepError in
finalizeUnserializableStep now receives suspension.globalThis like every
other dehydration in this file. Error detection is realm-independent, so
the host-created SerializationError serializes identically, but VM-realm
values guest code threw into the cause chain are now detected by the
realm-sensitive reducers.
…ep-arg-serialization-errors
# Conflicts:
#	packages/core/src/runtime/suspension-handler.test.ts
Comment threadpackages/core/src/runtime/suspension-handler.ts
- A crash or transient failure between finalization's two durable
writes leaves a lone placeholder step_created, and redelivery then
dispatches the step through normal crash recovery — previously
running user code with the placeholder arguments. The placeholder
now carries a structural flag on the input triple's top level (which
user code never controls, so no false positives), and the step
executor checks it after hydration: instead of running the body, it
throws the intended fatal SerializationError, completing the
interrupted finalization as step_failed. Applies to both engines
(they share the placeholder and the executor).
- Regression tests: executor fails a placeholder-input step without
running the body (and doesn't trip on a genuine argument equal to
the display marker); handleSuspension rejects for redelivery when
step_failed can't be written after step_created landed, leaving the
recoverable placeholder behind; mixed bad-step + large fan-out
returns the failure set alongside still-pending deferredBatchWork
whose rejection surfaces — the contract the runtime's failed-step
join (added previously) relies on.
- Docs: the two new code samples are now self-contained so the docs
code-sample typecheck passes.

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

AI Review: Re-reviewed the latest head. The interrupted-finalization placeholder is now safely recovered without executing user code, deferred batch work is joined before replay, QuickJS has equivalent behavior, and targeted regression coverage is present. No blocking issues remain.

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI review: no blocking issues

// finalizeUnserializableStep. In the terminal drain
// (finalizeUnserializableSteps unset), skip entirely — see the
// param docs.
if (step.serializationError) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Verified this round, against the head SHA:

  • CI is green on 00b30cf — all 163 checks pass, E2E Required Check included, and all 57 QuickJS lanes (Local Dev / Postgres / Prod, Windows, Vercel Prod, Multi-Region) that were red last round now pass.
  • Locally, nextjs-turbopack dev server with WORKFLOW_VM=quickjs: the four serialization e2e tests pass, and the dev log shows this path firing (Step arguments failed to serialize; failing the step so the workflow can observe the error, three runs) — so the QuickJS engine really is exercising the new finalize rather than passing by luck.
  • Added a fan-out fixture the current e2e fixtures don't cover: Promise.allSettled([badStep, ...five healthy steps]), so the bad step coexists with pair-folded inline claims and eager batched creates. Green on both engines, and the event log is exactly what the design predicts:
6 step_created (5 healthy + 1 placeholder)
5 step_started (one per healthy step — no double claim)
5 step_completed
1 step_failed
1 run_completed run status: completed, ~4.6s wall, no stall

with the workflow observing ['rejected','fulfilled','fulfilled','fulfilled','fulfilled','fulfilled'] and SerializationError on the rejected one. That settles what was behind my earlier point (2): healthy siblings are neither stranded nor executed twice, and the pass costs one extra replay rather than a stall. Not committed.

* Absent when {@link serializationError} is set — the input is precisely
* what refused to serialize.
*/
input?: Uint8Array;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Nit

Making input optional doesn't get you a compiler guard at either deref site: encrypt(data: Uint8Array | unknown, …) returns its argument unchanged when it isn't a Uint8Array, so an undefined input would sail through encryptSerializedData(step.input, …) in dispatchPendingOps (and through lazyStepInput on the inline path) as undefined rather than failing to typecheck. Both sites are correct today — the serializationError early return covers one, healthySteps the other — but the invariant rests on those two filters, not on the type. A narrowing throw at the deref would turn a future caller's mistake into a build error instead of a step created with no input.

@github-actionsgithub-actionsBot mentioned this pull request Aug 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Backport PR opened against stable: #3687. Merge conflicts were resolved by AI — please review carefully. (backport job run)

Sign up for freeto 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.

4 participants

@TooTallNate@VaguelySerious@karthikscale3
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(core): make step-argument serialization failures catchable in workflow code by TooTallNate · Pull Request #3675 · vercel/workflow · GitHub
Skip to content

fix(core): make step-argument serialization failures catchable in workflow code - #3675

Merged
TooTallNate merged 6 commits into
mainfrom
nrajlich/catchable-step-arg-serialization-errors
Aug 20, 2026
Merged

fix(core): make step-argument serialization failures catchable in workflow code#3675
TooTallNate merged 6 commits into
mainfrom
nrajlich/catchable-step-arg-serialization-errors

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Aug 19, 2026

Copy link
Copy Markdown
Member

Problem

A customer reported that passing an unserializable value to a step produced

Queue callback error: Error [WorkflowRuntimeError]: Failed to serialize step arguments at path …
[Workflow] Workflow handler exceeded max deliveries (49/48)

The failure happens in the host after the workflow function has parked, so a try/catch in the "use workflow" body never sees it — the run just redelivers until max-deliveries exhaustion (on v4; on main since #1849 it fails fast, but is still unobservable from workflow code).

This is asymmetric with step return-value serialization failures, which already write a step_failed event and reject into the workflow catchably.

Fix

handleSuspension now catches the SerializationError from dehydrateStepArguments per-step and, instead of rejecting the whole suspension:

  • writes step_created with a placeholder input (every World requires the step entity to exist before a terminal step event — and the real input is precisely what refused to serialize), then step_failed carrying the dehydrated SerializationError — the same two-sequential-createGuarded shape as the hook token-group ops
  • drops the step out of the lazy-inline batch and the createBatch fan-out fold (which only accepts creation events); healthy sibling steps proceed normally
  • reports the step in a new failedStepCorrelationIds result field; the runtime then forces an in-process replay over the reloaded log (nothing else would re-invoke the run when the failed step was the only pending work), demoting any retained VM session to a cold replay
  • tolerates EntityConflictError / RunExpiredError on both writes (concurrent handlers race toward the same deterministic failure)
  • non-SerializationError failures (e.g. RuntimeDecryptionError) keep their current behavior and RUNTIME_ERROR identity

Resulting semantics:

  • Caught: the replay rejects the step's promise via the existing step_failed consumer; a try/catch around the await step(...) observes the SerializationError (e.g. to report to Sentry from application code)
  • Uncaught: the error propagates out of the workflow body and the run fails immediately as a fatal USER_ERROR — no queue-redelivery retry loop

Tests

  • Unit (suspension-handler.test.ts): single bad step, bad + healthy siblings, batched fan-out drop-out, error payload round-trip via hydrateStepError, concurrent-finalization conflict tolerance, run-expired skip
  • e2e (new fixtures in 99_e2e.ts, symlinked to all workbenches): caught/uncaught × step-args/step-return-value — the return-value side already worked but had no e2e coverage. The uncaught-args test is the regression check for the production failure mode (asserts USER_ERROR, not MAX_DELIVERIES_EXCEEDED, well within a 60s timeout)

Verified locally: full packages/core unit suite (2185 passed), and e2e against a local nextjs-turbopack dev server (serialization failure, FatalError, and parallel slices all green).

Backport

This is a stability fix and should backport to stable (v4) — that's the branch the reporting customer is on. Stable has drifted here (no SerializationError class, older suspension handler), so the backport will likely need conflict resolution; if the catchable behavior doesn't port cleanly, the minimal stable fix is to treat serialization errors as fatal in stable's suspension catch so runs fail fast instead of burning 48 deliveries.

Follow-up

A separate PR will add user-registerable workflow lifecycle hooks (onRunCompleted / onRunFailed passing the lazily-hydrated Run instance and, for failures, the hydrated fatal error with its error code) so runtime-level failures can be reported to e.g. Sentry from a central place such as instrumentation.ts.

Docs Preview

PagePreview
Errors → serialization-failed ("Where the Error Surfaces")/v5/docs/errors/serialization-failed#where-the-error-surfaces
Foundations → Errors and Retries ("Serialization Failures")/v5/docs/foundations/errors-and-retries#serialization-failures

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

…kflow code
A step whose arguments fail to serialize is now finalized by the
suspension handler as step_created + step_failed (mirroring a step-body
failure) instead of rejecting the whole suspension. The next replay —
forced in-process, since no step message is dispatched for the failed
step — rejects the step's promise with the SerializationError, so a
try/catch around the step call observes it. Uncaught, the error
propagates out of the workflow body and fails the run as a fatal
USER_ERROR immediately, instead of redelivering the orchestrator
message until max deliveries (49/48) as reported in production on v4.
@TooTallNate
TooTallNate requested a review from a team as a code ownerAugust 19, 2026 21:08
CopilotAI lite review requested due to automatic review settings August 19, 2026 21:08
@changeset-bot

changeset-botBot commented Aug 19, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 00b30cf

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

This PR includes changesets to release 16 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

vercelBot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewAug 19, 2026 11:17pm
example-nextjs-workflow-webpackReadyReadyPreviewAug 19, 2026 11:17pm
example-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-astro-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-express-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-fastify-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-hono-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-nestjs-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-nitro-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-nuxt-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-python-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-sveltekit-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-tanstack-start-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workbench-vite-workflowReadyReadyPreviewAug 19, 2026 11:17pm
workflow-docsReadyReadyPreview, v0Aug 19, 2026 11:17pm
workflow-swc-playgroundReadyReadyPreviewAug 19, 2026 11:17pm
workflow-tarballsReadyReadyPreviewAug 19, 2026 11:17pm
workflow-webReadyReadyPreviewAug 19, 2026 11:17pm

CopilotAI 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

This PR fixes a workflow-runtime observability gap where step argument serialization failures occurred after the workflow parked, causing repeated redeliveries and making the failure uncatchable from "use workflow" code. It changes suspension handling so step-argument serialization failures are finalized as step failures (step_created + step_failed), allowing workflows to catch SerializationError the same way they can catch step-body failures and step return-value serialization failures.

Changes:

  • Catch SerializationError from step-argument dehydration in handleSuspension, finalize the step as failed, and report failed step correlation IDs.
  • Force an in-process cold replay when any step-argument serialization failures were finalized, ensuring the workflow observes the step_failed event.
  • Add unit + e2e coverage for caught/uncaught step-argument and step return-value serialization failures, plus a changeset entry.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
FileDescription
workbench/example/workflows/99_e2e.tsAdds workflow/step fixtures used by the e2e tests to trigger step-arg and step-return serialization failures (caught and uncaught variants).
packages/core/src/runtime/suspension-handler.tsImplements per-step handling of step-argument serialization failures by writing step_created (placeholder input) + step_failed, and returns failedStepCorrelationIds.
packages/core/src/runtime/suspension-handler.test.tsAdds unit tests covering single/batched scenarios, payload round-trip, and conflict/run-expired tolerances.
packages/core/src/runtime.tsForces an in-process replay when failedStepCorrelationIds is non-empty so workflow code can observe the failure deterministically.
packages/core/e2e/e2e.test.tsAdds end-to-end regression coverage for the new catchable behavior and for “fail fast as USER_ERROR” semantics.
.changeset/catchable-step-arg-serialization-errors.mdDocuments the user-facing behavior change as a patch bump for @workflow/core.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment threadpackages/core/src/runtime/suspension-handler.ts
…geset
Addresses review feedback: dehydrateStepError in
finalizeUnserializableStep now receives suspension.globalThis like every
other dehydration in this file. Error detection is realm-independent, so
the host-created SerializationError serializes identically, but VM-realm
values guest code threw into the cause chain are now detected by the
realm-sensitive reducers.
…ep-arg-serialization-errors
# Conflicts:
#	packages/core/src/runtime/suspension-handler.test.ts
Comment threadpackages/core/src/runtime/suspension-handler.ts
- A crash or transient failure between finalization's two durable
writes leaves a lone placeholder step_created, and redelivery then
dispatches the step through normal crash recovery — previously
running user code with the placeholder arguments. The placeholder
now carries a structural flag on the input triple's top level (which
user code never controls, so no false positives), and the step
executor checks it after hydration: instead of running the body, it
throws the intended fatal SerializationError, completing the
interrupted finalization as step_failed. Applies to both engines
(they share the placeholder and the executor).
- Regression tests: executor fails a placeholder-input step without
running the body (and doesn't trip on a genuine argument equal to
the display marker); handleSuspension rejects for redelivery when
step_failed can't be written after step_created landed, leaving the
recoverable placeholder behind; mixed bad-step + large fan-out
returns the failure set alongside still-pending deferredBatchWork
whose rejection surfaces — the contract the runtime's failed-step
join (added previously) relies on.
- Docs: the two new code samples are now self-contained so the docs
code-sample typecheck passes.

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

AI Review: Re-reviewed the latest head. The interrupted-finalization placeholder is now safely recovered without executing user code, deferred batch work is joined before replay, QuickJS has equivalent behavior, and targeted regression coverage is present. No blocking issues remain.

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI review: no blocking issues

// finalizeUnserializableStep. In the terminal drain
// (finalizeUnserializableSteps unset), skip entirely — see the
// param docs.
if (step.serializationError) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Verified this round, against the head SHA:

  • CI is green on 00b30cf — all 163 checks pass, E2E Required Check included, and all 57 QuickJS lanes (Local Dev / Postgres / Prod, Windows, Vercel Prod, Multi-Region) that were red last round now pass.
  • Locally, nextjs-turbopack dev server with WORKFLOW_VM=quickjs: the four serialization e2e tests pass, and the dev log shows this path firing (Step arguments failed to serialize; failing the step so the workflow can observe the error, three runs) — so the QuickJS engine really is exercising the new finalize rather than passing by luck.
  • Added a fan-out fixture the current e2e fixtures don't cover: Promise.allSettled([badStep, ...five healthy steps]), so the bad step coexists with pair-folded inline claims and eager batched creates. Green on both engines, and the event log is exactly what the design predicts:
6 step_created (5 healthy + 1 placeholder)
5 step_started (one per healthy step — no double claim)
5 step_completed
1 step_failed
1 run_completed run status: completed, ~4.6s wall, no stall

with the workflow observing ['rejected','fulfilled','fulfilled','fulfilled','fulfilled','fulfilled'] and SerializationError on the rejected one. That settles what was behind my earlier point (2): healthy siblings are neither stranded nor executed twice, and the pass costs one extra replay rather than a stall. Not committed.

* Absent when {@link serializationError} is set — the input is precisely
* what refused to serialize.
*/
input?: Uint8Array;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Nit

Making input optional doesn't get you a compiler guard at either deref site: encrypt(data: Uint8Array | unknown, …) returns its argument unchanged when it isn't a Uint8Array, so an undefined input would sail through encryptSerializedData(step.input, …) in dispatchPendingOps (and through lazyStepInput on the inline path) as undefined rather than failing to typecheck. Both sites are correct today — the serializationError early return covers one, healthySteps the other — but the invariant rests on those two filters, not on the type. A narrowing throw at the deref would turn a future caller's mistake into a build error instead of a step created with no input.

@github-actionsgithub-actionsBot mentioned this pull request Aug 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Backport PR opened against stable: #3687. Merge conflicts were resolved by AI — please review carefully. (backport job run)

Sign up for freeto 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.

4 participants

@TooTallNate@VaguelySerious@karthikscale3