Skip to content

Recreating a hook token after dispose() in the same run self-conflicts: suspension processing validates the new hook_created before the pending hook_disposed persists #2777

Description

@AndrewBarba

Summary

Inside a single workflow run, calling hook.dispose() and then createHook({ token }) with the same token deterministically rejects the new hook's claim with HookConflictError — conflicting with the run's own previous, already-disposed hook. The event log shows the new hook's hook_conflict persisted before the prior hook's hook_disposed, even though workflow code called dispose() strictly before the second createHook().

This makes the natural "one hook token per loop iteration" pattern (e.g. a per-turn cancellation hook keyed on a stable token, recreated each turn of a long-lived run) unusable: the first reuse always fails.

Related to but distinct from #2283: that issue was duplicate processing of the same hook_created becoming a self-conflict, fixed by making same-hook creation idempotent. Here two different hooks (different correlation ids) legitimately reuse one token across a dispose(), and the ordering of disposal vs. next-creation is what breaks.

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 5/5 attempts; always fails on the first reuse (round 1).

Event log smoking gun

One run, events in stored order:

hook_created   corr=hook_…RM01  token wrun_…:spike-reuse
hook_received  corr=hook_…RM01  token wrun_…:spike-reuse
hook_conflict  corr=hook_…RM02  token wrun_…:spike-reuse  conflictingRunId=<same run>
hook_disposed  corr=hook_…RM01  token wrun_…:spike-reuse

Workflow code order was: create RM01 → receive payload → dispose() RM01 → create RM02 → await getConflict(). The engine validated RM02's registration (recording hook_conflict) before RM01's hook_disposed was persisted.

Repro

export async function reuseLoop(input: { rounds: number }) {
  "use workflow";
  const { workflowRunId } = getWorkflowMetadata();

  for (let round = 0; round < input.rounds; round++) {
    const hook = createHook<{ n: number }>({ token: `${workflowRunId}:reuse` });
    const iterator = hook[Symbol.asyncIterator]();

    const conflict = await hook.getConflict(); // round 1: rejects with HookConflictError (self)
    if (conflict !== null) return `conflict-round-${round}:${conflict.runId}`;

    await iterator.next();          // wait for one payload
    await iterator.return?.(undefined);
    hook.dispose();
  }
  return "ok";
}

Driver: start(reuseLoop, [{ rounds: 2 }]), then resumeHook(token, …) once per round as each hook registers. Round 0 succeeds; round 1's getConflict() rejects:

HookConflictError: Hook token "wrun_…:reuse" is already in use by another workflow (run "wrun_…")

with the conflicting run id equal to the current run.

Why this happens (hypothesis)

dispose() is synchronous in workflow code — it only marks the hook for disposal; persistence happens when the workflow suspends. On the suspension that commits the next hook's registration, pending same-token hook_created validation runs against storage before the pending hook_disposed from the earlier hook has drained, so the claim check still sees the old hook as the active token owner and records hook_conflict.

Supporting evidence: interposing any "use step" call between dispose() and the next same-token createHook() — i.e. forcing an intermediate suspension that flushes the disposal — makes the identical loop pass reliably (verified 3/3).

Expected behavior

Within one run, operations should take effect in workflow-code order: a dispose() called before a same-token createHook() must be persisted/applied before the new hook's claim is validated. The loop above should complete with "ok" for any number of rounds.

Suggested fix shape

When processing a suspension, drain pending hook disposals before validating pending hook creations (or, equivalently, validate token claims against the post-disposal state of the same batch). A regression test:

  1. In one workflow: create hook A with token T, receive, dispose(), create hook B with token T, await getConflict().
  2. Assert getConflict() resolves null and the event log orders hook_disposed(A) before hook_created(B)/its validation.

Activity

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