@adonis-agora/durable 0.6.1 → 0.8.0

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.
Files changed (159) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +1 -1
  3. package/dist/commands/main.d.ts +3 -2
  4. package/dist/commands/main.d.ts.map +1 -1
  5. package/dist/commands/main.js +3 -2
  6. package/dist/commands/main.js.map +1 -1
  7. package/dist/commands/make_workflow.d.ts +4 -3
  8. package/dist/commands/make_workflow.d.ts.map +1 -1
  9. package/dist/commands/make_workflow.js +4 -3
  10. package/dist/commands/make_workflow.js.map +1 -1
  11. package/dist/configure.d.ts +6 -5
  12. package/dist/configure.d.ts.map +1 -1
  13. package/dist/configure.js +9 -6
  14. package/dist/configure.js.map +1 -1
  15. package/dist/providers/dashboard_provider.d.ts +34 -3
  16. package/dist/providers/dashboard_provider.d.ts.map +1 -1
  17. package/dist/providers/dashboard_provider.js +110 -6
  18. package/dist/providers/dashboard_provider.js.map +1 -1
  19. package/dist/providers/durable_provider.d.ts.map +1 -1
  20. package/dist/providers/durable_provider.js +41 -4
  21. package/dist/providers/durable_provider.js.map +1 -1
  22. package/dist/src/base-workflow.d.ts +78 -0
  23. package/dist/src/base-workflow.d.ts.map +1 -0
  24. package/dist/src/base-workflow.js +106 -0
  25. package/dist/src/base-workflow.js.map +1 -0
  26. package/dist/src/codec-state-store.d.ts +13 -0
  27. package/dist/src/codec-state-store.d.ts.map +1 -1
  28. package/dist/src/codec-state-store.js +13 -0
  29. package/dist/src/codec-state-store.js.map +1 -1
  30. package/dist/src/control-flow-signal.d.ts +50 -0
  31. package/dist/src/control-flow-signal.d.ts.map +1 -0
  32. package/dist/src/control-flow-signal.js +54 -0
  33. package/dist/src/control-flow-signal.js.map +1 -0
  34. package/dist/src/control-plane-redis/redis-control-plane.d.ts +57 -5
  35. package/dist/src/control-plane-redis/redis-control-plane.d.ts.map +1 -1
  36. package/dist/src/control-plane-redis/redis-control-plane.js +100 -2
  37. package/dist/src/control-plane-redis/redis-control-plane.js.map +1 -1
  38. package/dist/src/dashboard/auth.d.ts +89 -0
  39. package/dist/src/dashboard/auth.d.ts.map +1 -0
  40. package/dist/src/dashboard/auth.js +113 -0
  41. package/dist/src/dashboard/auth.js.map +1 -0
  42. package/dist/src/dashboard/define_config.d.ts +17 -0
  43. package/dist/src/dashboard/define_config.d.ts.map +1 -1
  44. package/dist/src/dashboard/define_config.js +23 -1
  45. package/dist/src/dashboard/define_config.js.map +1 -1
  46. package/dist/src/dashboard/index.d.ts +6 -1
  47. package/dist/src/dashboard/index.d.ts.map +1 -1
  48. package/dist/src/dashboard/index.js +5 -1
  49. package/dist/src/dashboard/index.js.map +1 -1
  50. package/dist/src/dashboard/login_page.d.ts +23 -0
  51. package/dist/src/dashboard/login_page.d.ts.map +1 -0
  52. package/dist/src/dashboard/login_page.js +170 -0
  53. package/dist/src/dashboard/login_page.js.map +1 -0
  54. package/dist/src/dashboard/session_cookie.d.ts +42 -0
  55. package/dist/src/dashboard/session_cookie.d.ts.map +1 -0
  56. package/dist/src/dashboard/session_cookie.js +97 -0
  57. package/dist/src/dashboard/session_cookie.js.map +1 -0
  58. package/dist/src/define_config.d.ts +11 -2
  59. package/dist/src/define_config.d.ts.map +1 -1
  60. package/dist/src/define_config.js.map +1 -1
  61. package/dist/src/engine.d.ts +91 -3
  62. package/dist/src/engine.d.ts.map +1 -1
  63. package/dist/src/engine.js +331 -44
  64. package/dist/src/engine.js.map +1 -1
  65. package/dist/src/entities.js +2 -2
  66. package/dist/src/entities.js.map +1 -1
  67. package/dist/src/errors.d.ts +6 -1
  68. package/dist/src/errors.d.ts.map +1 -1
  69. package/dist/src/errors.js +7 -2
  70. package/dist/src/errors.js.map +1 -1
  71. package/dist/src/event-accumulators.d.ts +1 -1
  72. package/dist/src/event-accumulators.d.ts.map +1 -1
  73. package/dist/src/events.d.ts +16 -0
  74. package/dist/src/events.d.ts.map +1 -1
  75. package/dist/src/events.js +26 -1
  76. package/dist/src/events.js.map +1 -1
  77. package/dist/src/hooks/steps.d.ts +43 -0
  78. package/dist/src/hooks/steps.d.ts.map +1 -0
  79. package/dist/src/hooks/steps.js +46 -0
  80. package/dist/src/hooks/steps.js.map +1 -0
  81. package/dist/src/hooks/workflows.d.ts +3 -2
  82. package/dist/src/hooks/workflows.d.ts.map +1 -1
  83. package/dist/src/hooks/workflows.js +3 -2
  84. package/dist/src/hooks/workflows.js.map +1 -1
  85. package/dist/src/index.d.ts +8 -2
  86. package/dist/src/index.d.ts.map +1 -1
  87. package/dist/src/index.js +8 -2
  88. package/dist/src/index.js.map +1 -1
  89. package/dist/src/interfaces.d.ts +160 -43
  90. package/dist/src/interfaces.d.ts.map +1 -1
  91. package/dist/src/services/main.d.ts +13 -0
  92. package/dist/src/services/main.d.ts.map +1 -0
  93. package/dist/src/services/main.js +17 -0
  94. package/dist/src/services/main.js.map +1 -0
  95. package/dist/src/step-discovery.d.ts +42 -0
  96. package/dist/src/step-discovery.d.ts.map +1 -0
  97. package/dist/src/step-discovery.js +127 -0
  98. package/dist/src/step-discovery.js.map +1 -0
  99. package/dist/src/step-name-symbol.d.ts +63 -0
  100. package/dist/src/step-name-symbol.d.ts.map +1 -0
  101. package/dist/src/step-name-symbol.js +42 -0
  102. package/dist/src/step-name-symbol.js.map +1 -0
  103. package/dist/src/step-ref.d.ts +70 -0
  104. package/dist/src/step-ref.d.ts.map +1 -0
  105. package/dist/src/step-ref.js +93 -0
  106. package/dist/src/step-ref.js.map +1 -0
  107. package/dist/src/stores/lucid-schema.d.ts +1 -0
  108. package/dist/src/stores/lucid-schema.d.ts.map +1 -1
  109. package/dist/src/stores/lucid-schema.js +15 -0
  110. package/dist/src/stores/lucid-schema.js.map +1 -1
  111. package/dist/src/stores/lucid.d.ts +13 -0
  112. package/dist/src/stores/lucid.d.ts.map +1 -1
  113. package/dist/src/stores/lucid.js +43 -0
  114. package/dist/src/stores/lucid.js.map +1 -1
  115. package/dist/src/tenant-group.d.ts +18 -0
  116. package/dist/src/tenant-group.d.ts.map +1 -0
  117. package/dist/src/tenant-group.js +24 -0
  118. package/dist/src/tenant-group.js.map +1 -0
  119. package/dist/src/testing/in-memory-state-store.d.ts +16 -0
  120. package/dist/src/testing/in-memory-state-store.d.ts.map +1 -1
  121. package/dist/src/testing/in-memory-state-store.js +29 -0
  122. package/dist/src/testing/in-memory-state-store.js.map +1 -1
  123. package/dist/src/testing-kit/state-store-conformance.d.ts.map +1 -1
  124. package/dist/src/testing-kit/state-store-conformance.js +33 -2
  125. package/dist/src/testing-kit/state-store-conformance.js.map +1 -1
  126. package/dist/src/testing-kit/transport-conformance.d.ts.map +1 -1
  127. package/dist/src/testing-kit/transport-conformance.js +7 -12
  128. package/dist/src/testing-kit/transport-conformance.js.map +1 -1
  129. package/dist/src/transports/db.d.ts +14 -5
  130. package/dist/src/transports/db.d.ts.map +1 -1
  131. package/dist/src/transports/db.js +20 -15
  132. package/dist/src/transports/db.js.map +1 -1
  133. package/dist/src/transports/queue.d.ts +15 -5
  134. package/dist/src/transports/queue.d.ts.map +1 -1
  135. package/dist/src/transports/queue.js +19 -17
  136. package/dist/src/transports/queue.js.map +1 -1
  137. package/dist/src/workflow-als.d.ts +21 -0
  138. package/dist/src/workflow-als.d.ts.map +1 -0
  139. package/dist/src/workflow-als.js +22 -0
  140. package/dist/src/workflow-als.js.map +1 -0
  141. package/dist/src/workflow-ctx.d.ts +27 -8
  142. package/dist/src/workflow-ctx.d.ts.map +1 -1
  143. package/dist/src/workflow-ctx.js +183 -19
  144. package/dist/src/workflow-ctx.js.map +1 -1
  145. package/dist/src/workflow-discovery.d.ts +21 -15
  146. package/dist/src/workflow-discovery.d.ts.map +1 -1
  147. package/dist/src/workflow-discovery.js +17 -13
  148. package/dist/src/workflow-discovery.js.map +1 -1
  149. package/dist/src/workflow-ref.d.ts +9 -27
  150. package/dist/src/workflow-ref.d.ts.map +1 -1
  151. package/dist/src/workflow-ref.js +12 -34
  152. package/dist/src/workflow-ref.js.map +1 -1
  153. package/dist/stubs/database/migrations/create_durable_tables.stub +13 -1
  154. package/dist/stubs/make/workflow/main.stub +5 -4
  155. package/package.json +11 -1
  156. package/dist/src/remote-step-factory.d.ts +0 -15
  157. package/dist/src/remote-step-factory.d.ts.map +0 -1
  158. package/dist/src/remote-step-factory.js +0 -12
  159. package/dist/src/remote-step-factory.js.map +0 -1
