Summary
A Hook (from createHook() / createWebhook()) is a thenable, not a Promise. Every .then() call — including the implicit one Promise.race / await makes — pushes a fresh one-shot deferred onto the hook's awaiter list, and hook_received resolves the oldest entry. An awaiter whose race was lost to a sleep() is never removed, so it stays first in line. The next payload resolves that dead promise, is marked consumed, and the iteration that is actually waiting never wakes.
@workflow/core@5.0.0-beta.53, packages/core/src/workflow/hook.ts:
Nothing removes an awaiter when the other side of a Promise.race settles. This is deterministic (awaiter order, not timing) and survives replay.
Reproduction
Run 2026-09-19 on the local world (through eve 0.61.1's bundled workflow 5.0.0-beta.53; the workflow body is a defineWorkflowTool executor, the Hook code is the published package's). log is a "use step" that appends a timestamped line to a file. mode: "reraced" is the bug; mode: "hoisted" is the workaround below.
import { createWebhook, sleep } from "workflow";
export async function waitInLoop(mode: "reraced" | "hoisted", maxIterations = 4) {
"use workflow";
const webhook = createWebhook();
await log(mode, "webhook.url", webhook.url);
const delivered = mode === "hoisted" ? webhook.then(() => "webhook" as const) : undefined;
for (let i = 1; i <= maxIterations; i++) {
await log(mode, `iteration ${i} start (racing 8s sleep)`);
const winner = await Promise.race([
delivered ?? webhook.then(() => "webhook" as const),
sleep("8s").then(() => "sleep" as const),
]);
await log(mode, `iteration ${i} winner=${winner}`);
if (winner === "webhook") return { mode, iterations: i, wokeBy: "webhook" };
}
return { mode, iterations: maxIterations, wokeBy: "none" };
}
- Start the run; wait for
iteration 1 winner=sleep.
- ~2 s into iteration 2,
curl -X POST <webhook.url> once. The POST returns 202.
- Observed (
reraced): iteration 2 waits out its full 8 s and every later iteration does too. One delivery, zero wakes:
04:56:23.330Z reraced iteration 1 start (racing 8s sleep)
04:56:31.368Z reraced iteration 1 winner=sleep
04:56:31.389Z reraced iteration 2 start (racing 8s sleep)
04:56:33.462Z POST …/.well-known/workflow/v1/webhook/QzHWMNAKuimDDpOLfYa5g -> HTTP 202
04:56:39.441Z reraced iteration 2 winner=sleep
04:56:47.538Z reraced iteration 3 winner=sleep
04:56:55.617Z reraced iteration 4 winner=sleep → tool result { iterations: 4, wokeBy: "none" }
- Same run with two POSTs 500 ms apart during iteration 2: the first is swallowed by iteration 1's stale awaiter, the second wakes iteration 2 — waking iteration k takes k deliveries:
04:58:35.090Z reraced iteration 2 start (racing 8s sleep)
04:58:37.375Z POST #1 …/webhook/NprXAPVCz6fEMRslN7gjF -> HTTP 202
04:58:37.899Z POST #2 …/webhook/NprXAPVCz6fEMRslN7gjF -> HTTP 202
04:58:37.921Z reraced iteration 2 winner=webhook → { iterations: 2, wokeBy: "webhook" }
- Control (
hoisted, one awaiter enrolled before the loop): a single POST during iteration 2 wakes it 24 ms later:
04:57:16.709Z hoisted iteration 2 start (racing 8s sleep)
04:57:18.971Z POST …/webhook/bHY6D5LUhv10V_d4dJt_Q -> HTTP 202
04:57:18.995Z hoisted iteration 2 winner=webhook → { iterations: 2, wokeBy: "webhook" }
Not executed here, derived from the source path only: Promise.race([webhook, sleep(...)]) without the explicit .then (Promise.resolve on a thenable calls then, so it should enrol the same way), and createHook() + resumeHook() (same Hook class, same createHookPromise).
Expected
A payload delivered while the workflow is awaiting the hook wakes the await that is actually pending. At minimum the docs should say a hook may only be awaited/raced once per payload.
Workaround
Enrol one awaiter and race that Promise (the hoisted mode above; verified):
const delivered = webhook.then((r) => r); // once, before the loop
for (;;) {
const winner = await Promise.race([delivered, sleep("30s").then(() => "sleep" as const)]);
…
}
Suggested change
Either of these, in order of cost:
- Document it on
createHook() / createWebhook(), and revisit the Timeouts cookbook — "Soft timeout (retry): loop and retry with a fresh Promise.race" and "Human approvals … escalate" describe exactly this pattern, and as written they lose the payload for hooks and webhooks.
- Reuse the pending deferred: while no payload has been delivered,
then() returns the same unsettled promise instead of enrolling another. for await (sequential awaits) keeps its semantics; only concurrent Promise.all([hook, hook]) on one hook would change, which the docs do not describe.
Environment
workflow / @workflow/core 5.0.0-beta.53 (observed through eve 0.61.1's bundled copy, eve dev local world; the awaiter code above is the published package's). Not re-run on a standalone workflow project or on the Vercel world.
- Node v25.9.0, pnpm 10.33.2, macOS 26.4.1 (arm64)
Summary
A
Hook(fromcreateHook()/createWebhook()) is a thenable, not a Promise. Every.then()call — including the implicit onePromise.race/awaitmakes — pushes a fresh one-shot deferred onto the hook's awaiter list, andhook_receivedresolves the oldest entry. An awaiter whose race was lost to asleep()is never removed, so it stays first in line. The next payload resolves that dead promise, is marked consumed, and the iteration that is actually waiting never wakes.@workflow/core@5.0.0-beta.53,packages/core/src/workflow/hook.ts:then()→createHookPromise()createHookPromise()pushes a new deferred each callhook_received→promises.shift()Nothing removes an awaiter when the other side of a
Promise.racesettles. This is deterministic (awaiter order, not timing) and survives replay.Reproduction
Run 2026-09-19 on the local world (through eve 0.61.1's bundled
workflow5.0.0-beta.53; the workflow body is adefineWorkflowToolexecutor, the Hook code is the published package's).logis a"use step"that appends a timestamped line to a file.mode: "reraced"is the bug;mode: "hoisted"is the workaround below.iteration 1 winner=sleep.curl -X POST <webhook.url>once. The POST returns202.reraced): iteration 2 waits out its full 8 s and every later iteration does too. One delivery, zero wakes:hoisted, one awaiter enrolled before the loop): a single POST during iteration 2 wakes it 24 ms later:Not executed here, derived from the source path only:
Promise.race([webhook, sleep(...)])without the explicit.then(Promise.resolveon a thenable callsthen, so it should enrol the same way), andcreateHook()+resumeHook()(sameHookclass, samecreateHookPromise).Expected
A payload delivered while the workflow is awaiting the hook wakes the await that is actually pending. At minimum the docs should say a hook may only be awaited/raced once per payload.
Workaround
Enrol one awaiter and race that Promise (the
hoistedmode above; verified):Suggested change
Either of these, in order of cost:
createHook()/createWebhook(), and revisit the Timeouts cookbook — "Soft timeout (retry): loop and retry with a freshPromise.race" and "Human approvals … escalate" describe exactly this pattern, and as written they lose the payload for hooks and webhooks.then()returns the same unsettled promise instead of enrolling another.for await(sequential awaits) keeps its semantics; only concurrentPromise.all([hook, hook])on one hook would change, which the docs do not describe.Environment
workflow/@workflow/core5.0.0-beta.53 (observed through eve 0.61.1's bundled copy,eve devlocal world; the awaiter code above is the published package's). Not re-run on a standaloneworkflowproject or on the Vercel world.