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 samehook_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
exportasyncfunctionreuseLoop(input: {rounds: number}){"use workflow";const{ workflowRunId }=getWorkflowMetadata();for(letround=0;round<input.rounds;round++){consthook=createHook<{n: number}>({token: `${workflowRunId}:reuse`});constiterator=hook[Symbol.asyncIterator]();constconflict=awaithook.getConflict();// round 1: rejects with HookConflictError (self)if(conflict!==null)return`conflict-round-${round}:${conflict.runId}`;awaititerator.next();// wait for one payloadawaititerator.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:
- In one workflow: create hook A with token
T, receive, dispose(), create hook B with token T, await getConflict(). - Assert
getConflict() resolves null and the event log orders hook_disposed(A) before hook_created(B)/its validation.
Summary
Inside a single workflow run, calling
hook.dispose()and thencreateHook({ token })with the same token deterministically rejects the new hook's claim withHookConflictError— conflicting with the run's own previous, already-disposed hook. The event log shows the new hook'shook_conflictpersisted before the prior hook'shook_disposed, even though workflow code calleddispose()strictly before the secondcreateHook().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_createdbecoming a self-conflict, fixed by making same-hook creation idempotent. Here two different hooks (different correlation ids) legitimately reuse one token across adispose(), 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(viacreateLocalWorld; in-process handler,WORKFLOW_TURBO=0)Reproduced 5/5 attempts; always fails on the first reuse (round 1).
Event log smoking gun
One run, events in stored order:
Workflow code order was: create RM01 → receive payload →
dispose()RM01 → create RM02 →await getConflict(). The engine validated RM02's registration (recordinghook_conflict) before RM01'shook_disposedwas persisted.Repro
Driver:
start(reuseLoop, [{ rounds: 2 }]), thenresumeHook(token, …)once per round as each hook registers. Round 0 succeeds; round 1'sgetConflict()rejects: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-tokenhook_createdvalidation runs against storage before the pendinghook_disposedfrom the earlier hook has drained, so the claim check still sees the old hook as the active token owner and recordshook_conflict.Supporting evidence: interposing any
"use step"call betweendispose()and the next same-tokencreateHook()— 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-tokencreateHook()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:
T, receive,dispose(), create hook B with tokenT,await getConflict().getConflict()resolvesnulland the event log ordershook_disposed(A)beforehook_created(B)/its validation.