@@ -7,8 +7,11 @@ import { ContinueAsNew, NonDeterminismError, RemoteStepTimeout, WorkflowSuspende
7
7
  import { EventAccumulators } from './event-accumulators.js';
8
8
  import { eventMatchOf, eventMatches, eventPrefix } from './events.js';
9
9
  import { breakpointToken, stepId } from './protocol.js';
10
+ import { RemoteWorkflowExecutor } from './remote-workflow-executor.js';
10
11
  import { SingletonGate } from './singleton-gate.js';
12
+ import { sanitizeQueueToken, tenantGroup } from './tenant-group.js';
11
13
  import { TransportPool } from './transport-pool.js';
14
+ import { workflowAls } from './workflow-als.js';
12
15
  import { createWorkflowCtx, } from './workflow-ctx.js';
13
16
  import { workflowName, } from './workflow-ref.js';
14
17
  /** Union of a workflow's static tags and a run's start-time tags, de-duplicated, or undefined if none. */
@@ -52,6 +55,11 @@ export class WorkflowEngine {
52
55
  store;
53
56
  /** Ordered transport pool (dispatch + failover). Empty = no remote steps. */
54
57
  pool;
58
+ /** The primary task transport (first of the pool), used to build an on-the-fly remote-workflow
59
+ * executor for {@link WorkflowEngineDeps.remoteByConvention}. Undefined when no transport is wired. */
60
+ primaryTransport;
61
+ /** Convention-dispatch opt-in — see {@link WorkflowEngineDeps.remoteByConvention}. */
62
+ remoteByConvention;
55
63
  controlPlane;
56
64
  clock;
57
65
  instanceId;
@@ -110,6 +118,8 @@ export class WorkflowEngine {
110
118
  constructor(deps) {
111
119
  this.store = deps.store;
112
120
  this.pool = new TransportPool(deps.transports ?? (deps.transport ? [{ id: 'default', transport: deps.transport }] : []));
121
+ this.primaryTransport = deps.transports?.[0]?.transport ?? deps.transport;
122
+ this.remoteByConvention = deps.remoteByConvention ?? false;
113
123
  this.controlPlane = deps.controlPlane;
114
124
  this.clock = deps.clock ?? Date.now;
115
125
  this.admission = deps.admission ?? new InMemoryAdmissionBackend(this.clock);
@@ -294,7 +304,28 @@ export class WorkflowEngine {
294
304
  this.latest.set(name, registered);
295
305
  }
296
306
  /**
297
- * Register a flow-control queue referenced by `ctx.call(step, input, { queue })`. Caps concurrent
307
+ * Convention-dispatch resolution (opt-in via {@link WorkflowEngineDeps.remoteByConvention}): when
308
+ * `name` isn't locally registered but a LIVE worker group of the same name exists, auto-register a
309
+ * remote workflow routed to that group (bare group, no partition) so its runs advance over the
310
+ * broker like any `registerRemote`'d one. Returns whether `name` is registered afterwards. A no-op
311
+ * (returns `false`) when the opt-in is off, no transport is wired, or no matching group is live.
312
+ */
313
+ async ensureConventionWorkflow(name) {
314
+ if (!this.remoteByConvention || !this.primaryTransport)
315
+ return false;
316
+ if (this.latest.get(name))
317
+ return true;
318
+ const groups = await this.pool.listWorkerGroups();
319
+ if (!groups.includes(name))
320
+ return false;
321
+ this.registerRemote(name, '1', {
322
+ group: name,
323
+ executor: new RemoteWorkflowExecutor(this.primaryTransport, name),
324
+ });
325
+ return true;
326
+ }
327
+ /**
328
+ * Register a flow-control queue referenced by `ctx.step(name, input, { queue })`. Caps concurrent
298
329
  * in-flight steps and/or the admission rate; blocked calls re-suspend and retry, so the limit is
299
330
  * durable. Per engine instance (see {@link QueueConfig}). Registering the same name replaces it.
300
331
  */
@@ -394,7 +425,12 @@ export class WorkflowEngine {
394
425
  }
395
426
  async start(workflow, input, runId, opts) {
396
427
  const name = workflowName(workflow);
397
- const registered = this.latest.get(name);
428
+ let registered = this.latest.get(name);
429
+ // remoteByConvention: an unregistered workflow whose name matches a LIVE worker group is routed to
430
+ // it as a remote workflow (bare group). Off by default → the original "not registered" throw.
431
+ if (!registered && (await this.ensureConventionWorkflow(name))) {
432
+ registered = this.latest.get(name);
433
+ }
398
434
  if (!registered)
399
435
  throw new Error(`workflow ${name} is not registered`);
400
436
  // Validate the input up front — a bad payload is rejected before any run is created.
@@ -421,7 +457,7 @@ export class WorkflowEngine {
421
457
  workflow: name,
422
458
  workflowVersion: registered.version,
423
459
  status: 'pending',
424
- namespace: this.namespace,
460
+ namespace: opts?.namespace ?? this.namespace,
425
461
  input,
426
462
  tags,
427
463
  searchAttributes: opts?.searchAttributes,
@@ -496,7 +532,14 @@ export class WorkflowEngine {
496
532
  }
497
533
  // Pin to the version the run STARTED on — replay is positional, so running a changed
498
534
  // workflow body against old checkpoints would corrupt the run.
499
- const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion));
535
+ let registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion));
536
+ // remoteByConvention: a run recovered on an instance that never registered this workflow (e.g. a
537
+ // crash-recovery pickup) is re-routed to a live worker group of the same name, if one exists.
538
+ if (!registered && (await this.ensureConventionWorkflow(run.workflow))) {
539
+ registered =
540
+ this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ??
541
+ this.latest.get(run.workflow);
542
+ }
500
543
  if (!registered) {
501
544
  throw new Error(`workflow ${run.workflow}@${run.workflowVersion} is not registered — keep the prior version deployed so in-flight runs can drain (skew protection)`);
502
545
  }
@@ -550,7 +593,13 @@ export class WorkflowEngine {
550
593
  continue;
551
594
  const error = { message: 'execution timeout', code: 'execution_timeout' };
552
595
  await this.store.updateRun(run.id, { status: 'cancelled', error, updatedAt: new Date() });
553
- this.emit({ type: 'run.failed', runId: run.id, workflow: run.workflow, error });
596
+ this.emit({
597
+ type: 'run.failed',
598
+ runId: run.id,
599
+ workflow: run.workflow,
600
+ namespace: run.namespace,
601
+ error,
602
+ });
554
603
  }
555
604
  }
556
605
  }
