hook-force-claimed

This error is thrown to a workflow run that was waiting on a hook when another run created a hook with the same token and experimental_force: true. The token moved to that run; this run's hook is disposed and will not receive anything further.

Error message

Hook token "<token>" was force-claimed by another workflow (run "<runId>")

Why this happens

A hook token is owned by one active run at a time. Normally a second run asking for a held token gets HookConflictError. With experimental_force, the second run takes the token instead:

  1. The holder's hook is disposed, and a hook_disposed event naming the new owner is written to the holder's event log.
  2. The holder is woken. If it was awaiting the hook, that promise rejects with HookForceClaimedError. Payloads that arrived before the takeover are still delivered first; a for await...of loop drains them and then throws.
  3. Every resumeHook() for the token from then on reaches the new owner, including a delivery that was already in flight. Senders are never told the token moved. The one exception is a resumeWebhook() request caught inside the handoff window: its body can be sent only once, so it fails with a retryable error instead of being redirected, and the sender's retry reaches the new owner.

This is expected behavior, not a failure of the replaced run. It usually means a newer run for the same subject (a channel, a conversation, a device) has started and is meant to take over.

Handling the takeover

Catch the error where the hook is awaited and let the run finish. The error tells you which run replaced this one:

import { createHook } from "workflow";
import { HookForceClaimedError } from "workflow/errors";

declare function processMessage(message: { text: string }): Promise<void>; // @setup
declare function flushDraft(channelId: string): Promise<void>; // @setup

export async function channelWorkflow(channelId: string) {
  "use workflow";

  const hook = createHook<{ text: string }>({
    token: `channel:${channelId}`,
    experimental_force: true,
  });

  try {
    for await (const message of hook) {
      await processMessage(message);
    }
  } catch (error) {
    if (HookForceClaimedError.is(error)) {
      // A newer run owns the channel now. Hand off and stop.
      await flushDraft(channelId);
      return { replacedBy: error.claimedByRunId };
    }
    throw error;
  }
}

Anything the replaced run still needs to publish should happen in this branch. Do not create another hook with the same token here unless this run really should take the token back: runs forcing the same token converge on whichever registered last, and every other one receives this error again.

Several runs forcing the same token

Any number of runs can force the same token at once without leaving the token or the runs in a bad state. The takeovers chain: each run that loses the token receives HookForceClaimedError naming the run that took it, exactly one run ends up owning the token, and a run that crashes halfway through its own takeover is completed by the next request for the token. Which of several simultaneous claimers wins is not defined, so start them in order if the order matters. If a run should fall back rather than keep fighting for the token, catch HookForceClaimedError and exit instead of forcing again.

Runs that cannot be taken from

A run that was started at a Workflow spec version below 8 (an older SDK release, a Python SDK run, or a deployment with WORKFLOW_SEALED_LOG=0) would never learn that its hook was disposed. The World declines to take its token: the forced hook rejects with the ordinary HookConflictError, whose conflictingRunId names that run, and the run keeps receiving its payloads. Handle it like any other conflict, for example by delegating to the active run. A finished run holding a retained token is taken over at any version.

Deciding who takes over

  • The newest run should win. Create the hook with experimental_force: true in the workflow that starts on each new deployment, restart, or session. Older runs get HookForceClaimedError and exit.
  • The first run should win. Do not use experimental_force. Later runs get HookConflictError and can delegate to the active run.
  • Both runs should keep working. Give them different tokens.

When it is thrown

  • To await hook and to for await...of once buffered payloads are drained.
  • On every later await of the same hook. hook.getConflict() resolves with null: the hook was registered, it just no longer holds the token.
  • Never to a run that has already finished. A token retained after the run ended with experimental_minRetention is taken over silently.