workflow 5.0.0-beta.54 → 5.0.0-beta.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,2 +1,2 @@
1
- export { EntityConflictError, HookConflictError, HookNotFoundError, PreconditionFailedError, RunExpiredError, RunNotSupportedError, StepNotRegisteredError, StreamError, ThrottleError, TooEarlyError, WorkflowError, WorkflowNotRegisteredError, WorkflowRunCancelledError, WorkflowRunFailedError, WorkflowRunNotCompletedError, WorkflowRunNotFoundError, WorkflowRuntimeError, WorkflowWorldError, } from '@workflow/errors';
1
+ export { EntityConflictError, HookConflictError, HookForceClaimedError, HookNotFoundError, PreconditionFailedError, RunExpiredError, RunNotSupportedError, StepNotRegisteredError, StreamError, ThrottleError, TooEarlyError, WorkflowError, WorkflowNotRegisteredError, WorkflowRunCancelledError, WorkflowRunFailedError, WorkflowRunNotCompletedError, WorkflowRunNotFoundError, WorkflowRuntimeError, WorkflowWorldError, } from '@workflow/errors';
2
2
  //# sourceMappingURL=errors.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/internal/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,mBAAmB,EACnB,iBAAiB,EACjB,iBAAiB,EACjB,uBAAuB,EACvB,eAAe,EACf,oBAAoB,EACpB,sBAAsB,EACtB,WAAW,EACX,aAAa,EACb,aAAa,EACb,aAAa,EACb,0BAA0B,EAC1B,yBAAyB,EACzB,sBAAsB,EACtB,4BAA4B,EAC5B,wBAAwB,EACxB,oBAAoB,EACpB,kBAAkB,GACnB,MAAM,kBAAkB,CAAC"}
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/internal/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,mBAAmB,EACnB,iBAAiB,EACjB,qBAAqB,EACrB,iBAAiB,EACjB,uBAAuB,EACvB,eAAe,EACf,oBAAoB,EACpB,sBAAsB,EACtB,WAAW,EACX,aAAa,EACb,aAAa,EACb,aAAa,EACb,0BAA0B,EAC1B,yBAAyB,EACzB,sBAAsB,EACtB,4BAA4B,EAC5B,wBAAwB,EACxB,oBAAoB,EACpB,kBAAkB,GACnB,MAAM,kBAAkB,CAAC"}
@@ -1,2 +1,2 @@
1
- export { EntityConflictError, HookConflictError, HookNotFoundError, PreconditionFailedError, RunExpiredError, RunNotSupportedError, StepNotRegisteredError, StreamError, ThrottleError, TooEarlyError, WorkflowError, WorkflowNotRegisteredError, WorkflowRunCancelledError, WorkflowRunFailedError, WorkflowRunNotCompletedError, WorkflowRunNotFoundError, WorkflowRuntimeError, WorkflowWorldError, } from '@workflow/errors';
2
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZXJyb3JzLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vc3JjL2ludGVybmFsL2Vycm9ycy50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEVBQ0wsbUJBQW1CLEVBQ25CLGlCQUFpQixFQUNqQixpQkFBaUIsRUFDakIsdUJBQXVCLEVBQ3ZCLGVBQWUsRUFDZixvQkFBb0IsRUFDcEIsc0JBQXNCLEVBQ3RCLFdBQVcsRUFDWCxhQUFhLEVBQ2IsYUFBYSxFQUNiLGFBQWEsRUFDYiwwQkFBMEIsRUFDMUIseUJBQXlCLEVBQ3pCLHNCQUFzQixFQUN0Qiw0QkFBNEIsRUFDNUIsd0JBQXdCLEVBQ3hCLG9CQUFvQixFQUNwQixrQkFBa0IsR0FDbkIsTUFBTSxrQkFBa0IsQ0FBQyIsInNvdXJjZXNDb250ZW50IjpbImV4cG9ydCB7XG4gIEVudGl0eUNvbmZsaWN0RXJyb3IsXG4gIEhvb2tDb25mbGljdEVycm9yLFxuICBIb29rTm90Rm91bmRFcnJvcixcbiAgUHJlY29uZGl0aW9uRmFpbGVkRXJyb3IsXG4gIFJ1bkV4cGlyZWRFcnJvcixcbiAgUnVuTm90U3VwcG9ydGVkRXJyb3IsXG4gIFN0ZXBOb3RSZWdpc3RlcmVkRXJyb3IsXG4gIFN0cmVhbUVycm9yLFxuICBUaHJvdHRsZUVycm9yLFxuICBUb29FYXJseUVycm9yLFxuICBXb3JrZmxvd0Vycm9yLFxuICBXb3JrZmxvd05vdFJlZ2lzdGVyZWRFcnJvcixcbiAgV29ya2Zsb3dSdW5DYW5jZWxsZWRFcnJvcixcbiAgV29ya2Zsb3dSdW5GYWlsZWRFcnJvcixcbiAgV29ya2Zsb3dSdW5Ob3RDb21wbGV0ZWRFcnJvcixcbiAgV29ya2Zsb3dSdW5Ob3RGb3VuZEVycm9yLFxuICBXb3JrZmxvd1J1bnRpbWVFcnJvcixcbiAgV29ya2Zsb3dXb3JsZEVycm9yLFxufSBmcm9tICdAd29ya2Zsb3cvZXJyb3JzJztcbiJdfQ==
1
+ export { EntityConflictError, HookConflictError, HookForceClaimedError, HookNotFoundError, PreconditionFailedError, RunExpiredError, RunNotSupportedError, StepNotRegisteredError, StreamError, ThrottleError, TooEarlyError, WorkflowError, WorkflowNotRegisteredError, WorkflowRunCancelledError, WorkflowRunFailedError, WorkflowRunNotCompletedError, WorkflowRunNotFoundError, WorkflowRuntimeError, WorkflowWorldError, } from '@workflow/errors';
2
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZXJyb3JzLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vc3JjL2ludGVybmFsL2Vycm9ycy50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEVBQ0wsbUJBQW1CLEVBQ25CLGlCQUFpQixFQUNqQixxQkFBcUIsRUFDckIsaUJBQWlCLEVBQ2pCLHVCQUF1QixFQUN2QixlQUFlLEVBQ2Ysb0JBQW9CLEVBQ3BCLHNCQUFzQixFQUN0QixXQUFXLEVBQ1gsYUFBYSxFQUNiLGFBQWEsRUFDYixhQUFhLEVBQ2IsMEJBQTBCLEVBQzFCLHlCQUF5QixFQUN6QixzQkFBc0IsRUFDdEIsNEJBQTRCLEVBQzVCLHdCQUF3QixFQUN4QixvQkFBb0IsRUFDcEIsa0JBQWtCLEdBQ25CLE1BQU0sa0JBQWtCLENBQUMiLCJzb3VyY2VzQ29udGVudCI6WyJleHBvcnQge1xuICBFbnRpdHlDb25mbGljdEVycm9yLFxuICBIb29rQ29uZmxpY3RFcnJvcixcbiAgSG9va0ZvcmNlQ2xhaW1lZEVycm9yLFxuICBIb29rTm90Rm91bmRFcnJvcixcbiAgUHJlY29uZGl0aW9uRmFpbGVkRXJyb3IsXG4gIFJ1bkV4cGlyZWRFcnJvcixcbiAgUnVuTm90U3VwcG9ydGVkRXJyb3IsXG4gIFN0ZXBOb3RSZWdpc3RlcmVkRXJyb3IsXG4gIFN0cmVhbUVycm9yLFxuICBUaHJvdHRsZUVycm9yLFxuICBUb29FYXJseUVycm9yLFxuICBXb3JrZmxvd0Vycm9yLFxuICBXb3JrZmxvd05vdFJlZ2lzdGVyZWRFcnJvcixcbiAgV29ya2Zsb3dSdW5DYW5jZWxsZWRFcnJvcixcbiAgV29ya2Zsb3dSdW5GYWlsZWRFcnJvcixcbiAgV29ya2Zsb3dSdW5Ob3RDb21wbGV0ZWRFcnJvcixcbiAgV29ya2Zsb3dSdW5Ob3RGb3VuZEVycm9yLFxuICBXb3JrZmxvd1J1bnRpbWVFcnJvcixcbiAgV29ya2Zsb3dXb3JsZEVycm9yLFxufSBmcm9tICdAd29ya2Zsb3cvZXJyb3JzJztcbiJdfQ==
@@ -190,6 +190,54 @@ After the workflow ends, [`getHookByToken()`](/docs/api-reference/workflow-api/g
190
190
  This option is experimental. Worlds can limit how long tokens are retained; see [World configuration](/docs/configuration/worlds) for each World's limit. If the configured World does not support minimum retention, the workflow fails when registering the Hook. `createWebhook()` does not accept this option.
191
191
  </Callout>
192
192
 
193
+ ### Take over a token another run holds
194
+
195
+ By default, a token that another active run already registered makes the new Hook reject with [`HookConflictError`](/docs/api-reference/workflow-errors/hook-conflict-error). Set `experimental_force` when the newest run should own the token instead, for example when a fresh deployment or a restarted conversation must replace a run that is still waiting:
196
+
197
+ ```typescript lineNumbers
198
+ import { createHook } from "workflow";
199
+
200
+ declare function processMessage(message: SlackMessage): Promise<void>; // @setup
201
+ type SlackMessage = { text: string }; // @setup
202
+
203
+ export async function slackChannelWorkflow(channelId: string) {
204
+ "use workflow";
205
+
206
+ // Whichever run for this channel started most recently owns the token.
207
+ const hook = createHook<SlackMessage>({ // [!code highlight]
208
+ token: `slack_messages:${channelId}`, // [!code highlight]
209
+ experimental_force: true, // [!code highlight]
210
+ }); // [!code highlight]
211
+
212
+ for await (const message of hook) {
213
+ await processMessage(message);
214
+ }
215
+ }
216
+ ```
217
+
218
+ With `experimental_force`, this run always ends up owning the token:
219
+
220
+ - The previous owner's Hook is disposed, recorded in that run's event log, and the previous owner is woken. If it was awaiting the Hook, that `await` rejects with [`HookForceClaimedError`](/docs/api-reference/workflow-errors/hook-force-claimed-error), which names the run that took the token. Payloads it received before the takeover stay with it; a `for await...of` loop drains them before it throws.
221
+ - Every [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) for the token from then on reaches this run, including a call that was already in flight when the takeover happened. Callers never see the token move; a delivery aimed at the previous owner is redirected to this run inside `resumeHook()`.
222
+ - Any number of runs forcing the same token at the same time converge on a single owner. The takeovers form a chain: each run that loses the token gets `HookForceClaimedError`, exactly one run ends up owning it, and none of them can get stuck. Which run wins among simultaneous claimers is not defined; if the order matters, start them in order.
223
+ - A finished run that still holds the token under [`experimental_minRetention`](#keep-a-token-unavailable-after-the-run-ends) is taken over silently, since there is nothing left to wake. A run can also take over a token held by its own earlier Hook.
224
+
225
+ The takeover is durable. If either run's compute fails partway through, the next request for the token completes it, so the token never ends up held by nobody or by both runs. The previous owner's wake is durable too: the new owner republishes it on replay until it records its next event, and the wake is idempotent, so a crash between registering the Hook and waking the previous owner is repaired by the new owner's next invocation.
226
+
227
+ <Callout type="info">
228
+ A token can only be taken from a run whose runtime understands being taken from. Runs started at a Workflow spec version below 8, which includes every run started by an older SDK release, a Python SDK run, or a deployment with `WORKFLOW_SEALED_LOG=0`, would never learn that their Hook was disposed. The World declines to take their token and the forced Hook rejects with the ordinary [`HookConflictError`](/docs/api-reference/workflow-errors/hook-conflict-error) instead, exactly as if `experimental_force` had not been set. Finished runs holding a retained token are taken over at any version.
229
+ </Callout>
230
+
231
+ `hook.getConflict()` on a forced Hook resolves with `null` once the takeover succeeds: the token is this run's by construction. If the World declines the takeover because the current owner predates spec version 8, `getConflict()` behaves as it does for an ordinary conflict and resolves with that owner's `Run`. Read `hook.claimedFrom` on the value returned by [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) to find out which run, if any, the token was taken from.
232
+
233
+ <Callout type="warn">
234
+ This option is experimental. It requires an explicit `token` (a generated token can never conflict) and is not accepted by `createWebhook()`. If the configured World does not support force-claiming at all, the workflow fails when registering the Hook; a World that declines a specific takeover because the current owner cannot be woken answers with `HookConflictError` instead.
235
+
236
+ Senders on an older SDK release are not redirected. A `resumeHook()` from a deployment that predates this option and that looked the token up inside the short handoff window gets an error (`EntityConflictError`) instead of following the token; the payload is refused, never delivered to the wrong run, and a retry resolves the new owner. Upgrade the sending deployment for the transparent redirect.
237
+
238
+ Webhook requests are not redirected either. [`resumeWebhook()`](/docs/api-reference/workflow-api/resume-webhook) streams the request body once, and buffering a copy of every webhook body on the chance that its token is being taken over at that moment would be a cost paid by everyone who never uses this option. A webhook delivery that lands inside the handoff window fails with a retryable error naming the takeover; nothing is delivered anywhere, and the sender's retry reaches the new owner. A forced `createHook()` can take over a token that a webhook holds, but the token then belongs to a Hook that is not a webhook, so `resumeWebhook()` answers every later request for it as not found, exactly as it does for any `createHook()` token, and never redirects one into it.
239
+ </Callout>
240
+
193
241
  ### Waiting for multiple payloads
194
242
 
195
243
  You can also wait for multiple payloads by using the `for await...of` syntax.
@@ -13,6 +13,8 @@ Retrieves a hook by its unique token, returning the associated workflow run info
13
13
 
14
14
  When `experimental_minRetention` is set, this function continues to return the Hook after its workflow ends until retention ends. That Hook cannot be resumed. Use `getRun(hook.runId)` to inspect the finished run.
15
15
 
16
+ When the Hook was created with [`experimental_force`](/docs/api-reference/workflow/create-hook#take-over-a-token-another-run-holds) and took its token from another run, `hook.claimedFrom` names that run and its Hook. A lookup always follows the token to its current owner, so the same token returns the new Hook as soon as the takeover happens.
17
+
16
18
  <Callout type="warn">
17
19
  `getHookByToken` is a runtime function that must be called from outside a workflow function.
18
20
  </Callout>
@@ -0,0 +1,70 @@
1
+ ---
2
+ title: HookForceClaimedError
3
+ description: Thrown to a hook's awaiters when another workflow run takes its token over with experimental_force.
4
+ type: reference
5
+ summary: Catch HookForceClaimedError when a run created with experimental_force took a hook token this run was waiting on.
6
+ related:
7
+ - /docs/api-reference/workflow/create-hook
8
+ - /docs/foundations/hooks
9
+ - /docs/errors/hook-force-claimed
10
+ ---
11
+
12
+ `HookForceClaimedError` is thrown when awaiting a hook whose token another workflow run took over with [`createHook({ experimental_force: true })`](/docs/api-reference/workflow/create-hook#take-over-a-token-another-run-holds). The hook is disposed and receives no further payloads; the error names the run that now owns the token. See the [hook-force-claimed](/docs/errors/hook-force-claimed) error guide for how to react.
13
+
14
+ ```typescript lineNumbers
15
+ import { createHook } from "workflow";
16
+ import { HookForceClaimedError } from "workflow/errors";
17
+
18
+ export async function channelWorkflow(channelId: string) {
19
+ "use workflow";
20
+
21
+ const hook = createHook<{ text: string }>({ token: `channel:${channelId}` });
22
+
23
+ try {
24
+ const message = await hook;
25
+ return { message };
26
+ } catch (error) {
27
+ if (HookForceClaimedError.is(error)) { // [!code highlight]
28
+ console.log(
29
+ `Token "${error.token}" now belongs to run ${error.claimedByRunId}`
30
+ );
31
+ return { replacedBy: error.claimedByRunId };
32
+ }
33
+ throw error;
34
+ }
35
+ }
36
+ ```
37
+
38
+ ## API signature
39
+
40
+ ### Properties
41
+
42
+ <TSDoc
43
+ definition={`
44
+ interface HookForceClaimedError {
45
+ /** The hook token that was taken over. */
46
+ token: string;
47
+ /** The run that took the token. */
48
+ claimedByRunId: string;
49
+ /** The hook in that run the token now belongs to, when known. */
50
+ claimedByHookId?: string;
51
+ /** The error message. */
52
+ message: string;
53
+ }
54
+ export default HookForceClaimedError;`}
55
+ />
56
+
57
+ ### Static methods
58
+
59
+ #### `HookForceClaimedError.is(value)`
60
+
61
+ Type-safe check for `HookForceClaimedError` instances. Preferred over `instanceof` because it works across module boundaries and virtual machine contexts.
62
+
63
+ ```typescript
64
+ import { HookForceClaimedError } from "workflow/errors"
65
+ declare const error: unknown; // @setup
66
+
67
+ if (HookForceClaimedError.is(error)) {
68
+ // error is typed as HookForceClaimedError
69
+ }
70
+ ```
@@ -68,6 +68,9 @@ All errors extend [`WorkflowError`](/docs/api-reference/workflow-errors/workflow
68
68
  <Card href="/docs/api-reference/workflow-errors/hook-conflict-error" title="HookConflictError">
69
69
  Thrown when creating a hook with a token that is already in use by another workflow run.
70
70
  </Card>
71
+ <Card href="/docs/api-reference/workflow-errors/hook-force-claimed-error" title="HookForceClaimedError">
72
+ Thrown to a hook's awaiters when another run takes its token over with `experimental_force`.
73
+ </Card>
71
74
  </Cards>
72
75
 
73
76
  ## Backend errors
@@ -4,6 +4,7 @@
4
4
  "workflow-error",
5
5
  "hook-not-found-error",
6
6
  "hook-conflict-error",
7
+ "hook-force-claimed-error",
7
8
  "step-not-registered-error",
8
9
  "workflow-not-registered-error",
9
10
  "workflow-run-not-found-error",
@@ -98,6 +98,14 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL
98
98
  - Producer-side recoveries are reported on the suspension span as `workflow.step.resilient_dispatch_recovered`; a consumer that materialized the event reports `workflow.step.resilient_dispatch_materialized`.
99
99
  - Set `1` to enable it.
100
100
 
101
+ ### `WORKFLOW_OPEN_WAIT_CLOCK_SKEW_MS`
102
+
103
+ - Default: `30000` (30 seconds)
104
+ - A pending wait (a `sleep()` whose timer has not fired, including one that lost a `Promise.race()` against a hook) keeps the per-step event-log delta optimization off only if it can fire while the current invocation is still running steps inline: its target time must fall before the end of the invocation's inline window ([`WORKFLOW_V2_TIMEOUT_MS`](#workflow_v2_timeout_ms), which is otherwise derived from the platform's function deadline and tops out at 10 minutes) plus this allowance for clock skew between the runtime and the wait timer. A wait due later than that does not cost the extra `events.list()` per step. A `wait_created` whose target time cannot be read is treated as due now.
105
+ - Before an invocation parks on a wait over a log it last extended from a step's inline delta, it re-reads the log once, so a wait completed early through `run.wakeUp()` (the dashboard's "cancel sleeps" action) is acted on then rather than when the wait's own timer would have fired.
106
+ - Turbo's forced optimistic start is unaffected: any open wait keeps it off, whatever the target time.
107
+ - Must be a finite integer. `31536000000` (one year) restores the older behavior of gating on any pending wait; `Infinity` is rejected and falls back to the default. Open hooks are unaffected: they gate regardless of this setting.
108
+
101
109
  ### Stale reads, and why nothing has to be rejected
102
110
 
103
111
  - Not a variable: this is how a replay working from an out-of-date event log stays correct, and why no World needs a precondition guard to make it so.
@@ -0,0 +1,96 @@
1
+ ---
2
+ title: hook-force-claimed
3
+ description: Another workflow run took this hook's token over with experimental_force.
4
+ type: troubleshooting
5
+ summary: Handle HookForceClaimedError by letting the replaced run wrap up while the new owner receives the token's payloads.
6
+ prerequisites:
7
+ - /docs/foundations/hooks
8
+ related:
9
+ - /docs/api-reference/workflow/create-hook
10
+ - /docs/api-reference/workflow-errors/hook-force-claimed-error
11
+ ---
12
+
13
+ <CopyPrompt
14
+ text="Handle hook-force-claimed. Find every `createHook({ token })` whose token can be taken over by another run created with `experimental_force: true`. Wrap the `await hook` / `for await...of` in a try/catch, check `HookForceClaimedError.is(error)` from `workflow/errors`, and make the replaced run finish cleanly: persist or return anything it still owes (using `error.claimedByRunId` if the new owner should be told), then exit instead of retrying the await. If the run itself should be the one taking over, add `experimental_force: true` to its `createHook()` call and make sure the token is explicit. Verify that starting a second run with the same token wakes the first, that its await rejects with HookForceClaimedError, and that resumeHook() payloads sent after the takeover reach the second run."
15
+ />
16
+
17
+ 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`](/docs/api-reference/workflow/create-hook#take-over-a-token-another-run-holds). The token moved to that run; this run's hook is disposed and will not receive anything further.
18
+
19
+ ## Error message
20
+
21
+ ```text
22
+ Hook token "<token>" was force-claimed by another workflow (run "<runId>")
23
+ ```
24
+
25
+ ## Why this happens
26
+
27
+ A hook token is owned by one active run at a time. Normally a second run asking for a held token gets [`HookConflictError`](/docs/errors/hook-conflict). With `experimental_force`, the second run takes the token instead:
28
+
29
+ 1. The holder's hook is disposed, and a `hook_disposed` event naming the new owner is written to the holder's event log.
30
+ 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.
31
+ 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.
32
+
33
+ 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.
34
+
35
+ ## Handling the takeover
36
+
37
+ Catch the error where the hook is awaited and let the run finish. The error tells you which run replaced this one:
38
+
39
+ ```typescript lineNumbers
40
+ import { createHook } from "workflow";
41
+ import { HookForceClaimedError } from "workflow/errors";
42
+
43
+ declare function processMessage(message: { text: string }): Promise<void>; // @setup
44
+ declare function flushDraft(channelId: string): Promise<void>; // @setup
45
+
46
+ export async function channelWorkflow(channelId: string) {
47
+ "use workflow";
48
+
49
+ const hook = createHook<{ text: string }>({
50
+ token: `channel:${channelId}`,
51
+ experimental_force: true,
52
+ });
53
+
54
+ try {
55
+ for await (const message of hook) {
56
+ await processMessage(message);
57
+ }
58
+ } catch (error) {
59
+ if (HookForceClaimedError.is(error)) { // [!code highlight]
60
+ // A newer run owns the channel now. Hand off and stop.
61
+ await flushDraft(channelId);
62
+ return { replacedBy: error.claimedByRunId };
63
+ }
64
+ throw error;
65
+ }
66
+ }
67
+ ```
68
+
69
+ 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.
70
+
71
+ ## Several runs forcing the same token
72
+
73
+ 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.
74
+
75
+ ## Runs that cannot be taken from
76
+
77
+ 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`](/docs/errors/hook-conflict), 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](/docs/errors/hook-conflict#delegate-to-the-active-run). A finished run holding a retained token is taken over at any version.
78
+
79
+ ## Deciding who takes over
80
+
81
+ - **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.
82
+ - **The first run should win.** Do not use `experimental_force`. Later runs get `HookConflictError` and can [delegate to the active run](/docs/errors/hook-conflict#delegate-to-the-active-run).
83
+ - **Both runs should keep working.** Give them different tokens.
84
+
85
+ ## When it is thrown
86
+
87
+ - To `await hook` and to `for await...of` once buffered payloads are drained.
88
+ - On every later `await` of the same hook. `hook.getConflict()` resolves with `null`: the hook was registered, it just no longer holds the token.
89
+ - Never to a run that has already finished. A token retained after the run ended with `experimental_minRetention` is taken over silently.
90
+
91
+ ## Related
92
+
93
+ - [Hooks](/docs/foundations/hooks) - Taking over a token from another run
94
+ - [createHook](/docs/api-reference/workflow/create-hook#take-over-a-token-another-run-holds) - The `experimental_force` option
95
+ - [HookForceClaimedError](/docs/api-reference/workflow-errors/hook-force-claimed-error) - Error reference
96
+ - [hook-conflict](/docs/errors/hook-conflict) - The default behavior without `experimental_force`
@@ -240,6 +240,40 @@ hook.dispose(); // Manually release the token
240
240
  After disposal, the hook will no longer receive events and the async iterator will stop yielding values.
241
241
  </Callout>
242
242
 
243
+ ### Taking over a token from another run
244
+
245
+ Disposing early only works when the run holding the token cooperates. When the newest run should own a token regardless, such as a redeployed bot that must replace the run still waiting on a channel, pass `experimental_force` and let the runtime perform the handoff:
246
+
247
+ ```typescript lineNumbers
248
+ import { createHook } from "workflow";
249
+ import { HookForceClaimedError } from "workflow/errors";
250
+
251
+ declare function processMessage(message: { text: string }): Promise<void>; // @setup
252
+
253
+ export async function channelWorkflow(channelId: string) {
254
+ "use workflow";
255
+
256
+ const hook = createHook<{ text: string }>({
257
+ token: `channel:${channelId}`,
258
+ experimental_force: true, // [!code highlight]
259
+ });
260
+
261
+ try {
262
+ for await (const message of hook) {
263
+ await processMessage(message);
264
+ }
265
+ } catch (error) {
266
+ if (HookForceClaimedError.is(error)) { // [!code highlight]
267
+ // A newer run for this channel took the token. Wrap up and exit.
268
+ return { replacedBy: error.claimedByRunId };
269
+ }
270
+ throw error;
271
+ }
272
+ }
273
+ ```
274
+
275
+ The run that held the token is woken and its `await hook` (or `for await...of`) rejects with [`HookForceClaimedError`](/docs/api-reference/workflow-errors/hook-force-claimed-error) once it has drained the payloads it received before the takeover. Every `resumeHook()` for the token from then on reaches the new run, including one that was already in flight, so senders never notice the handoff. Several runs forcing the same token at once chain in the same way and always end with exactly one owner. A run started at a spec version below 8 cannot be taken from; the forced hook then gets an ordinary `HookConflictError`. See [`createHook()`](/docs/api-reference/workflow/create-hook#take-over-a-token-another-run-holds) for the full semantics.
276
+
243
277
  ## Understanding webhooks
244
278
 
245
279
  Hooks require you to manually handle HTTP requests and route them to workflows. **Webhooks** provide a higher-level abstraction built on top of hooks that:
@@ -141,6 +141,7 @@ All three first-party Worlds now implement it: Vercel accepts up to 30 days, and
141
141
  ### Also new in 5.0
142
142
 
143
143
  - **`start()` from inside a workflow.** Spawn a child run or hand off to a new run directly in a workflow function, without wrapping it in a step. See [Starting workflows](/docs/foundations/starting-workflows).
144
+ - **Hook token takeover.** Pass `experimental_force: true` to [`createHook()`](/docs/api-reference/workflow/create-hook#take-over-a-token-another-run-holds) when the newest run should own a token another run still holds. The previous owner is woken and its `await hook` rejects with [`HookForceClaimedError`](/docs/api-reference/workflow-errors/hook-force-claimed-error), while `resumeHook()` callers, including one already in flight, are routed to the new owner without noticing. Supported by all three first-party Worlds.
144
145
  - **Stronger hook coordination.** `hook.getConflict()` resolves with the conflicting [`Run`](/docs/api-reference/workflow-api/get-run) rather than a bare `{ runId }`, so a duplicate can `await conflict.status`, `await conflict.returnValue`, or `await conflict.cancel()` directly. See [Run idempotency](/docs/foundations/idempotency#run-idempotency).
145
146
  - **Support for more frameworks.** [React Router](/docs/getting-started/react-router) (v7 and v8, via Nitro) and [NestJS](/docs/getting-started/nestjs) are now supported.
146
147
  - **A misrouted delivery no longer fails a run.** Runs are pinned to the deployment that created them. A delivery that arrives at a different deployment is now re-routed to the pinned one with backoff instead of failing, and only gives up with the new [`DEPLOYMENT_MISMATCH`](/docs/errors/deployment-mismatch) error once the recovery budget is spent. Nothing executes on the wrong deployment while this happens. In 4.x the same situation surfaced as an unexplained decryption failure.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "workflow",
3
- "version": "5.0.0-beta.54",
3
+ "version": "5.0.0-beta.56",
4
4
  "description": "Workflow SDK - Build durable, resilient, and observable workflows",
5
5
  "main": "dist/typescript-plugin.cjs",
6
6
  "type": "module",
@@ -58,19 +58,19 @@
58
58
  }
59
59
  },
60
60
  "dependencies": {
61
- "@workflow/astro": "5.0.0-beta.54",
62
- "@workflow/cli": "5.0.0-beta.54",
63
- "@workflow/core": "5.0.0-beta.54",
64
- "@workflow/errors": "5.0.0-beta.22",
61
+ "@workflow/astro": "5.0.0-beta.56",
62
+ "@workflow/cli": "5.0.0-beta.56",
63
+ "@workflow/core": "5.0.0-beta.56",
64
+ "@workflow/errors": "5.0.0-beta.23",
65
65
  "@workflow/typescript-plugin": "5.0.0-beta.5",
66
66
  "@workflow/utils": "5.0.0-beta.10",
67
67
  "ms": "2.1.3",
68
- "@workflow/next": "5.0.0-beta.54",
69
- "@workflow/nest": "5.0.0-beta.54",
70
- "@workflow/nitro": "5.0.0-beta.54",
71
- "@workflow/nuxt": "5.0.0-beta.54",
72
- "@workflow/sveltekit": "5.0.0-beta.54",
73
- "@workflow/rollup": "5.0.0-beta.54"
68
+ "@workflow/next": "5.0.0-beta.56",
69
+ "@workflow/nest": "5.0.0-beta.56",
70
+ "@workflow/nitro": "5.0.0-beta.56",
71
+ "@workflow/nuxt": "5.0.0-beta.56",
72
+ "@workflow/sveltekit": "5.0.0-beta.56",
73
+ "@workflow/rollup": "5.0.0-beta.56"
74
74
  },
75
75
  "devDependencies": {
76
76
  "@types/ms": "2.1.0",