@@ -597,7 +646,13 @@ export class WorkflowEngine {
597
646
  };
598
647
  await this.store.updateRun(run.id, { status: 'dead', error, updatedAt: new Date() });
599
648
  await this.store.releaseRunLock(run.id);
600
- this.emit({ type: 'run.failed', runId: run.id, workflow: run.workflow, error });
649
+ this.emit({
650
+ type: 'run.failed',
651
+ runId: run.id,
652
+ workflow: run.workflow,
653
+ namespace: run.namespace,
654
+ error,
655
+ });
601
656
  this.notifyDead({ ...run, status: 'dead', error, recoveryAttempts: attempts });
602
657
  return { runId: run.id, status: 'dead', error };
603
658
  }
@@ -659,7 +714,9 @@ export class WorkflowEngine {
659
714
  if (!run)
660
715
  return null;
661
716
  const id = newRunId ?? `${runId}~retry~${globalThis.crypto.randomUUID().slice(0, 8)}`;
662
- await this.start(run.workflow, input, id, { tags: run.tags });
717
+ // Inherit the original run's namespace so a retry lands in the SAME worker-pool partition (an
718
+ // operator-routed run keeps its tenant on retry, rather than falling back to the engine's own).
719
+ await this.start(run.workflow, input, id, { tags: run.tags, namespace: run.namespace });
663
720
  return { runId: id };
664
721
  }
665
722
  /**
@@ -677,9 +734,17 @@ export class WorkflowEngine {
677
734
  * Resolve once `runId` reaches a settled state — terminal (completed/failed/cancelled/dead) or
678
735
  * suspended (handed off to a timer/signal/event). The async counterpart to dispatch: pair it with
679
736
  * `start` when a call site needs the outcome — `await start(...); const r = await waitForRun(id)`.
737
+ *
738
+ * Pass `{ terminal: true }` to wait for a strictly TERMINAL state only (completed/failed/cancelled/
739
+ * dead) — a `suspended` run (e.g. one parked on `ctx.sleep`/`ctx.waitForSignal`) does NOT resolve;
740
+ * the wait continues until the run reaches an end state. This is what `BaseWorkflow.start` needs so
741
+ * "I want the result" blocks through a suspension instead of returning early with no output. The
742
+ * default (`terminal` unset/false) resolves on any settled state, suspended included.
680
743
  */
