Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-events-wait.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@workflow/web-shared': patch
---

Prevent event payload flicker during decryption.
18 changes: 18 additions & 0 deletions .changeset/durable-hook-resume.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@workflow/core': patch
'@workflow/world': patch
---

Make `resumeHook()` durable before it resolves: the `hook_received` event is
written durably first, and the workflow wake is published only after the write
is acknowledged. A disposal racing the queue delivery can no longer lose a
resume the caller was told succeeded. The wake message is unchanged, so no
consumer or backend coordination is needed; `WORKFLOW_DISABLE_LAZY_HOOK_RESUME`
is now a no-op and the internal `resumeHookDurable()` alias is removed.

Behavior changes: a resume against an ended run now throws `HookNotFoundError`
instead of resolving (the lazy path never observed the server's rejection, so a
late webhook delivery to a finished run answered 202 where it now answers 404).
A transient write conflict (HTTP 409, e.g. an event-slot conflict under
contention) is no longer re-keyed to `HookNotFoundError`: it surfaces as a
retryable error, since its transaction committed nothing.
5 changes: 5 additions & 0 deletions .changeset/late-hooks-rearm.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/core": patch
---

Keep late-claimed buffered hook payloads from being preempted by workflow suspension in retained VMs.
2 changes: 1 addition & 1 deletion .changeset/lazy-hook-resume-vitest.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
'@workflow/vitest': patch
---

`waitForHook()` accepts `notHookId` to skip a hook the caller already resumed, whose `hook_received` may not be written yet.
`waitForHook()` accepts `notHookId` to exclude a previously observed hook when a workflow creates several hooks with the same token.
5 changes: 5 additions & 0 deletions .changeset/overlap-workflow-compile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/core": patch
---

Compile Node.js workflow scripts while loading the replay event log.
7 changes: 7 additions & 0 deletions .changeset/prepare-streamed-replay-payloads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@workflow/core": patch
"@workflow/world": patch
"@workflow/world-vercel": patch
---

Prepare replay payloads as validated event frames arrive, reuse primitive step results across fresh VMs, and preserve replay startup overlap.
5 changes: 5 additions & 0 deletions .changeset/tidy-rivers-expand.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@workflow/web-shared': patch
---

Render Headers, URLSearchParams, and other iterables in the data inspector. Map-like values use `entries()`; other iterables render as lists.
5 changes: 5 additions & 0 deletions .changeset/trace-replay-phases.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/core": patch
---

Trace event loading, workflow VM creation, bundle compilation and evaluation, input hydration, and replay execution.
5 changes: 5 additions & 0 deletions .changeset/typed-replay-stream-failures.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@workflow/world-vercel': patch
---

Classify malformed replay responses as typed world failures and resume incomplete streams from their last validated event.
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ related:

Resumes a workflow run by sending a payload to a hook identified by its token.

It publishes a workflow invocation carrying the payload; the runtime creates the `hook_received` event and continues execution from it.
It durably writes the `hook_received` event and only then publishes a workflow wake. The call resolves only after both operations succeed, in that order.

`resumeHook()` throws `HookNotFoundError` when no hook holds the token. A run that has already ended cannot be resumed, including one whose Hook is kept by `experimental_minRetention`, but whether the call reports that depends on the path it takes: a resume dispatched without reading the run resolves and the ended state is only detected once the payload arrives, while one that reads the run, or that falls back to writing the event up front, throws `HookNotFoundError`. See [lazy hook resume](/docs/changelog/lazy-hook-resume).
`resumeHook()` throws `HookNotFoundError` when no hook holds the token or when its `hook_received` write is refused because the hook was disposed or the run ended. See [durable hook resume](/docs/changelog/lazy-hook-resume).

If `resumeHook()` throws any other error, the outcome is ambiguous only in dispatch, never in durability: the event may already be durable even though the workflow wake failed, and any later wake of the run delivers it. Calling `resumeHook()` again creates a new `resumeId` and can append a second `hook_received`. Callers that need at-most-once behavior across separate invocations must retain and deduplicate their own request key.

<Callout type="warn">
`resumeHook` is a runtime function that must be called from outside a workflow function.
Expand Down Expand Up @@ -50,7 +52,7 @@ showSections={["parameters"]}

### Returns

Returns a `Promise<ResumedHook>`, a `Hook` extended with an optional `resilientResume` flag. Resolving means the resume was accepted for delivery: the payload rides the workflow queue message and the runtime materializes the `hook_received` event from it before replaying (see the [lazy hook resume changelog](/docs/changelog/lazy-hook-resume)). `resilientResume` is retained for source compatibility and is no longer set by any path. The resolved hook:
Returns a `Promise<ResumedHook>`, a `Hook` extended with an optional `resilientResume` flag. Resolving means the payload is durably recorded as `hook_received` and the workflow wake was accepted. `resilientResume` is retained for source compatibility and is no longer set by any path. The resolved hook:

