diff --git a/.changeset/docs-event-sourcing-duplicate-events.md b/.changeset/docs-event-sourcing-duplicate-events.md
new file mode 100644
index 0000000000..c05a24fef4
--- /dev/null
+++ b/.changeset/docs-event-sourcing-duplicate-events.md
@@ -0,0 +1,4 @@
+---
+---
+
+Document how replay handles duplicate events in the event sourcing guide.
diff --git a/.changeset/duplicate-event-log-level.md b/.changeset/duplicate-event-log-level.md
new file mode 100644
index 0000000000..663193260e
--- /dev/null
+++ b/.changeset/duplicate-event-log-level.md
@@ -0,0 +1,7 @@
+---
+'@workflow/core': patch
+'@workflow/world': patch
+'workflow': patch
+---
+
+Log ignored duplicate events at `debug` instead of `info`/`error`, so a straggler no longer prints on every replay of the run.
diff --git a/docs/content/docs/v5/api-reference/workflow-api/resume-webhook.mdx b/docs/content/docs/v5/api-reference/workflow-api/resume-webhook.mdx
index adf4fc8a52..5d16ba8fd2 100644
--- a/docs/content/docs/v5/api-reference/workflow-api/resume-webhook.mdx
+++ b/docs/content/docs/v5/api-reference/workflow-api/resume-webhook.mdx
@@ -61,7 +61,7 @@ Throws [`HookNotFoundError`](/docs/api-reference/workflow-errors/hook-not-found-
## Usage Note
-In most cases, you should not need to call `resumeWebhook()` directly. When you use `createWebhook()`, the framework automatically generates a random webhook token and provides a public URL at `/.well-known/workflow/v1/webhook/:token`. External systems can send HTTP requests directly to that URL.
+In most cases, you should not need to call `resumeWebhook()` directly. When you use `createWebhook()`, the framework automatically generates a webhook token and provides a public URL at `/.well-known/workflow/v1/webhook/:token`. External systems can send HTTP requests directly to that URL.
For server-side hook resumption with deterministic tokens, use [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) with [`createHook()`](/docs/api-reference/workflow/create-hook) instead.
diff --git a/docs/content/docs/v5/errors/corrupted-event-log.mdx b/docs/content/docs/v5/errors/corrupted-event-log.mdx
index f8d31bdb05..d57f6b53f7 100644
--- a/docs/content/docs/v5/errors/corrupted-event-log.mdx
+++ b/docs/content/docs/v5/errors/corrupted-event-log.mdx
@@ -2,14 +2,14 @@
title: corrupted-event-log
description: The workflow's event log contains an event that no consumer can process, indicating corruption or invalid state.
type: troubleshooting
-summary: Resolve corrupted event log errors caused by duplicate or orphaned events.
+summary: Resolve corrupted event log errors caused by orphaned or unattributable events.
prerequisites:
- /docs/foundations/workflows-and-steps
related:
- /docs/foundations/errors-and-retries
---
-This error occurs when the Workflow runtime repeatedly cannot replay events in the event log. This usually means the event log is in an invalid state, such as duplicate or orphaned events, or that a runtime determinism bug persists across retry attempts.
+This error occurs when the Workflow runtime repeatedly cannot replay events in the event log. This usually means the event log is in an invalid state, such as an orphaned event or one no consumer can attribute to anything the workflow did, or that a runtime determinism bug persists across retry attempts.
This is a **workflow-level fatal error**. It cannot be caught or handled inside your workflow code. The runtime first retries transient replay divergence automatically; it marks the run as failed with this error only after replay still cannot recover.
@@ -29,10 +29,9 @@ Before failing, the runtime retries a divergent replay and surfaces this termina
Common scenarios that produce this error:
-1. **Duplicate completion events** — Two `wait_completed` events for a single `wait_created`, or two `step_completed` events for the same step. The first is consumed normally, and the second resolves something already resolved, so no later consumer can claim it.
+1. **An unclaimed event that repeats nothing** — A duplicate of a kind the log already records for that entity is read past rather than failing the run, so a second `step_completed` or `wait_completed` is not this error (see [Duplicate Events](/docs/how-it-works/event-sourcing#duplicate-events)). What fails is an unclaimed event with no earlier counterpart to defer to: a `step_started` behind a `step_completed` on a log that never recorded a `step_started`, for instance. No consumer remains for the step, and there is no earlier event of that kind the replay could be reading instead.
2. **Orphaned events** — A `step_completed` or `wait_completed` event whose `correlationId` doesn't match any step or sleep in the workflow code, so the replay reaches its end still holding it.
-3. **Events after terminal state** — An event that arrives after its corresponding step or wait has already reached a terminal state (e.g., `step_retrying` after `step_completed`).
-4. **A hole in the log** — Events are numbered by their position in the run's log, and those positions are dense, so a position below the log's highest that holds no event means the log the replay loaded is incomplete. The runtime cannot tell a position no write ever occupied from one whose event it failed to read, so it refuses to replay rather than produce a result that may be silently wrong. See [`WORKFLOW_SLOT_GAP_CHECK`](/docs/configuration/runtime-tuning#workflow_slot_gap_check).
+3. **A hole in the log** — Events are numbered by their position in the run's log, and those positions are dense, so a position below the log's highest that holds no event means the log the replay loaded is incomplete. The runtime cannot tell a position no write ever occupied from one whose event it failed to read, so it refuses to replay rather than produce a result that may be silently wrong. See [`WORKFLOW_SLOT_GAP_CHECK`](/docs/configuration/runtime-tuning#workflow_slot_gap_check).
## What To Do
diff --git a/docs/content/docs/v5/errors/replay-divergence.mdx b/docs/content/docs/v5/errors/replay-divergence.mdx
index 0fadb48e48..5b24d86bd6 100644
--- a/docs/content/docs/v5/errors/replay-divergence.mdx
+++ b/docs/content/docs/v5/errors/replay-divergence.mdx
@@ -20,7 +20,7 @@ A single divergent replay does not prove that persisted history is corrupted. Fo
The runtime automatically queues another replay when an invocation reports `REPLAY_DIVERGENCE`. No terminal `run_failed` event is written during these recovery attempts.
-If recovery replays continue to diverge after the retry budget is exhausted, the runtime marks the run as failed with `CORRUPTED_EVENT_LOG` and records the latest divergent event for diagnosis.
+If recovery replays continue to diverge after the recovery budget is exhausted, the runtime marks the run as failed with `CORRUPTED_EVENT_LOG` and records the latest divergent event for diagnosis.
## What To Do
diff --git a/docs/content/docs/v5/foundations/hooks.mdx b/docs/content/docs/v5/foundations/hooks.mdx
index 348f31da8c..531eaa29ef 100644
--- a/docs/content/docs/v5/foundations/hooks.mdx
+++ b/docs/content/docs/v5/foundations/hooks.mdx
@@ -116,7 +116,7 @@ Calling `createHook()` on its own does not register the hook — registration is
### Custom Tokens for Deterministic Hooks
-By default, hooks generate a random token. However, you often want to use a **custom token** that external systems can reconstruct. This is especially useful for long-running workflows where the same workflow instance should handle multiple events.
+By default, hooks generate their own token. However, you often want to use a **custom token** that external systems can reconstruct. This is especially useful for long-running workflows where the same workflow instance should handle multiple events.
For example, imagine a Slack bot where each channel should have its own workflow instance:
@@ -480,7 +480,7 @@ This pattern is especially valuable in larger applications where the workflow an
### Token Design
-Custom tokens are available for `createHook()` with server-side `resumeHook()` only. Webhooks (`createWebhook()`) always use randomly generated tokens to prevent unauthorized access to public webhook endpoints.
+Custom tokens are available for `createHook()` with server-side `resumeHook()` only. Webhooks (`createWebhook()`) always generate their own unique tokens. A generated token is not trivial to guess, but it is not a strong security contract either, so anyone who obtains the URL can invoke an unintended webhook resumption. To prevent unauthenticated run resumptions entirely, prefer a **hook** over the **webhook** convenience and implement your own authentication on the route that calls `resumeHook()`.
When using custom tokens with `createHook()`:
diff --git a/docs/content/docs/v5/how-it-works/event-sourcing.mdx b/docs/content/docs/v5/how-it-works/event-sourcing.mdx
index 31febb849a..14c3bcf321 100644
--- a/docs/content/docs/v5/how-it-works/event-sourcing.mdx
+++ b/docs/content/docs/v5/how-it-works/event-sourcing.mdx
@@ -94,7 +94,7 @@ flowchart TD
- `cancelled`: Reserved for future use (not currently emitted)
-The `step_retrying` event is optional. Steps can retry without it - the retry mechanism works regardless of whether this event is emitted. You may see back-to-back `step_started` events in logs when a step retries after a timeout or when the error is not explicitly captured. See [Errors and Retries](/docs/foundations/errors-and-retries) for more on how retries work.
+The `step_retrying` event is optional. Steps can retry without it - the retry mechanism works regardless of whether this event is emitted. You may see back-to-back `step_started` events in logs when a step retries after a timeout or when the error is not explicitly captured, and also when concurrent replays each commit one (see [Duplicate Events](#duplicate-events)). See [Errors and Retries](/docs/foundations/errors-and-retries) for more on how retries work.
When present, the `step_retrying` event moves a step back to `pending` state and records the error that caused the retry. This provides two benefits:
@@ -225,6 +225,44 @@ Terminal states represent the end of an entity's lifecycle. Once an entity reach
Attempting to create an event that would transition an entity out of a terminal state will result in an error. This prevents inconsistent state and ensures the integrity of the event log.
+That guard sits on the write path. A duplicate the write path does permit — a second `step_created` for a step that is not yet terminal, for example — is handled during replay instead, described next.
+
+## Duplicate Events
+
+Concurrent invocations replaying the same run share one event log. An invocation working from a stale prefix — one that predates another invocation's write — can commit its own `step_created`, `step_started`, or `wait_created` for an entity the log already records one of. These writes pass the terminal-state guard above, so the write path commits them even when a backend validates transitions atomically with the insert.
+
+Those duplicates are committed but inert. The outcome was decided by the first event of its kind at a lower position in the log, and every replay reads that same event at that same position, so a later copy cannot change what the workflow observes.
+
+To keep an inert copy from failing an otherwise healthy run, the runtime groups event types into **classes** and tracks, per entity, which classes the current replay has already consumed. When an event is offered to every registered consumer and none wants it, and its class is already recorded for that entity, the replay steps over it instead of reporting a [replay divergence](/docs/errors/replay-divergence) — which, once the recovery budget is exhausted, ends the run with [`CORRUPTED_EVENT_LOG`](/docs/errors/corrupted-event-log).
+
+| Class | Event types |
+|-------|-------------|
+| `run_started` | `run_started` |
+| `step_created` | `step_created` |
+| `step_started` | `step_started` |
+| `step_retrying` | `step_retrying` |
+| `step_terminal` | `step_completed`, `step_failed` |
+| `wait_created` | `wait_created` |
+| `wait_completed` | `wait_completed` |
+| `hook_created` | `hook_created` |
+| `hook_disposed` | `hook_disposed` |
+
+Types that share a class are the mutually exclusive outcomes of one decision: a step either completes or fails, and the first outcome recorded is the one that counts. Classes are independent of one another, so passing over one does not suppress another. A step whose result is already in the log has still recorded exactly one `step_created`, which is what makes a second one ignorable on its own terms.
+
+The two hook classes cover the same shape of duplicate, and replay reaches them less often because the write path resolves most hook duplicates before they reach the log: a run re-creating a hook it already owns converges on the existing `hook_created` rather than appending a second one, and a second `hook_disposed` for the same hook is refused as an idempotent no-op. A log that holds either anyway is read past like any other repeat.
+
+The remaining event types belong to no class and are never skipped:
+
+- `hook_received`: a hook legitimately receives many payloads under one ID, so a second `hook_received` is not a repeat of anything.
+- `hook_conflict`: records a failed acquisition of a hook's token, which the same run can hit repeatedly over its lifetime as other runs take and release that token. The hook's own consumer stays registered and claims every copy it is offered, so a repeat is consumed rather than reaching the class check.
+- `attr_set`: written on every [`setAttributes()`](/docs/api-reference/workflow/set-attributes) call, so a second write of the same key is a new fact rather than a repeat.
+- `run_created` precedes every replay and is always consumed.
+- `run_completed`, `run_failed`, and `run_cancelled` never reach the check. The runtime exits before replaying the workflow body once the log holds one of them, so no consumer ever takes one and no class is ever recorded for them.
+
+Both kinds of skip are logged at `debug`, so neither reaches the console unless you run with `DEBUG=workflow:runtime:*`. A duplicate is a permanent feature of the log: every later replay re-reads it and lands on the same check, so anything printed unconditionally would print once per replay for the life of the run, and there is nothing to act on either way. A repeat that decides a class differently — a `step_failed` behind a `step_completed`, or the reverse — gets its own message, because unlike a re-commit of the same outcome there is no reading in which both writers were right.
+
+The observability UI greys out the events it can identify this way, with the reason on hover. Its set is narrower than the runtime's: it reads the log without consumer state, and a consumer for an entity that is still open legitimately claims a repeat — each retry of a step writes another `step_started`. So it marks a repeat only once no consumer can remain for it: past a terminal event for the same entity, or a second `run_started`, of which the log records one per run. On a partial view of the log — one page of a paginated list, or search results — it marks nothing, since which copy came first is a property of the whole log.
+
## Event Correlation
Events use a `correlationId` to link related events together. For step, hook, and wait events, the correlation ID identifies the specific entity instance:
diff --git a/docs/content/docs/v5/testing/index.mdx b/docs/content/docs/v5/testing/index.mdx
index ad1f87483f..36a69fda11 100644
--- a/docs/content/docs/v5/testing/index.mdx
+++ b/docs/content/docs/v5/testing/index.mdx
@@ -256,7 +256,7 @@ import { createWebhook } from "workflow";
export async function ingestWorkflow(endpointId: string) {
"use workflow";
- // Webhook tokens are always randomly generated
+ // Webhook tokens are always generated for you
using webhook = createWebhook(); // [!code highlight]
const request = await webhook; // [!code highlight]
@@ -282,7 +282,7 @@ describe("ingestWorkflow", () => {
it("should process webhook data", async () => {
const run = await start(ingestWorkflow, ["ep-1"]);
- // Discover the randomly generated webhook token
+ // Discover the generated webhook token
const hook = await waitForHook(run); // [!code highlight]
// Resume the webhook with a Request object
diff --git a/packages/core/src/create-hook.ts b/packages/core/src/create-hook.ts
index e24a528e23..dce83c4b2d 100644
--- a/packages/core/src/create-hook.ts
+++ b/packages/core/src/create-hook.ts
@@ -117,13 +117,17 @@ export interface HookOptions {
* the token with the information it has available.
*
* Deterministic tokens are intended for use with `createHook()` and
- * server-side `resumeHook()` only. For webhooks (`createWebhook()`),
- * tokens are always randomly generated to prevent unauthorized access
- * to the public webhook endpoint.
+ * server-side `resumeHook()` only. For webhooks (`createWebhook()`), an
+ * explicit token is not accepted — one is always generated for you.
+ *
+ * A generated token is not trivial to guess but is not a security
+ * contract, so authenticate webhook requests themselves rather than
+ * relying on URL secrecy:
+ * https://workflow-sdk.dev/docs/foundations/hooks#token-design
*
* If provided, the token must be a non-empty string; passing an empty
- * string throws. If not provided (or `undefined`), a randomly generated
- * token will be assigned.
+ * string throws. If not provided (or `undefined`), a token is generated
+ * for you.
*
* @example
*
diff --git a/packages/core/src/duplicate-events.test.ts b/packages/core/src/duplicate-events.test.ts
index 5466a386dc..6224b4dbfc 100644
--- a/packages/core/src/duplicate-events.test.ts
+++ b/packages/core/src/duplicate-events.test.ts
@@ -8,6 +8,7 @@ import {
runWithDiscontinuation,
setupWorkflowContext,
} from './test-support/orchestrator-context.js';
+import { createCreateHook } from './workflow/hook.js';
import { createSleep } from './workflow/sleep.js';
/**
@@ -120,6 +121,66 @@ describe('events repeating a class already in the log', () => {
expect(onDuplicateEvent).toHaveBeenCalledWith(events[2], 'wait_created');
});
+ it('ignores a second hook_disposed for a hook already disposed', async () => {
+ const onDuplicateEvent = vi.fn();
+ const events = [
+ event(0, 'hook_created', `hook_${CORR_IDS[0]}`, { token: 'a-token' }),
+ event(1, 'hook_disposed', `hook_${CORR_IDS[0]}`, { token: 'a-token' }),
+ // Both writers of a concurrent teardown reached the dispose. The write
+ // path rejects the second one, so a log holding it is rare, and this is
+ // what replay does when it holds one anyway.
+ event(2, 'hook_disposed', `hook_${CORR_IDS[0]}`, { token: 'a-token' }),
+ event(3, 'step_created', `step_${CORR_IDS[1]}`, {
+ stepName: 'afterHook',
+ }),
+ ];
+ const ctx = setupWorkflowContext(events, { onDuplicateEvent });
+ const createHook = createCreateHook(ctx);
+ const useStep = createUseStep(ctx);
+
+ const { error } = await runWithDiscontinuation(ctx, async () => {
+ const hook = createHook({ token: 'a-token' });
+ hook.dispose();
+ await useStep('afterHook')();
+ return 'done';
+ });
+
+ expect(WorkflowSuspension.is(error)).toBe(true);
+ expect(pendingStepNames(ctx)).toEqual(['afterHook']);
+ expect(onDuplicateEvent).toHaveBeenCalledTimes(1);
+ expect(onDuplicateEvent).toHaveBeenCalledWith(events[2], 'hook_disposed');
+ });
+
+ it('ignores a hook_created that lands after the hook was disposed', async () => {
+ const onDuplicateEvent = vi.fn();
+ const events = [
+ event(0, 'hook_created', `hook_${CORR_IDS[0]}`, { token: 'a-token' }),
+ event(1, 'hook_disposed', `hook_${CORR_IDS[0]}`, { token: 'a-token' }),
+ // A replay working from a prefix that predates the disposal re-created
+ // the hook. Creation has a class of its own, so this is read past rather
+ // than reviving a hook whose scope already exited.
+ event(2, 'hook_created', `hook_${CORR_IDS[0]}`, { token: 'a-token' }),
+ event(3, 'step_created', `step_${CORR_IDS[1]}`, {
+ stepName: 'afterHook',
+ }),
+ ];
+ const ctx = setupWorkflowContext(events, { onDuplicateEvent });
+ const createHook = createCreateHook(ctx);
+ const useStep = createUseStep(ctx);
+
+ const { error } = await runWithDiscontinuation(ctx, async () => {
+ const hook = createHook({ token: 'a-token' });
+ hook.dispose();
+ await useStep('afterHook')();
+ return 'done';
+ });
+
+ expect(WorkflowSuspension.is(error)).toBe(true);
+ expect(pendingStepNames(ctx)).toEqual(['afterHook']);
+ expect(onDuplicateEvent).toHaveBeenCalledTimes(1);
+ expect(onDuplicateEvent).toHaveBeenCalledWith(events[2], 'hook_created');
+ });
+
it('still reports divergence for an event repeating nothing in the log', async () => {
const result = await dehydrate('a-result');
const onDuplicateEvent = vi.fn();
diff --git a/packages/core/src/workflow.ts b/packages/core/src/workflow.ts
index 1ceca96e0d..98b8728269 100644
--- a/packages/core/src/workflow.ts
+++ b/packages/core/src/workflow.ts
@@ -429,6 +429,14 @@ async function createWorkflowSession({
)
);
},
+ // Both branches log at `debug`, so neither reaches the console unless
+ // `DEBUG` matches. A duplicate is a permanent feature of the log: every
+ // later replay re-reads it and lands here again, so anything the logger
+ // prints unconditionally would print once per replay for the life of the
+ // run. There is also nothing to act on. Ignoring the event is the correct
+ // outcome, not a degraded one, and no user change makes the straggler go
+ // away. What the levels are for is diagnosis after the fact, which is
+ // exactly what `DEBUG=workflow:runtime:debug` is for.
onDuplicateEvent: (event, firstEventType) => {
const details = {
workflowRunId: workflowRun.runId,
@@ -443,19 +451,19 @@ async function createWorkflowSession({
// is still correct and still deterministic (replay reads the first one
// at the same position every time), but unlike a re-commit of the same
// outcome there is no reading of this where both writers were right,
- // so the discarded outcome is worth an error in the run's logs.
- runtimeLogger.error(
- 'Ignoring event that decides an already-decided outcome differently',
+ // so the discarded outcome gets its own message.
+ runtimeLogger.debug(
+ 'Ignoring inert event that decides an already-decided outcome differently',
details
);
return;
}
- // Not an error: the first event of this class decided the outcome at a
- // lower log position and replay reads that one. Logged because a
- // straggler is still evidence of two replays writing for the same
- // entity, which is worth seeing when diagnosing a run.
- runtimeLogger.info(
- 'Ignoring event that repeats a class already in the event log',
+ // The first event of this class decided the outcome at a lower log
+ // position and replay reads that one. Recorded because a straggler is
+ // still evidence of two replays writing for the same entity, which is
+ // worth seeing when diagnosing a run.
+ runtimeLogger.debug(
+ 'Ignoring inert event that repeats a class already in the event log',
details
);
},
diff --git a/packages/core/src/workflow/create-hook.ts b/packages/core/src/workflow/create-hook.ts
index 96e31d1b9f..bc773c67d2 100644
--- a/packages/core/src/workflow/create-hook.ts
+++ b/packages/core/src/workflow/create-hook.ts
@@ -62,7 +62,7 @@ export function createWebhook(
if (token !== undefined) {
throw new Error(
- '`createWebhook()` does not accept a `token` option. Webhook tokens are always randomly generated. Use `createHook()` with `resumeHook()` for deterministic token patterns.'
+ '`createWebhook()` does not accept a `token` option. Webhook tokens are always generated for you. Use `createHook()` with `resumeHook()` for deterministic token patterns.'
);
}
diff --git a/packages/core/src/workflow/hook.test.ts b/packages/core/src/workflow/hook.test.ts
index bdaa3e5442..c6c59727db 100644
--- a/packages/core/src/workflow/hook.test.ts
+++ b/packages/core/src/workflow/hook.test.ts
@@ -1264,7 +1264,7 @@ describe('createCreateHook', () => {
const createHook = createCreateHook(ctx);
expect(() => createHook({ token: '' })).toThrow(
- '`createHook()` was called with an empty string token. Pass a non-empty token, or omit the `token` option to use a randomly generated one.'
+ '`createHook()` was called with an empty string token. Pass a non-empty token, or omit the `token` option to use a generated one.'
);
// The rejected hook must not be registered in the invocations queue.
@@ -1290,7 +1290,7 @@ describe('createCreateHook', () => {
describe('createWebhook', () => {
it('should throw when a token option is passed', () => {
expect(() => (createWebhook as any)({ token: 'anything' })).toThrow(
- '`createWebhook()` does not accept a `token` option. Webhook tokens are always randomly generated. Use `createHook()` with `resumeHook()` for deterministic token patterns.'
+ '`createWebhook()` does not accept a `token` option. Webhook tokens are always generated for you. Use `createHook()` with `resumeHook()` for deterministic token patterns.'
);
});
});
diff --git a/packages/core/src/workflow/hook.ts b/packages/core/src/workflow/hook.ts
index d5d5bed8ec..bcb714d89d 100644
--- a/packages/core/src/workflow/hook.ts
+++ b/packages/core/src/workflow/hook.ts
@@ -66,14 +66,14 @@ function createConflictingRun(
export function createCreateHook(ctx: WorkflowOrchestratorContext) {
return function createHookImpl(options: HookOptions = {}): Hook {
// Reject an explicit empty-string token. A token must either be omitted
- // (or `undefined`/`null`) to get a randomly generated one, or be an
+ // (or `undefined`/`null`) to get a generated one, or be an
// explicit non-empty string. An empty string is almost always an
// accidental value (e.g. an unset variable) and would otherwise slip
// through the `??` below — which only falls back for nullish values — and
// be used as a meaningless, non-deterministic token.
if (options.token === '') {
throw new Error(
- '`createHook()` was called with an empty string token. Pass a non-empty token, or omit the `token` option to use a randomly generated one.'
+ '`createHook()` was called with an empty string token. Pass a non-empty token, or omit the `token` option to use a generated one.'
);
}
diff --git a/packages/world/src/test-support/duplicate-event-fixtures.ts b/packages/world/src/test-support/duplicate-event-fixtures.ts
index dbe6947b16..ea77f1deaf 100644
--- a/packages/world/src/test-support/duplicate-event-fixtures.ts
+++ b/packages/world/src/test-support/duplicate-event-fixtures.ts
@@ -131,6 +131,26 @@ export const DUPLICATE_EVENT_FIXTURES: readonly DuplicateEventFixture[] = [
],
ignoredIndices: [],
},
+ {
+ name: 'second disposal of one hook',
+ why: 'A hook closes on disposal, and both writers of a concurrent teardown can reach it. The write path rejects the second one, so the class entry is what covers a log that holds it anyway.',
+ events: [
+ { eventType: 'hook_created', entity: 'hook_a' },
+ { eventType: 'hook_disposed', entity: 'hook_a' },
+ { eventType: 'hook_disposed', entity: 'hook_a' },
+ ],
+ ignoredIndices: [2],
+ },
+ {
+ name: 'hook recreated after it was disposed',
+ why: 'Creation has a class of its own, so a stale replay re-creating a hook whose scope already exited is read past rather than treated as a new hook.',
+ events: [
+ { eventType: 'hook_created', entity: 'hook_a' },
+ { eventType: 'hook_disposed', entity: 'hook_a' },
+ { eventType: 'hook_created', entity: 'hook_a' },
+ ],
+ ignoredIndices: [2],
+ },
{
name: 'two steps in flight',
why: 'Classes are tracked per entity, so sibling steps running the same shape never collide.',
diff --git a/workbench/vitest/test/workflow.test.ts b/workbench/vitest/test/workflow.test.ts
index 176037b2ff..414f232008 100644
--- a/workbench/vitest/test/workflow.test.ts
+++ b/workbench/vitest/test/workflow.test.ts
@@ -87,7 +87,7 @@ describe('webhook workflow', () => {
it('should resume when webhook receives data via resumeWebhook', async () => {
const run = await start(webhookWorkflow, ['endpoint-1']);
- // Webhook tokens are randomly generated, so discover via waitForHook
+ // Webhook tokens are generated for you, so discover via waitForHook
const hook = await waitForHook(run);
await resumeWebhook(
diff --git a/workbench/vitest/workflows/webhook.ts b/workbench/vitest/workflows/webhook.ts
index 22c24fe374..2719f991a8 100644
--- a/workbench/vitest/workflows/webhook.ts
+++ b/workbench/vitest/workflows/webhook.ts
@@ -8,7 +8,7 @@ async function processPayload(body: string) {
export async function webhookWorkflow(endpointId: string) {
'use workflow';
- // createWebhook() does not accept a token — tokens are randomly generated.
+ // createWebhook() does not accept a token — one is always generated for you.
// Use waitForHook() in tests to discover the token.
using webhook = createWebhook();