681
744
  waitForRun(runId, opts) {
745
+ const isTerminal = (s) => s === 'completed' || s === 'failed' || s === 'cancelled' || s === 'dead';
682
746
  const isSettled = (s) => s !== 'pending' && s !== 'running';
747
+ const shouldResolve = opts?.terminal ? isTerminal : isSettled;
683
748
  const toResult = (run) => ({
684
749
  runId,
685
750
  status: run.status,
@@ -709,7 +774,7 @@ export class WorkflowEngine {
709
774
  };
710
775
  const check = () => {
711
776
  void this.store.getRun(runId).then((run) => {
712
- if (run && isSettled(run.status))
777
+ if (run && shouldResolve(run.status))
713
778
  finish(run);
714
779
  });
715
780
  };
@@ -753,6 +818,17 @@ export class WorkflowEngine {
753
818
  * 2. **Starts** a fresh run of every workflow registered with `onEvent: [name]`, passing the
754
819
  * payload as input. Idempotent by `evt:<id>:<workflow>` — pass `opts.id` to dedupe redeliveries
755
820
  * of the same logical event (default: a fresh uuid, so each publish triggers once).
821
+ *
822
+ * Reliable (buffered) delivery — mirrors `signalWithStart`'s reliability contract, so document it
823
+ * exactly: a publish that resumes ≥1 live waiter, OR routes into an `eventBatch` accumulator /
824
+ * starts ≥1 subscriber, is NOT buffered (fan-out stays live-only). A publish that touches NEITHER
825
+ * buffers ONE copy (via {@link StateStore.bufferEvent}) unless `opts.buffer === false`, so it isn't
826
+ * silently dropped just because nobody was listening yet. That buffered copy is consumed by the
827
+ * FIRST future `ctx.waitForEvent(name, { match })` whose match accepts its payload — point-to-point
828
+ * on redelivery, never by a later-registered onEvent subscriber. Right after buffering, this
829
+ * re-checks `listSignalWaiters` ONCE (sandwich parity with `signal`'s take → buffer → re-check) so a
830
+ * waiter that registers in the sliver between the initial miss and the buffer write is still paired
831
+ * instead of leaving both rows stranded (`waitForEvent`'s post-registration scan is the mirror half).
756
832
  */
757
833
  async publishEvent(name, payload, opts) {
758
834
  let touched = 0;
@@ -786,20 +862,45 @@ export class WorkflowEngine {
786
862
  }
787
863
  }
788
864
  }
865
+ // Nobody received it live (no waiter matched, no subscriber exists/started) — buffer ONE copy
866
+ // unless the caller opted out. `touched > 0` covers BOTH the waiter fan-out and the subscriber loop.
867
+ if (touched > 0 || opts?.buffer === false)
868
+ return touched;
869
+ const bufferedId = globalThis.crypto.randomUUID();
870
+ await this.store.bufferEvent({ name, payload, id: bufferedId, publishedAt: this.clock() });
871
+ // Re-check: a waiter may have registered in the window between the miss above and this buffer
872
+ // write (the events-side mirror of engine.signal's interleaving proof). Only the FIRST
873
+ // late-registered matching waiter matters — only one buffered copy exists.
874
+ const lateWaiters = await this.store.listSignalWaiters(eventPrefix(name));
875
+ const lateWaiter = lateWaiters.find((w) => eventMatches(payload, eventMatchOf(w.token)));
876
+ if (!lateWaiter)
877
+ return touched;
878
+ if (!(await this.store.removeBufferedEvent(bufferedId)))
879
+ return touched; // claimed elsewhere already
880
+ // `takeSignalWaiter` is safe here even though it deletes ANY row for the token: an event token
881
+ // embeds this ONE `waitForEvent` call's own `runId#seq` (see eventToken in events.ts), so no other
882
+ // registration could ever share it.
883
+ const waiter = await this.store.takeSignalWaiter(lateWaiter.token);
884
+ if (waiter) {
885
+ const settled = await this.deliverSignal(waiter, payload);
886
+ if (settled)
887
+ touched += 1;
888
+ }
889
+ // If `waiter` is null, that exact registration resolved itself some other way in the interim (most
890
+ // likely its own timeout deadline) — the buffered copy is already spent, so drop it rather than
891
+ // resuming a run a second time or re-buffering for a THIRD, unrelated waiter to pick up later.
789
892
  return touched;
790
893
  }
791
- async signal(token, payload) {
792
- const waiter = await this.store.takeSignalWaiter(token);
793
- if (!waiter) {
794
- // No one is waiting yet — buffer it so the next `waitForSignal(token)` consumes it instead of
795
- // dropping it (reliable signals; the basis of `signalWithStart`).
796
- await this.store.bufferSignal(token, payload);
797
- return null;
798
- }
894
+ /**
895
+ * Deliver a signal to an already-known waiter: write its resolving `signal:<token>` checkpoint and
896
+ * resume the run. Shared by {@link signal}'s direct hit and its post-buffer re-check reclaim — both
897
+ * resolve a waiter the exact same way.
898
+ */
899
+ async deliverSignal(waiter, payload) {
799
900
  await this.store.saveCheckpoint(instantCheckpoint({
800
901
  runId: waiter.runId,
801
902
  seq: waiter.seq,
802
- name: `signal:${token}`,
903
+ name: `signal:${waiter.token}`,
803
904
  kind: 'signal',
804
905
  output: payload,
805
906
  // Carry the awaiting command's fan group (set when a `ctx.gather_children`/`ctx.all` fan-out
@@ -810,6 +911,40 @@ export class WorkflowEngine {
810
911
  }));
811
912
  return this.resume(waiter.runId);
812
913
  }
914
+ /**
915
+ * Deliver an external signal to the run waiting on `token`, resume it with `payload`, and return
916
+ * the run result — or null if no run is (or ends up) waiting for it.
917
+ *
918
+ * Race with the waiter side (`waitForSignal`'s check → `putSignalWaiter` → re-check): a waiter can
919
+ * register in the sliver between this method's initial `takeSignalWaiter` miss and its
920
+ * `bufferSignal` write, and the naive old flow (buffer and return) would then leave BOTH a buffered
921
+ * payload and a registered waiter sitting in the store with nothing left to pair them — a lost wake,
922
+ * forever suspended. The interleaving proof: the waiter side does check → put → re-check, this side
923
+ * does take → buffer → re-check; whichever side's SECOND look runs last always observes the other
924
+ * side's write, and the destructive `take*` ops arbitrate so a signal is never delivered twice.
925
+ */
926
+ async signal(token, payload) {
927
+ const waiter = await this.store.takeSignalWaiter(token);
928
+ if (waiter)
929
+ return this.deliverSignal(waiter, payload);
930
+ // No one was waiting yet — buffer it so the next `waitForSignal(token)` consumes it instead of
931
+ // dropping it (reliable signals; the basis of `signalWithStart`).
932
+ await this.store.bufferSignal(token, payload);
933
+ // Re-check: a waiter may have registered in the window between the miss above and this buffer
934
+ // write (see the interleaving proof above).
935
+ const lateWaiter = await this.store.takeSignalWaiter(token);
936
+ if (!lateWaiter)
937
+ return null;
938
+ const reclaimed = await this.store.takeBufferedSignal(token);
939
+ if (!reclaimed) {
940
+ // The waiter side won the race to consume the buffer itself (its own checkpoint+resume already
941
+ // delivered the payload) in the sliver between our takeSignalWaiter and takeBufferedSignal just
942
+ // above. We've already (destructively) taken `lateWaiter` off the store, but there is nothing
943
+ // left to deliver — drop it deliberately rather than resuming the run a second time.
944
+ return null;
945
+ }
946
+ return this.deliverSignal(lateWaiter, reclaimed.payload);
947
+ }
813
948
  /**
814
949
  * Ensure a run exists for `runId`, then deliver a signal to it — atomically race-free thanks to
815
950
  * signal buffering: if the run is new (or busy / not yet waiting), the signal is buffered and
@@ -848,6 +983,27 @@ export class WorkflowEngine {
848
983
  notifyParent(runId, completion) {
849
984
  void this.signal(`child:${runId}`, completion).catch(() => undefined);
850
985
  }
986
+ /**
987
+ * Deferred child start shared by the in-process ctx host and the remote `startChild` command.
988
+ * Deferred (microtask) so a fast child can't reentrantly resume a still-suspending parent. A start
989
+ * that THROWS (unregistered/unroutable workflow, input validation, singleton back-pressure, store
990
+ * failure) must NOT be swallowed: the parent is already suspended on `child:<childId>` (the waiter
991
+ * is put BEFORE start on every path), so a silent drop parks it in suspended-forever, invisibly
992
+ * re-attempting on every recovery wake — a misconfigured remote child looks exactly like a healthy
993
+ * long wait. Instead the failure is delivered to that waiter like a failed child (notifyParent):
994
+ * the parent resumes and fails loudly with the cause. For a fire-and-forget `ctx.startChild`
995
+ * (spawn — no waiter) the completion is buffered; a later join by the same id consumes it and
996
+ * correctly observes the failed start.
997
+ */
998
+ startChildDeferred(workflow, input, childId, opts) {
999
+ queueMicrotask(() => void this.start(workflow, input, childId, opts).catch((err) => {
1000
+ const message = err instanceof Error ? err.message : String(err);
1001
+ this.notifyParent(childId, {
1002
+ ok: false,
1003
+ error: `child workflow "${workflow}" failed to start: ${message}`,
1004
+ });
1005
+ }));
1006
+ }
851
1007
  /**
852
1008
  * Cancel a run (e.g. from the dashboard). Returns null if the run does not exist. Pass
853
1009
  * `{ compensate: true }` to undo the saga first: the suspended run is resumed so its completed
@@ -885,7 +1041,13 @@ export class WorkflowEngine {
885
1041
  }
886
1042
  const error = { message: 'cancelled' };
887
1043
  await this.store.updateRun(runId, { status: 'cancelled', error, updatedAt: new Date() });
888
- this.emit({ type: 'run.failed', runId, workflow: run.workflow, error });
1044
+ this.emit({
1045
+ type: 'run.failed',
1046
+ runId,
1047
+ workflow: run.workflow,
1048
+ namespace: run.namespace,
1049
+ error,
1050
+ });
889
1051
  await this.cancelChildren(runId, opts);
890
1052
  // Notify local cancel listeners now (a worker on this pod), and broadcast so the instance/worker
891
1053
  // actually running this run learns of it and can abort cooperatively (the store already records
@@ -1204,6 +1366,7 @@ export class WorkflowEngine {
1204
1366
  type: 'run.completed',
1205
1367
  runId: run.id,
1206
1368
  workflow: run.workflow,
1369
+ namespace: run.namespace,
1207
1370
  output: outcome.output,
1208
1371
  });
1209
1372
  void this.notifyParent(run.id, { ok: true, value: outcome.output });
@@ -1215,13 +1378,28 @@ export class WorkflowEngine {
1215
1378
  type: 'run.failed',
1216
1379
  runId: run.id,
1217
1380
  workflow: run.workflow,
1381
+ namespace: run.namespace,
1218
1382
  error: outcome.error,
1219
1383
  });
1220
1384
  void this.notifyParent(run.id, { ok: false, error: outcome.error.message });
1221
1385
  return { runId: run.id, status: 'failed', error: outcome.error };
1222
1386
  }
1387
+ // This outcome was computed by a turn that started from a possibly-stale run snapshot. If the run
1388
+ // was cancelled WHILE that turn was still executing (e.g. `ctx.all`'s failFast cancelling a
1389
+ // sibling mid-turn — plain `cancel()` writes `cancelled` directly, without waiting for the target's
1390
+ // in-flight turn to notice), this now-stale "suspended" outcome must not resurrect it: re-check the
1391
+ // CURRENT persisted status right before writing and echo it instead of clobbering a real cancel.
1392
+ const latest = await this.store.getRun(run.id);
1393
+ if (latest?.status === 'cancelled') {
1394
+ return { runId: run.id, status: 'cancelled', error: latest.error };
1395
+ }
1223
1396
  await this.store.updateRun(run.id, { status: 'suspended', wakeAt: outcome.wakeAt, updatedAt });
1224
- this.emit({ type: 'run.suspended', runId: run.id, workflow: run.workflow });
1397
+ this.emit({
1398
+ type: 'run.suspended',
1399
+ runId: run.id,
1400
+ workflow: run.workflow,
1401
+ namespace: run.namespace,
1402
+ });
1225
1403
  return { runId: run.id, status: 'suspended' };
1226
1404
  }
1227
1405
  async runRemoteExecution(run, registered) {
@@ -1229,12 +1407,22 @@ export class WorkflowEngine {
1229
1407
  if (run.status === 'pending') {
1230
1408
  await this.store.updateRun(run.id, { status: 'running', updatedAt: new Date() });
1231
1409
  run.status = 'running';
1232
- this.emit({ type: 'run.started', runId: run.id, workflow: run.workflow });
1410
+ this.emit({
1411
+ type: 'run.started',
1412
+ runId: run.id,
1413
+ workflow: run.workflow,
1414
+ namespace: run.namespace,
1415
+ });
1233
1416
  }
1234
1417
  if (registered.singleton && !(await this.singletons.admit(run, registered.singleton))) {
1235
1418
  const wakeAt = this.singletons.retryWakeAt();
1236
1419
  await this.store.updateRun(run.id, { status: 'suspended', wakeAt, updatedAt: new Date() });
1237
- this.emit({ type: 'run.suspended', runId: run.id, workflow: run.workflow });
1420
+ this.emit({
1421
+ type: 'run.suspended',
1422
+ runId: run.id,
1423
+ workflow: run.workflow,
1424
+ namespace: run.namespace,
1425
+ });
1238
1426
  await this.store.releaseRunLock(run.id);
1239
1427
  return { runId: run.id, status: 'suspended' };
1240
1428
  }
@@ -1249,7 +1437,13 @@ export class WorkflowEngine {
1249
1437
  stack: err instanceof Error ? err.stack : undefined,
1250
1438
  };
1251
1439
  await this.store.updateRun(run.id, { status: 'failed', error, updatedAt: new Date() });
1252
- this.emit({ type: 'run.failed', runId: run.id, workflow: run.workflow, error });
1440
+ this.emit({
1441
+ type: 'run.failed',
1442
+ runId: run.id,
1443
+ workflow: run.workflow,
1444
+ namespace: run.namespace,
1445
+ error,
1446
+ });
1253
1447
  return { runId: run.id, status: 'failed', error };
1254
1448
  }
1255
1449
  if (decision.status === 'completed') {
@@ -1375,6 +1569,10 @@ export class WorkflowEngine {
1375
1569
  // Mirrors the `startChild` `getRun(childId)` guard below.
1376
1570
  if (await this.store.getCheckpoint(run.id, cmd.seq))
1377
1571
  continue;
1572
+ // Route this cross-SDK-worker `call` by the SAME name-based token a native `ctx.step`
1573
+ // dispatches with (and a worker subscribes to per handler name), so a decision-driven remote
1574
+ // step and an in-process one land on the identical queue. The command carries no partition.
1575
+ const callToken = tenantGroup(sanitizeQueueToken(cmd.name), undefined);
1378
1576
  await this.store.saveCheckpoint(stepCheckpoint({
1379
1577
  runId: run.id,
1380
1578
  seq: cmd.seq,
@@ -1383,13 +1581,13 @@ export class WorkflowEngine {
1383
1581
  status: 'pending',
1384
1582
  input: cmd.input,
1385
1583
  attempts: 1,
1386
- workerGroup: cmd.group,
1584
+ workerGroup: callToken,
1387
1585
  enqueuedAt: at,
1388
1586
  startedAt: at,
1389
1587
  finishedAt: at,
1390
1588
  // A `ctx.gather_calls([...])` fan-out stamps every dispatched `call` in the fan with the
1391
1589
  // same group, so the dashboard renders the remote steps as one parallel fan (parity with
1392
- // the gathered `recordStep`/`startChild` tags). Undefined for a lone sequential `ctx.call`.
1590
+ // the gathered `recordStep`/`startChild` tags). Undefined for a lone sequential `ctx.step`.
1393
1591
  parallelGroup: cmd.parallelGroup,
1394
1592
  }));
1395
1593
  await this.pool.dispatch({
@@ -1397,7 +1595,7 @@ export class WorkflowEngine {
1397
1595
  seq: cmd.seq,
1398
1596
  name: cmd.name,
1399
1597
  stepId: id,
1400
- group: cmd.group,
1598
+ group: callToken,
1401
1599
  input: cmd.input,
1402
1600
  traceparent: this.traceparent?.(),
1403
1601
  context: this.context?.(),
@@ -1433,19 +1631,33 @@ export class WorkflowEngine {
1433
1631
  // engine.signal(name, payload) delivers it. If the signal was already delivered (buffered
1434
1632
  // before the workflow reached this point — e.g. signalWithStart), resolve it now and re-drive
1435
1633
  // on a macrotask, AFTER this turn suspends and frees the run lock (a re-entrant resume bails).
1436
- const buffered = await this.store.takeBufferedSignal(cmd.signal);
1437
- if (buffered) {
1634
+ const deliverBuffered = async (payload) => {
1438
1635
  await this.store.saveCheckpoint(instantCheckpoint({
1439
1636
  runId: run.id,
1440
1637
  seq: cmd.seq,
1441
1638
  name: `signal:${cmd.signal}`,
1442
1639
  kind: 'signal',
1443
- output: buffered.payload,
1640
+ output: payload,
1444
1641
  }));
1445
1642
  setTimeout(() => void this.resume(run.id).catch(() => undefined), 0);
1643
+ };
1644
+ const buffered = await this.store.takeBufferedSignal(cmd.signal);
1645
+ if (buffered) {
1646
+ await deliverBuffered(buffered.payload);
1446
1647
  }
1447
1648
  else {
1649
+ // Same reorder as the in-process ctx.waitForSignal: register on a miss, then re-check once
1650
+ // more before suspending — closing the lost-wake window where a signal races in between the
1651
+ // miss above and the registration below (see the interleaving proof at engine.signal).
1448
1652
  await this.store.putSignalWaiter({ token: cmd.signal, runId: run.id, seq: cmd.seq });
1653
+ const lateBuffered = await this.store.takeBufferedSignal(cmd.signal);
1654
+ if (lateBuffered) {
1655
+ // Resolved it ourselves — remove OUR OWN row via the exact match (not
1656
+ // takeSignalWaiter(cmd.signal), which deletes ANY row for this token and could steal a
1657
+ // different run's waiter that has since claimed the same token).
1658
+ await this.store.removeSignalWaiter({ token: cmd.signal, runId: run.id, seq: cmd.seq });
1659
+ await deliverBuffered(lateBuffered.payload);
1660
+ }
1449
1661
  }
1450
1662
  }
1451
1663
  else if (cmd.kind === 'startChild') {
@@ -1466,7 +1678,7 @@ export class WorkflowEngine {
1466
1678
  parallelGroup: cmd.parallelGroup,
1467
1679
  });
1468
1680
  if (!(await this.store.getRun(childId))) {
1469
- queueMicrotask(() => void this.start(cmd.workflow, cmd.input, childId).catch(() => undefined));
1681
+ this.startChildDeferred(cmd.workflow, cmd.input, childId);
1470
1682
  }
1471
1683
  }
1472
1684
  else {
@@ -1483,14 +1695,24 @@ export class WorkflowEngine {
1483
1695
  if (run.status === 'pending') {
1484
1696
  await this.store.updateRun(run.id, { status: 'running', updatedAt: new Date() });
1485
1697
  run.status = 'running';
1486
- this.emit({ type: 'run.started', runId: run.id, workflow: run.workflow });
1698
+ this.emit({
1699
+ type: 'run.started',
1700
+ runId: run.id,
1701
+ workflow: run.workflow,
1702
+ namespace: run.namespace,
1703
+ });
1487
1704
  }
1488
1705
  // Singleton admission gate: if this run shares its key with `limit` older in-flight runs, wait
1489
1706
  // (suspend on a short timer) until a slot frees instead of running now. Re-checked on each resume.
1490
1707
  if (registered?.singleton && !(await this.singletons.admit(run, registered.singleton))) {
1491
1708
  const wakeAt = this.singletons.retryWakeAt();
1492
1709
  await this.store.updateRun(run.id, { status: 'suspended', wakeAt, updatedAt: new Date() });
1493
- this.emit({ type: 'run.suspended', runId: run.id, workflow: run.workflow });
1710
+ this.emit({
1711
+ type: 'run.suspended',
1712
+ runId: run.id,
1713
+ workflow: run.workflow,
1714
+ namespace: run.namespace,
1715
+ });
1494
1716
  await this.store.releaseRunLock(run.id);
1495
1717
  return { runId: run.id, status: 'suspended' };
1496
1718
  }
@@ -1507,7 +1729,10 @@ export class WorkflowEngine {
1507
1729
  replay.set(cp.seq, cp);
1508
1730
  const ctx = createWorkflowCtx(this.ctxHostFor(replay), run.id, compensations, run.workflow);
1509
1731
  try {
1510
- const output = await fn(ctx, run.input);
1732
+ // Establish the ambient ctx for the duration of this body turn so context-aware statics
1733
+ // (`BaseWorkflow.start`/`dispatch`) reachable from `fn` route through this run's `ctx.child`/
1734
+ // `ctx.startChild`. Re-set on every replay turn (each is its own async scope) — correct.
1735
+ const output = await workflowAls.run(ctx, () => fn(ctx, run.input));
1511
1736
  return this.settleRun(run, { kind: 'completed', output });
1512
1737
  }
1513
1738
  catch (err) {
@@ -1521,7 +1746,12 @@ export class WorkflowEngine {
1521
1746
  error: undefined,
1522
1747
  updatedAt: new Date(),
1523
1748
  });
1524
- this.emit({ type: 'run.completed', runId: run.id, workflow: run.workflow });
1749
+ this.emit({
1750
+ type: 'run.completed',
1751
+ runId: run.id,
1752
+ workflow: run.workflow,
1753
+ namespace: run.namespace,
1754
+ });
1525
1755
  void this.notifyParent(run.id, { ok: true, value: undefined });
1526
1756
  const nextId = nextContinuationId(run.id);
1527
1757
  queueMicrotask(() => void this.start(run.workflow, err.input, nextId).catch(() => undefined));
@@ -1539,7 +1769,13 @@ export class WorkflowEngine {
1539
1769
  }
1540
1770
  const error = { message: 'cancelled' };
1541
1771
  await this.store.updateRun(run.id, { status: 'cancelled', error, updatedAt: new Date() });
1542
- this.emit({ type: 'run.failed', runId: run.id, workflow: run.workflow, error });
1772
+ this.emit({
1773
+ type: 'run.failed',
1774
+ runId: run.id,
1775
+ workflow: run.workflow,
1776
+ namespace: run.namespace,
1777
+ error,
1778
+ });
1543
1779
  return { runId: run.id, status: 'cancelled', error };
1544
1780
  }
1545
1781
  return this.settleRun(run, { kind: 'suspended', wakeAt: err.wakeAt });
@@ -1569,14 +1805,21 @@ export class WorkflowEngine {
1569
1805
  /**
1570
1806
  * Run one saga compensation, retried up to `compensationRetries`, emitting a `compensate:<step>`
1571
1807
  * step event for its outcome so a stranded undo is visible. Never throws — a permanently-failing
1572
- * compensation is skipped so it can't mask the original failure.
1808
+ * compensation is skipped so it can't mask the original failure. Handles both compensation shapes:
1809
+ * a LOCAL `fn` (run in-process) and a DISPATCHED undo (an ordinary step def sent to a worker and
1810
+ * awaited inline — the worker serving its name runs it with the {@link StepUndo} envelope).
1573
1811
  */
1574
1812
  async runCompensation(run, comp) {
1575
1813
  const name = `compensate:${comp.name}`;
1576
1814
  for (let attempt = 1; attempt <= this.compensationRetries; attempt += 1) {
1577
1815
  const startedAt = Date.now();
1578
1816
  try {
1579
- await comp.fn();
1817
+ if ('fn' in comp) {
1818
+ await comp.fn();
1819
+ }
1820
+ else {
1821
+ await this.dispatchCompensation(run, comp.dispatch.def, comp.dispatch.args, attempt);
1822
+ }
1580
1823
  this.emit({
1581
1824
  type: 'step.completed',
1582
1825
  runId: run.id,
@@ -1602,6 +1845,43 @@ export class WorkflowEngine {
1602
1845
  }
1603
1846
  }
1604
1847
  }
1848
+ /**
1849
+ * Dispatch a saga undo step to a worker and await its result INLINE (the saga unwind already runs
1850
+ * outside the positional replay, so this is not checkpointed — it's a one-shot dispatch + await).
1851
+ * Routes by the undo def's own name/partition token, applies its own optional input schema, and
1852
+ * honours its own liveness `timeoutMs` via the heartbeat window. Throws on a worker-reported failure
1853
+ * or timeout so `runCompensation`'s retry/skip logic applies uniformly to both compensation shapes.
1854
+ */
1855
+ async dispatchCompensation(run, def, args, attempt) {
1856
+ if (this.pool.size === 0)
1857
+ throw new Error('dispatched compensation requires a Transport');
1858
+ const token = tenantGroup(sanitizeQueueToken(def.name), def.partition);
1859
+ const validInput = def.input ? def.input.parse(args) : args;
1860
+ const id = `${run.id}:compensate:${def.name}:${attempt}`;
1861
+ const resultPromise = new Promise((resolve, reject) => {
1862
+ this.pending.set(id, { resolve, reject });
1863
+ });
1864
+ try {
1865
+ await this.pool.dispatch({
1866
+ runId: run.id,
1867
+ seq: -1,
1868
+ name: def.name,
1869
+ stepId: id,
1870
+ group: token,
1871
+ input: validInput,
1872
+ traceparent: this.traceparent?.(),
1873
+ context: this.context?.(),
1874
+ attempt: 1,
1875
+ });
1876
+ await (def.timeoutMs
1877
+ ? this.awaitWithHeartbeat(id, resultPromise, def.timeoutMs)
1878
+ : resultPromise);
1879
+ }
1880
+ catch (err) {
1881
+ this.pending.delete(id);
1882
+ throw err;
1883
+ }
1884
+ }
1605
1885
  /** The seam handed to {@link createWorkflowCtx}: the authoring API reaches durability + lifecycle
1606
1886
  * (checkpointing, dispatch, child start) through this, so the ctx primitives live in their own
1607
1887
  * module and the engine stays the orchestrator. */
@@ -1617,7 +1897,7 @@ export class WorkflowEngine {
1617
1897
  callRemote: (runId, seq, step, input, queue, transport, admission) => this.callRemote(runId, seq, step, input, queue, transport, replay, admission),
1618
1898
  // Defer so a fast child can't reentrantly resume a still-running parent.
1619
1899
  startChild: (workflow, input, id, priority) => {
1620
- queueMicrotask(() => void this.start(workflow, input, id, { priority }).catch(() => undefined));
1900
+ this.startChildDeferred(workflow, input, id, { priority });
1621
1901
  },
1622
1902
  // Shallow-merge into the run's searchAttributes (the ctx primitive makes this exactly-once).
1623
1903
  upsertSearchAttributes: async (runId, attrs) => {
@@ -1760,7 +2040,13 @@ export class WorkflowEngine {
1760
2040
  this.queueWaiters.get(queue)?.delete(runId);
1761
2041
  this.stepQueue.set(id, queue);
1762
2042
  }
1763
- const validInput = step.input.parse(input);
2043
+ // Optional runtime schema: validate only when the def carries one (a bare `@Step()` / string-name
2044
+ // call carries none — the engine passes the input through untouched then).
2045
+ const validInput = step.input ? step.input.parse(input) : input;
2046
+ // Routing token: BY NAME (sanitized for brokers), optionally partition-suffixed. Computed once and
2047
+ // used at BOTH the checkpoint's workerGroup and the dispatched task's routing `group`, so the same
2048
+ // token the worker subscribes to per handler name serves this step.
2049
+ const token = tenantGroup(sanitizeQueueToken(step.name), step.partition);
1764
2050
  const enqueuedAt = new Date();
1765
2051
  // Persist the pending checkpoint BEFORE dispatching, so a fast result always finds it to complete.
1766
2052
  await this.store.saveCheckpoint({
@@ -1772,7 +2058,7 @@ export class WorkflowEngine {
1772
2058
  status: 'pending',
1773
2059
  input: validInput,
1774
2060
  attempts: attempt,
1775
- workerGroup: step.group,
2061
+ workerGroup: token,
1776
2062
  enqueuedAt,
1777
2063
  startedAt: enqueuedAt, // placeholders until the worker result lands
1778
2064
  finishedAt: enqueuedAt,
@@ -1782,7 +2068,7 @@ export class WorkflowEngine {
1782
2068
  seq,
1783
2069
  name: step.name,
1784
2070
  stepId: id,
1785
- group: step.group,
2071
+ group: token,
1786
2072
  input: validInput,
1787
2073
  traceparent: this.traceparent?.(),
1788
2074
  context: this.context?.(),
@@ -1859,7 +2145,8 @@ export class WorkflowEngine {
1859
2145
  async callRemoteInMemory(runId, seq, step, input, transport) {
1860
2146
  if (this.pool.size === 0)
1861
2147
  throw new Error('remote steps require a Transport');
1862
- const validInput = step.input.parse(input);
2148
+ const validInput = step.input ? step.input.parse(input) : input;
2149
+ const token = tenantGroup(sanitizeQueueToken(step.name), step.partition);
1863
2150
  const id = stepId(runId, seq);
1864
2151
  const enqueuedAt = new Date();
1865
2152
  this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
@@ -1878,7 +2165,7 @@ export class WorkflowEngine {
1878
2165
  seq,
1879
2166
  name: step.name,
1880
2167
  stepId: id,
1881
- group: step.group,
2168
+ group: token,
1882
2169
  input: validInput,
1883
2170
  traceparent: this.traceparent?.(),
1884
2171
  context: this.context?.(),
@@ -1888,7 +2175,7 @@ export class WorkflowEngine {
1888
2175
  const resolution = step.timeoutMs
1889
2176
  ? await this.awaitWithHeartbeat(id, resultPromise, step.timeoutMs)
1890
2177
  : await resultPromise;
1891
- const output = step.output.parse(resolution.output);
2178
+ const output = (step.output ? step.output.parse(resolution.output) : resolution.output);
1892
2179
  // The worker reports when it actually picked the task up; fall back to dispatch time if a
1893
2180
  // transport doesn't carry it (queue-wait then reads as zero rather than going negative).
1894
2181
  const startedAt = resolution.startedAt ? new Date(resolution.startedAt) : enqueuedAt;
@@ -1901,7 +2188,7 @@ export class WorkflowEngine {
1901
2188
  output,
1902
2189
  events: resolution.events,
1903
2190
  attempts: attempt,
1904
- workerGroup: step.group,
2191
+ workerGroup: token,
1905
2192
  enqueuedAt,
1906
2193
  startedAt,
1907
2194
  });