Files
Nathan Rajlich 438eaa6a59 Make resumeHook() resilient to transient hook_received event write failures (#1834)
* Make resumeHook() resilient to transient hook_received event write failures

When events.create('hook_received') fails with a retryable error (429/5xx),
resumeHook() now dispatches the queue message with a `hookInput` payload
carrying the dehydrated hook payload. The workflow runtime materializes the
missing hook_received event from that payload on its next delivery, mirroring
the existing resilient-start behavior of start() / run_created / run_started.

Returned Hook carries a new `resilientResume: true` flag when the fallback
path was taken. Both write paths share a client-minted `resumeId` as an
idempotency key so the runtime can dedup if the direct write actually
committed but the client saw a transient error.

Uses a sequential write-then-queue flow (not parallel) to avoid a dedup race
on the happy path: hook_received events have no entity-level conflict guard
(unlike run_created), so a duplicate written before the direct write commits
would double-deliver the payload to the workflow.

* Fix resilient resume: use local payload in materialized hook_received event

The server returns a 'lazy' response for hook_received event creation,
where eventData.payload may be a RefDescriptor (when the payload
exceeded the inline size and was offloaded to blob storage) rather
than the raw bytes. Pushing this directly to the in-memory events
array caused the workflow VM to fail with 'Invalid input' when trying
to deserialize the RefDescriptor as a Uint8Array.

Substitute the eventData we already have locally so the in-memory
event matches what getWorkflowRunEvents would return after
client-side ref hydration.

* Gate resilient resume on target runtime capability; carry hook token; export ResumedHook; docs

- Only take the resilient path when the target run's recorded
  @workflow/core version understands hookInput on the queue payload.
  Runs keep executing on the deployment they were created on (skew
  protection), and older runtimes parse the queue message with a schema
  that silently strips unknown fields - the resume payload would be
  lost while resumeHook() reported success. Fail fast (propagate the
  original event-write error) for such runs instead, preserving the
  caller's ability to retry.
- Carry the hook token on hookInput and write it into the materialized
  hook_received event so it gets the same replay-divergence guard as a
  directly written event (#2030 parity).
- Export ResumedHook from @workflow/core/runtime and workflow/api.
- Add changelog page and update resumeHook() API reference docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address review: correct capability cutoff, drop own-version escape hatch, replay-side resumeId dedup

Review fixes for the resilient-resume capability gate and dedup:

- Bump the supportsQueueHookInput cutoff to 5.0.0-beta.39: 5.0.0-beta.38 is
  published WITHOUT this feature (its queue-payload schema strips hookInput),
  so classifying it as capable would silently lose resume payloads. The
  cutoff is now a single exported constant (QUEUE_HOOK_INPUT_MIN_VERSION)
  with a TODO(release) requiring re-verification at merge time.
- Remove the own-version exact-match escape hatch entirely: version strings
  do not identify builds (a published beta.38 and a main-built tarball can
  share a version string while differing in content), so the check could
  declare a featureless published deployment capable. Pre-release builds now
  fall back to fail-fast until the version is bumped past the cutoff — the
  safe direction. Tests simulate a capable target explicitly.
- Make duplicate suppression authoritative at the replay boundary: replay
  now dedups hook_received events sharing a resumeId (same resume attempt),
  so even when concurrent redelivery of the same queue message
  double-materializes the event (no World enforces uniqueness on
  hook_received), the payload reaches workflow code exactly once. This is a
  pure function of the persisted log, keeping replay deterministic. The
  runtime's snapshot check remains as best-effort write suppression, with
  its comment corrected to say so; the EntityConflictError catch is kept as
  the forward-compatible signal for planned server-side (runId, resumeId)
  uniqueness, with its comment corrected to say it is defensive today.
- Stamp materialized hook_received events with occurredAt decoded from the
  resumeId ULID so resiliently-resumed hooks are timestamped at resume time
  rather than after the queue round-trip.
- Pin the cross-version compat contract in a test: the direct write is
  resumeId-only (no digest or negotiation fields), which later server-side
  idempotency work must keep accepting.
- Exercise the published boundary (5.0.0-beta.38) in fail-fast tests, and
  make the capability tests self-check against the exported cutoff constant
  instead of restating literals.
- Docs: changelog date June -> July 2026, dash consistency, and document the
  replay-side dedup guarantee.

* Encode release-gate and successor-rebase contracts into code comments

Comment-only changes capturing the review agreements so they survive the
parallel-resume successor rebase (no behavior change):

- capabilities.ts: the QUEUE_HOOK_INPUT_MIN_VERSION re-verification point
  is the actual combined SDK release (after the successor lands and its
  server-side dedup is deployed), not source-merge time — this PR merges
  source-only and no SDK is published from it alone. Every Version
  Packages merge in between moves the earliest possible carrier.
- workflow/hook.ts + runtime.ts: scope the replay-side resumeId dedup
  honestly as defense-in-depth over the persisted log, not a
  cross-invocation exactly-once guarantee — concurrent invocations
  replaying pre-duplicate snapshots each see only their own row; the
  storage-level (runId, resumeId) constraint in the successor work is the
  correctness boundary. The set stays useful post-constraint for logs
  written before it deployed.
- runtime.ts: document the EntityConflictError swallow's known gap while
  the branch is defensive (this invocation's local log lacks the payload;
  progress relies on the other writer's delivery or redelivery) and pin
  the rebase contract for when the constraint makes it live: a matching
  claim must append the canonical event locally and succeed; a real
  conflict must rethrow for redelivery.
- resume-hook-resilient.test.ts: reframe the wire-shape pin as a tripwire
  rather than a permanent contract — the successor deliberately widens it
  (ID/digest pair + attestation) before any SDK release, so the
  resumeId-only shape never ships as a published server contract.

---------

Co-authored-by: Peter Wielander <peter.wielander@vercel.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 20:45:54 +00:00
..
2025-12-02 09:11:46 -08:00
2026-01-26 13:31:22 -08:00
2025-10-23 12:07:52 +03:00
2025-10-23 12:07:52 +03:00
2025-11-03 13:24:03 -08:00
2025-10-23 12:07:52 +03:00