Durable hook resume

Durable hook resume

Motivation

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.

resumeHook() now resolves only after both the durable event write and the workflow wake have succeeded, in that order.

Design

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

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

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.

WORKFLOW_DISABLE_LAZY_HOOK_RESUME no longer gates anything and is ignored: there is no lazy path left to disable.