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 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:

  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.

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

      , 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
       blocks
      (function() {
      function addCopyButtons() {
      document.querySelectorAll('pre code').forEach(function(codeBlock) {
      if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
      codeBlock.parentElement.setAttribute('data-copy-added', 'true');
      var btn = document.createElement('button');
      btn.textContent = 'Copy';
      btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
      btn.onmouseover = function() { this.style.opacity = '1'; };
      btn.onmouseout = function() { this.style.opacity = '0.7'; };
      btn.onclick = function() {
      navigator.clipboard.writeText(codeBlock.textContent).then(function() {
      btn.textContent = 'Copied!';
      setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
      });
      };
      codeBlock.parentElement.style.position = 'relative';
      codeBlock.parentElement.appendChild(btn);
      });
      }
      addCopyButtons();
      // Re-run on dynamic content
      var observer = new MutationObserver(addCopyButtons);
      observer.observe(document.body, { childList: true, subtree: true });
      })();
      }
      } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
      })();
      (function(){
      try {
      var __m = "github.com";
      var __re = new RegExp('^' + "github\\.com" + '
      
      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 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:

      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.

      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

          , 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
          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 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:

          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.

          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

              , 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
              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 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:

              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.

              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

                  , 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
                  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 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:

                  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.

                  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

                      , 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
                      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 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:

                      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.

                      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

                          , 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
                          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 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:

                          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.

                          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

                              , 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
                              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 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:

                              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.

                              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