<TSDoc
definition={`
Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/v5/changelog/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Stay up to date with the latest changes to Workflow SDK.

## 2026

- [Lazy hook resume](/docs/changelog/lazy-hook-resume) (August 2026)
- [Durable hook resume](/docs/changelog/lazy-hook-resume) (August 2026)
- [Resilient hook resume](/docs/changelog/resilient-resume) (July 2026)
- [Eager processing of steps and incremental event replay](/docs/changelog/eager-processing) (March 2026)
- Serializable AbortController and AbortSignal (March 12, 2026)
93 changes: 60 additions & 33 deletions docs/content/docs/v5/changelog/lazy-hook-resume.mdx
Original file line number Diff line number Diff line change
@@ -1,51 +1,78 @@
---
title: Lazy hook resume
description: resumeHook() no longer writes hook_received itself. The queue consumer materializes the event from the message, so a resume costs one round trip.
title: Durable hook resume
description: resumeHook() durably writes hook_received and only then publishes the workflow wake, so a resolved call can never be lost to a disposal race.
---

# Lazy hook resume
# Durable hook resume

## Motivation

[Resilient hook resume](/docs/changelog/resilient-resume) made `resumeHook()` write the `hook_received` event and publish the workflow queue message concurrently, with the queue consumer re-ensuring the event from the message's `hookInput` before replay. Both sides then wrote the same event, and a `(runId, resumeId)` constraint collapsed them onto one.
The previous lazy path published the serialized hook payload on the workflow
queue and left the queue consumer to create `hook_received`. If the hook was
disposed after `resumeHook()` returned but before the consumer write committed,
that write was rejected and the acknowledged queue delivery could not resume the
workflow: the caller was told the resume succeeded, and it was lost.

Running the two concurrently removed the second round trip from the critical path, but the write itself stayed: every resume still spent a request on an event the consumer was about to write anyway, and the producer still had to classify its outcome (conflict, throttle, terminal run) to decide whether the resume had survived.

This change drops the producer's write entirely. On the lazy path `resumeHook()` publishes the queue message and nothing else.
`resumeHook()` now resolves only after both the durable event write and the
workflow wake have succeeded, in that order.

## Design

- `resumeHook()` publishes one message carrying `hookInput`: the dehydrated payload, a client-minted `resumeId`, the hook token, and a payload digest. It writes no event.
- The queue consumer materializes `hook_received` from `hookInput` before replay, keyed by `resumeId`. This is the same write it already performed; it is now the only one.
- The `(runId, resumeId)` constraint still matters: a queue redelivery, or a delivery re-routed for deployment affinity, repeats the write with the same key, and the backend collapses those onto exactly one committed event.
- **A failed publish fails the resume.** The message carries both the trigger and the only copy of the payload, so `resumeHook()` throws and nothing is persisted for a later delivery to pick up. This replaces the previous rule where a failed event write could still be recovered through the queue.
- `ResumedHook.resilientResume` is retained on the type but is never set: with a single writer there is no partial outcome to report. The `workflow.hook.resilient_resume` span attribute is likewise no longer emitted.
- The resume span reports `workflow.hook.resume_strategy: lazy` (previously `parallel`).

## Behavior change: the event is not visible when `resumeHook()` returns

`resumeHook()` used to await its own `hook_received` write, so by the time it resolved the event was in the log. It no longer writes, so **resolving means the message was published, not that the event exists**. The event appears when the run picks the resume up.

Code that reads the run back immediately after resuming now races. The pattern that breaks is a loop that resumes and then looks for the next thing to resume, keying off "this hook has no `hook_received` yet": it can be handed back the hook it just resumed and deliver a second payload to it. Wait for something that implies the run made progress instead. `waitForHook()` in `@workflow/vitest` takes a `notHookId` option for exactly this.

The runtime has one caller that needs the old guarantee. A step that aborts a shared `AbortController` resumes a hook to record the abort in the event log, and that write is an ordering barrier: it must land before the step completes, or the continuation `step_completed` enqueues can dispatch the next step with a stale, non-aborted signal. That path uses an internal durable resume which keeps the eager write and reports `resume_fallback_reason: durable_required`.

Nothing about delivery changes. The payload is on the queue message and reaches the workflow exactly once.
The dispatch is strictly serial:

1. The hook is resolved by token. An unknown token throws `HookNotFoundError`.
2. The producer writes `hook_received` durably into the run's event log. A
client-minted `resumeId` and payload digest ride the write when the backend
supports atomic resume claims, so transport-level retries of the same write
converge on exactly one committed event. A write refused because the hook
was disposed or the run ended throws `HookNotFoundError`.
3. Only after the write is acknowledged does the producer publish the workflow
wake. The wake carries no payload — the payload lives in the event log — so
nothing rides on the queue message but the trigger. Publication is retried
a bounded number of times.

Because the event is committed before the wake exists, a disposal or run
completion racing the queue delivery cannot erase a resume the caller was told
succeeded: the delivery replays the committed event from the log.

- `ResumedHook.resilientResume` remains on the type for source compatibility
and is no longer set. The internal `resumeHookDurable()` entry point is
removed; `resumeHook()` itself now provides the durable guarantee.

A resolved call proves that the event is durable and the wake was accepted.
`HookNotFoundError` proves this invocation committed no event. Any other thrown
error is ambiguous only in *dispatch*, never in durability: a wake failure
after the write leaves the event committed, and any later wake of the run
(from any source) delivers it. A fresh `resumeHook()` invocation mints a new
`resumeId`, so blindly retrying a failed call can append a second
`hook_received`; callers that need at-most-once behavior across separate
invocations must deduplicate on their own request key.

## Behavior change: resumes against an ended run

The hook lookup is unchanged: `resumeHook(token, ...)` still resolves the token through `hooks.getByToken()`, which throws `HookNotFoundError` when no hook holds it. Hook existence, and the token's binding to a run, are still validated before anything is published.

What the lookup does not carry is the run's *mutable* status. `HookResumeContext` is deliberately an immutable slice of the run, so a resume that runs off it never learns whether the run is still live. That used to be caught by the `hook_received` write being rejected. With no write, **a resume against an ended run resolves instead of throwing `HookNotFoundError`**.

This is only reachable when the hook record outlives its run, since otherwise the lookup itself fails: a hook kept by `experimental_minRetention`, or one whose token has not been released yet. Resumes that fall back to reading the run keep their terminal pre-check, as does the sequential path, so a resume on either of those still fails loudly.
The lazy path never observed the server's rejection — it published a message
and resolved, so a resume against a run that had already ended reported
success (reachable whenever the hook record outlives its run, e.g. token
retention). The durable write restores the check: **a resume against an ended
run now throws `HookNotFoundError`**, and a late webhook delivery to a
finished run answers 404 where it previously answered 202. Senders that treat
4xx as terminal will stop retrying such deliveries; that is the correct
signal, since nothing can resume an ended run.

Nothing resumes either way. The consumer's write is rejected the same way and the delivery is consumed, so the ended run is untouched. Only the producer's report changes: an accepted publish means the resume was dispatched, not that the run was still live when it arrived. A [webhook](/docs/api-reference/workflow-api/resume-webhook) whose run has ended can answer `202` rather than surfacing an error.

Callers that need the distinction have to read the run.
A transient write conflict (HTTP 409, e.g. an event-slot conflict that
escaped the server's internal retry budget under contention) is no longer
re-keyed to `HookNotFoundError`. It surfaces as a retryable error, and its
rejected transaction committed nothing, so retrying the resume is safe.

## Compatibility

The gating is unchanged: the lazy path activates only when the target run's queue consumer and the live backend both attest support, re-checked on every resume. Oversized payloads, legacy runs, non-CBOR transports, and `WORKFLOW_DISABLE_LAZY_HOOK_RESUME=1` fall back to the sequential write-then-publish path.
Nothing about the queue message changes: the wake has the same shape the
sequential path always published, so no consumer, backend, or server
coordination is needed and either side can roll back independently.

Consumers continue to accept legacy `hookInput` messages from older producers,
materializing their payload before replay. This permits rolling upgrades
without a coordinated producer and consumer deployment.

Consumers still accept a message from an older producer that wrote the event itself: such a message reports `strategy: parallel`, and the consumer's write converges on the producer's committed event exactly as before. No coordinated deploy is needed in either direction.
`WORKFLOW_DISABLE_LAZY_HOOK_RESUME` no longer gates anything and is ignored:
there is no lazy path left to disable.
14 changes: 8 additions & 6 deletions docs/content/docs/v5/changelog/resilient-resume.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ description: resumeHook() now tolerates transient event storage failures when th
# Resilient `resumeHook()`

<Callout type="info">
Superseded by [lazy hook resume](/docs/changelog/lazy-hook-resume):
`resumeHook()` no longer writes the `hook_received` event at all on the fast
path, so the two-writer design and the `resilientResume` flag described below
are historical. The `(runId, resumeId)` constraint and the queue-carried
payload remain.
Superseded by [durable hook resume](/docs/changelog/lazy-hook-resume):
`resumeHook()` now writes the `hook_received` event durably and only then
publishes the workflow wake, so the two-writer design, the queue-carried
payload, and the `resilientResume` flag described below are historical. The
`(runId, resumeId)` constraint remains, converging transport-level retries
of the producer's own write (and legacy `hookInput` redeliveries from older
producers).
</Callout>

## Motivation
Expand All @@ -27,4 +29,4 @@ description: resumeHook() now tolerates transient event storage failures when th

## Compatibility

The parallel fast path is gated per resume: it activates only when both the target run's queue consumer and the live backend independently attest dedup support (re-checked on every resume, so rollout and rollback both degrade safely). Otherwise (for oversized payloads, legacy runs, or with `WORKFLOW_DISABLE_LAZY_HOOK_RESUME=1`), `resumeHook()` falls back to the original sequential write-then-dispatch path. Because runs keep executing on the deployment they were created on, a resume targeting a run from an older deployment uses the sequential path.
The parallel fast path is gated per resume: it activates only when both the target run's queue consumer and the live backend independently attest dedup support (re-checked on every resume, so rollout and rollback both degrade safely). Otherwise (for oversized payloads or legacy runs), `resumeHook()` falls back to the original sequential write-then-dispatch path. Because runs keep executing on the deployment they were created on, a resume targeting a run from an older deployment uses the sequential path.
Loading
Loading