Can I combine sleep() with hooks for non-blocking scheduling?

HEAD
74aea7bAdd DurableAgent compat tests, e2e tests, and migrate to AI SDK v6 (#1362)
This post might have stale content, as HEAD is 235 commits ahead.

can i combine/race a sleep() with a hook? or like, imagine an "infinite workflow" pattern in which, with some hooks, i can schedule work for later (but not blocking the "hook loop")

Avatar of anthropic/claude-opus-4.6
Claude Opus 4.6
commented

I now have a complete picture. Here's the answer:


Yes, you absolutely can combine sleep() with hooks, and the "infinite workflow" pattern you're describing is a first-class, well-tested pattern. There are a few ways to do it:

##Pattern 1: void sleep() alongside a hook loop

This is the most directly supported pattern. You fire off a sleep() without awaiting it (void sleep(...)) while iterating over a hook:

import { createHook, sleep } from "workflow";

export async function myWorkflow(token: string) {
  "use workflow";

  using hook = createHook<Payload>({ token });

  // Fire-and-forget sleep — does NOT block the hook loop
  void sleep('1d');

  for await (const payload of hook) {
    await processPayload(payload);
    if (payload.done) break;
  }

  return results;
}

This works because both sleep() and hooks register as independent event consumers. The sleep adds a wait item to the invocationsQueue and the hook adds a hook item. Crucially, the runtime uses a scheduleWhenIdle mechanism (packages/core/src/private.ts:128) that ensures WorkflowSuspension only fires after all pending deliveries have been drained:

// packages/core/src/private.ts
export function scheduleWhenIdle(ctx, fn) {
  const check = () => {
    if (ctx.pendingDeliveries > 0) {
      // Still delivering data — wait for queue to drain, then re-check
      ctx.promiseQueue.then(() => { setTimeout(check, 0); });
    } else {
      fn();
    }
  };
  setTimeout(check, 0);
}

This means even though the sleep would want to suspend the workflow (because it hasn't completed yet), it won't prematurely terminate while hook payloads are being delivered. This was actually a bug that was fixed — there's a dedicated test file hook-sleep-interaction.test.ts specifically for this interaction.

##Pattern 2: void sleep().then(...) for cancellation signals

You can also use the sleep's .then() as a side-channel to set a flag:

export async function myWorkflow() {
  "use workflow";

  let shouldCancel = false;
  void sleep('1d').then(() => {
    shouldCancel = true;
  });

  const a = await addNumbers(1, 2);
  const b = await addNumbers(a, 3);
  return { a, b, shouldCancel };
}

This is tested in workbench/example/workflows/99_e2e.ts:1389 — the sleepWithSequentialStepsWorkflow.

##Pattern 3: Promise.race([sleep(), hook])

You can race a sleep against a hook await:

await Promise.race([sleep('30m'), hook]);

Promise.race is explicitly tested with sleeps (workflow.test.ts:428) and with steps (workflow.test.ts:560-730). Both entities register independently on the events consumer, and whichever resolves first wins the race.

##How it works under the hood

The key insight is how suspension works. When the events consumer runs out of events:

  1. Sleep (workflow/sleep.ts): Immediately schedules a WorkflowSuspension via scheduleWhenIdle
  2. Hook (workflow/hook.ts): Only schedules suspension if there are pending awaits with no buffered payloads (promises.length > 0 && payloadsQueue.length === 0)

Both use scheduleWhenIdle which polls ctx.pendingDeliveries — it won't fire the suspension until all in-flight async hydrations (decryption, deserialization) have completed. This two-phase deferral (promiseQueue.then(() => setTimeout(0))) ensures that:

  • Hook payloads already in the event log get delivered before any suspension fires
  • The sleep's desire to suspend doesn't preempt pending hook deliveries
  • Everything stays deterministic through the promiseQueue ordering

So your "infinite workflow" pattern — create a hook, void sleep() to schedule future work without blocking, iterate the hook to process incoming messages — is exactly the intended usage. The runtime is carefully designed to let these primitives coexist concurrently without interfering with each other.

Avatar of julianbenegas
Julian Benegas
commented

so cool. does calling a step in the sleep().then() work as expected and will get awaited?

like

void sleep(10_000).then(async () => { await someLongRunningStep() })

Avatar of anthropic/claude-opus-4.6
Claude Opus 4.6
commented

This confirms it. The answer is no — it won't work the way you'd hope. Here's why:


The workflow completes when workflowFn(...args) resolves — which is when your main workflow function returns. The runtime does:

const result = await Promise.race([
  workflowFn(...args),
  workflowDiscontinuation.promise,
]);

When you write:

void sleep(10_000).then(async () => {
  await someLongRunningStep();
});

// ... do other stuff ...
return results;

That void detaches the promise chain. The sleep().then(...) callback is a fire-and-forget continuation that the workflow function doesn't await. So when your main workflow body reaches return results, workflowFn(...args) resolves, and the runtime considers the workflow completed — regardless of whether the sleep has fired or whether the step inside its .then() has run.

You can actually see this proven in the e2e test for sleepWithSequentialStepsWorkflow:

// workbench/example/workflows/99_e2e.ts:1389
export async function sleepWithSequentialStepsWorkflow() {
  'use workflow';

  let shouldCancel = false;
  void sleep('1d').then(() => {
    shouldCancel = true;  // <-- This never runs
  });

  const a = await addNumbers(1, 2);
  const b = await addNumbers(a, 3);
  const c = await addNumbers(b, 4);
  return { a, b, c, shouldCancel };
}

The e2e test asserts the result is { a: 3, b: 6, c: 10, shouldCancel: false } — the .then() callback never executes because the workflow completes (all three steps resolve) before the 1-day sleep fires. The runtime also logs a warning via warnPendingQueueItems when it detects uncommitted operations at completion time.

###What you'd need instead

If you want to schedule work for "later" but still have the workflow care about it, you need the main workflow body to be structurally aware of that work. The pattern that does work is the hook loop with a void sleep():

export async function myWorkflow() {
  "use workflow";

  using hook = createHook<Command>({ token: "my-worker" });
  
  // Non-blocking sleep — but the hook loop keeps the workflow alive
  void sleep('1d');

  for await (const command of hook) {
    if (command.type === "doWork") {
      await someLongRunningStep(command.payload);
    }
    if (command.type === "stop") {
      break;  // Workflow completes when *this* exits
    }
  }

  return "done";
}

Here the workflow stays alive because the for await on the hook is what the main body is awaiting. The void sleep('1d') is just a concurrent entity in the invocationsQueue — it doesn't control the workflow's lifetime, the hook loop does.

TL;DR: void sleep().then(() => step()) — the step won't be awaited by the workflow. The void means "I don't care about this promise," and the runtime takes you at your word. If you need deferred work, keep the main workflow body alive with a hook loop (or another blocking await) that outlives the sleep.

Avatar of julianbenegas
Julian Benegas
commented

interesting.

what would happen on something like this

export async function myWorkflow() { "use workflow";

using hook = createHook({ token: "my-worker" }); const queue = []

for await (const command of hook) { queue.push(command)

while (queue.length > 0) {
  const event = queue.shift()
  switch () {
    ...
    // imagine in some case, we update the queue
    // but after the hook is "settled" and the queue is at 0
    // does the new queue item get processed?
      void sleep('1d').then(() => {
         queue.push({something})
      });
  }
}

}

return "done"; }

Avatar of anthropic/claude-opus-4.6
Claude Opus 4.6
commented

I now have the full picture. Here's the answer:


No, this won't work. The queue.push({something}) inside the sleep().then() will never be reached during that execution. Here's the step-by-step trace:

###What actually happens

Let's say a single command arrives on the hook. Your code does:

1. `for await (const command of hook)` → yields the command
2. `queue.push(command)` → queue = [command]
3. `while (queue.length > 0)` → enters inner loop
4. processes the event, hits the sleep branch:
     void sleep('1d').then(() => queue.push({something}))
5. queue is now empty → exits while loop
6. goes back to `for await` → calls `yield await this` (the async iterator)

At step 6, createHookPromise() is called. Since the event log is exhausted (eventLogEmpty = true) and there are no buffered payloads, the hook does:

// packages/core/src/workflow/hook.ts:202-210
if (eventLogEmpty) {
  scheduleWhenIdle(ctx, () => {
    ctx.onWorkflowError(
      new WorkflowSuspension(ctx.invocationsQueue, ctx.globalThis)
    );
  });
}
promises.push(resolvers);

Meanwhile, the sleep also saw null (end of event log) and scheduled its own suspension via scheduleWhenIdle. Both suspensions fire, and the runtime terminates the workflow execution with a WorkflowSuspension error — which is caught by:

// packages/core/src/workflow.ts:722
const result = await Promise.race([
  workflowFn(...args),
  workflowDiscontinuation.promise,  // ← WorkflowSuspension lands here
]);

The invocationsQueue at suspension time contains both the pending hook and the pending sleep. The runtime persists these, and later when the sleep fires (after 1 day), it re-runs the workflow with a wait_completed event in the log. But — here's the critical part — the sleep resolves inside the event replay, and its .then(() => queue.push(...)) runs. However:

  • The queue is a local, in-memory variable — it was recreated fresh at the top of the workflow replay
  • The for await loop is blocked waiting on the next hook payload
  • There's nothing connecting the sleep's .then() callback to the while (queue.length > 0) loop — that loop already exited

The .then() callback does fire during replay, but by then the control flow has moved past the while loop and is sitting at for await, waiting for a new hook payload. The pushed item just sits in queue doing nothing. The workflow suspends again (hook is still waiting for a payload), and that queue item is lost since it's just in-memory state.

###The fundamental issue

Workflows are re-executed from scratch on each resume. Local variables like queue don't persist across suspensions — they're rebuilt during replay. The only durable state is the event log. So even if the timing somehow worked out within a single execution, the queue array isn't part of the event-sourced state.

###What you'd want instead

If you need "schedule work for later," the work needs to flow through a durable primitive. Two patterns that work:

1. Self-resuming via hook: Have the sleep's callback send a message back to the hook:

export async function myWorkflow() {
  "use workflow";
  
  using hook = createHook<Command>({ token: "my-worker" });

  for await (const command of hook) {
    switch (command.type) {
      case "scheduleDeferred":
        // Sleep, then resume the hook from the outside
        void sleep('1d').then(async () => {
          await resumeHookStep(hook.token, { type: "deferred", data: ... });
        });
        break;
      case "deferred":
        await handleDeferredWork(command.data);
        break;
    }
  }
}

...but even this has the same problem — the step inside .then() isn't awaited by the workflow body.

2. Multiple awaited sleeps in the main flow: Keep the deferred work in the main control flow:

for await (const command of hook) {
  if (command.type === "doLater") {
    await sleep('1d');          // blocks here durably
    await someLongRunningStep(); // then does the work
    // hook loop continues after
  }
}

The tradeoff is this blocks the hook loop during the sleep — you can't receive new commands while sleeping. That's the fundamental tension: if the workflow body doesn't await it, it doesn't durably happen.

Avatar of julianbenegas
Julian Benegas
commented

yeah like your self resuming pattern would work if that step got awaited. alternatively, we could start() a sibling workflow that sleeps and resumes the loop

Avatar of anthropic/claude-opus-4.6
Claude Opus 4.6
commented

Now I have the complete picture. Here's the answer:


No, that won't work either. The problem is the same fundamental one: void detaches the promise chain, so the workflow doesn't await whatever happens inside .then(). The step would register in the invocationsQueue, but the workflow's main body has already moved on.

But you're absolutely right that the sibling workflow pattern is the clean solution. Here's what it would look like:

// The "timer" workflow — its only job is to sleep and then poke the main loop
async function delayedResume(hookToken: string, payload: any, delay: string) {
  "use workflow";
  await sleep(delay);
  await resumeTheHook(hookToken, payload);
}

async function resumeTheHook(token: string, payload: any) {
  "use step";
  const { resumeHook } = await import("workflow/api");
  await resumeHook(token, payload);
}

// The main "infinite loop" workflow
export async function mainLoop(token: string) {
  "use workflow";

  using hook = createHook<Command>({ token });

  for await (const command of hook) {
    switch (command.type) {
      case "scheduleDeferred":
        // Spawn a sibling workflow that sleeps then resumes our hook
        await spawnTimer(token, command.data, "10s");
        break;
      case "deferred":
        await handleDeferredWork(command.data);
        break;
    }
  }
}

async function spawnTimer(hookToken: string, payload: any, delay: string) {
  "use step";
  const { start } = await import("workflow/api");
  await start(delayedResume, [hookToken, payload, delay]);
}

This works because:

  1. start() is a runtime function (exported from workflow/api / @workflow/core/runtime) — it creates a run_created event and enqueues a new independent workflow run. Calling it from a step is just a normal side-effect (an API call), which is exactly what steps are for.

  2. resumeHook() is also a runtime function — it looks up the hook by token, creates a hook_received event, and re-queues the target workflow. It's explicitly documented as something that "must be called from outside a workflow function" (see packages/core/src/workflow/index.ts:22 — the in-workflow export is a stub that throws).

  3. The spawnTimer step is awaited in the main body, so the workflow properly persists the fact that it was called. But the sibling workflow runs completely independently — it has its own run ID, its own event log, its own lifecycle.

  4. The main hook loop stays unblocked — after spawnTimer completes (which just means the sibling run was enqueued, not that it finished), the for await immediately goes back to waiting for the next hook payload.

So the flow is: main loop receives "scheduleDeferred" → spawns a sibling workflow (durable) → sibling sleeps for 10s (durable) → sibling calls resumeHook on the main loop's token → main loop receives the "deferred" command → processes it. Everything is event-sourced and survives restarts.


END OF POST

Can I combine sleep() with hooks for non-blocking scheduling? — vercel/workflow