Skip to content

A hook_received while a step attempt is in flight re-dispatches the step; both attempts run concurrently in-process #2780

Closed

Description

@AndrewBarba

Summary

While a workflow is suspended awaiting a step, receiving a payload on an unrelated hook wakes the run, and the wake's replay re-dispatches the in-flight step under the same correlation id. The original attempt is still running in the same process and is not cancelled: both attempts execute the step body to completion, duplicating every side effect. Only one step_completed is recorded, so the duplication is invisible to the event log's completion accounting.

For steps whose side effects are externally visible — stream writes, resumeHook calls to other runs, third-party API calls — the duplicate is user-observable. In our case a duplicated resumeHook from a re-executed step also produced a resume landing on an already-disposed hook, corrupting the receiving run's replay (filed separately, see below).

At-least-once execution is expected for crash recovery. This report is about the live-process case: the runtime re-dispatches a step whose original attempt is demonstrably still running on the same event loop, and the trigger is any unrelated hook_received — routine traffic for hook-driven workflows.

Versions observed

  • @workflow/core@5.0.0-beta.26
  • @workflow/world-local@5.0.0-beta.22 (via createLocalWorld; in-process handler, WORKFLOW_TURBO=0)
  • Node v24

Reproduced 4/4 attempts with the minimal repro below.

Event log

One run; the step's side-effect log shows its body ran twice (starts=2):

step_started   corr=step_…CN0S  spikeSlowStep      ← attempt 1 (with input)
step_created   corr=step_…CN0S  spikeSlowStep
hook_received  corr=hook_…      (unrelated wake token)
step_started   corr=step_…CN0S  spikeSlowStep      ← attempt 2, same correlation id
step_completed corr=step_…CN0S  spikeSlowStep      ← one completion

Repro

import { appendFile } from "node:fs/promises";
import { createHook } from "workflow";

export async function spikeSlowStep(input: { logPath: string }) {
  "use step";
  await appendFile(input.logPath, "slow-step\n");           // side-effect marker
  await new Promise((resolve) => setTimeout(resolve, 1500));
  return "slow-done";
}

export async function wakeMidStepWorkflow(input: { token: string; logPath: string }) {
  "use workflow";
  const hook = createHook<{ n: number }>({ token: input.token });
  const iterator = hook[Symbol.asyncIterator]();
  const pending = iterator.next();                           // unrelated wake source

  await spikeSlowStep({ logPath: input.logPath });           // in flight when the wake lands

  const first = await pending;
  hook.dispose();
  return { received: first.done ? -1 : first.value.n };
}

Client:

const run = await start(wakeMidStepWorkflow, [{ token, logPath }]);
await waitForHookRegistered(token);
await sleep(400);                       // let the slow step start
await resumeHook(token, { n: 7 });      // unrelated wake, mid-step
await run.returnValue;                  // resolves fine: { received: 7 }
// logPath now contains "slow-step" twice

Expected

A step in created/started but not completed state whose attempt is still executing in the current process is recognized as pending during a wake's replay and not re-dispatched (or the superseded attempt is torn down before the new one starts, so at most one body's side effects land).

Actual

The step body executes twice concurrently in one process; all side effects flush from both attempts; one completion is recorded.

Impact / workaround

Any workflow that holds a hook open while running steps (the natural shape for cancellation, inbox, or control-channel patterns) gets duplicate step side effects under routine hook traffic. We had to (a) make cancellation-related step results pure markers, (b) move stream-emitting settle logic into a different run whose wake sources are quieter, and (c) add an in-process single-flight around the remaining emission step.

Related

Activity

  1. robertyates commented on Jul 8, 2026

    @robertyates

    Corroborating this from a production app (Next.js on Vercel, hosted world — not world-local), @workflow/core@5.0.0-beta.26. Same mechanism, and I want to add the streaming-step angle because it makes the duplication user-visible and also defeats the very operation that triggers it.

    Our shape. One durable workflow run per chat turn. At turn start we construct a hook-backed AbortController and use its token purely as a Stop channel: a /cancel API route calls resumeHook(abortToken, { aborted: true }), and the workflow reads signal.aborted at each agent step boundary (stopWhen) to end the loop. We deliberately do not thread the signal into the step args (that path hits mergeAbortSignals' x instanceof AbortSignal, and in the workflow VM AbortSignal is a non-callable statics object → throws), so this is the "read .aborted at the boundary" variant rather than the "signal-in-step" variant from #2795.

    The catch: the run is stopped while a step is in flight by definition — Stop only fires against a running turn, and our in-flight step is a streaming LLM call that runs for 5–30s. So the resumeHook wake lands squarely mid-step, and per this issue the step is re-dispatched under the same correlation id with the original attempt still running.

    What that does to a streaming step specifically:

    1. The single run output stream is corrupted. Both attempts are independent model generations writing into the same stream. In a captured buffer we see two different drafts of the same answer interleaved chunk-by-chunk — a text-start for an id that's already open, text-deltas arriving after text-end, two answers woven mid-sentence. One step_completed wins the journal, so the persisted transcript is clean, but the client renders the live stream mid-turn and shows the garble (and, in an ask_user variant, a "ghost" question card from the losing attempt's chunks that has no backing hook and can never be answered).
    2. The model call is billed twice.
    3. The Stop often no-ops. The original in-flight invocation never observes the abort event (only a fresh replay consumes it), so when it wins the journal the turn completes normally — finishReason: "stop", no "stopped" marker. The user clicked Stop; nothing stopped.

    Natural experiment (same run, back to back): a /cancel that returned cancelled:false (didn't resume the hook — our Option-A gate) produced a clean single generation; a /cancel ~2s in that returned cancelled:true (resumed the hook mid-step) produced the interleaved duplicate. The wake is the trigger, reproducibly.

    Worth noting the wake source here is the run's own intended control hook, not unrelated traffic — so this hits the canonical "hook as cancellation signal" pattern head-on, which I think reinforces the reporter's point that this isn't just crash-recovery at-least-once.

    Our mitigation matches the workarounds above: we're moving Stop off the hook channel entirely — the /cancel route writes a resettable flag to our own store, and the run reads it at its step boundary during normal execution, so nothing wakes the run and no second invocation is ever spawned. Happy to share more repro detail if it's useful.

  2. VaguelySerious commented on Jul 9, 2026

    @VaguelySerious
    Member

    Will have a solution for this soon

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions