@adonis-agora/durable 0.47.0 → 0.49.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 (183) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/dist/assets/spa/assets/index-6nmSTfT7.css +1 -0
  3. package/dist/assets/spa/assets/index-DdB9zxuX.js +16 -0
  4. package/dist/assets/spa/assets/index-DdB9zxuX.js.map +1 -0
  5. package/dist/assets/spa/index.html +2 -2
  6. package/dist/configure.d.ts.map +1 -1
  7. package/dist/configure.js +1 -0
  8. package/dist/configure.js.map +1 -1
  9. package/dist/providers/dashboard_provider.d.ts +3 -1
  10. package/dist/providers/dashboard_provider.d.ts.map +1 -1
  11. package/dist/providers/dashboard_provider.js +7 -2
  12. package/dist/providers/dashboard_provider.js.map +1 -1
  13. package/dist/providers/durable_provider.d.ts +0 -25
  14. package/dist/providers/durable_provider.d.ts.map +1 -1
  15. package/dist/providers/durable_provider.js +30 -5
  16. package/dist/providers/durable_provider.js.map +1 -1
  17. package/dist/src/codec-state-store.d.ts +2 -0
  18. package/dist/src/codec-state-store.d.ts.map +1 -1
  19. package/dist/src/codec-state-store.js +6 -0
  20. package/dist/src/codec-state-store.js.map +1 -1
  21. package/dist/src/completion.d.ts +11 -0
  22. package/dist/src/completion.d.ts.map +1 -1
  23. package/dist/src/completion.js +18 -1
  24. package/dist/src/completion.js.map +1 -1
  25. package/dist/src/dashboard/gateway-adapter.d.ts.map +1 -1
  26. package/dist/src/dashboard/gateway-adapter.js +10 -0
  27. package/dist/src/dashboard/gateway-adapter.js.map +1 -1
  28. package/dist/src/dashboard/handlers.d.ts +22 -1
  29. package/dist/src/dashboard/handlers.d.ts.map +1 -1
  30. package/dist/src/dashboard/handlers.js +63 -10
  31. package/dist/src/dashboard/handlers.js.map +1 -1
  32. package/dist/src/dashboard/index.d.ts +1 -1
  33. package/dist/src/dashboard/index.js +1 -1
  34. package/dist/src/dashboard/openapi.d.ts.map +1 -1
  35. package/dist/src/dashboard/openapi.js +26 -2
  36. package/dist/src/dashboard/openapi.js.map +1 -1
  37. package/dist/src/define_config.d.ts +6 -0
  38. package/dist/src/define_config.d.ts.map +1 -1
  39. package/dist/src/define_config.js.map +1 -1
  40. package/dist/src/engine.d.ts +89 -3
  41. package/dist/src/engine.d.ts.map +1 -1
  42. package/dist/src/engine.js +448 -39
  43. package/dist/src/engine.js.map +1 -1
  44. package/dist/src/errors.d.ts +30 -12
  45. package/dist/src/errors.d.ts.map +1 -1
  46. package/dist/src/errors.js +17 -8
  47. package/dist/src/errors.js.map +1 -1
  48. package/dist/src/fan-join.d.ts +36 -6
  49. package/dist/src/fan-join.d.ts.map +1 -1
  50. package/dist/src/fan-join.js +63 -8
  51. package/dist/src/fan-join.js.map +1 -1
  52. package/dist/src/handshake/announced.d.ts +94 -0
  53. package/dist/src/handshake/announced.d.ts.map +1 -0
  54. package/dist/src/handshake/announced.js +113 -0
  55. package/dist/src/handshake/announced.js.map +1 -0
  56. package/dist/src/handshake/descriptor.d.ts +59 -0
  57. package/dist/src/handshake/descriptor.d.ts.map +1 -1
  58. package/dist/src/handshake/descriptor.js +38 -0
  59. package/dist/src/handshake/descriptor.js.map +1 -1
  60. package/dist/src/index.d.ts +6 -3
  61. package/dist/src/index.d.ts.map +1 -1
  62. package/dist/src/index.js +5 -2
  63. package/dist/src/index.js.map +1 -1
  64. package/dist/src/interfaces.d.ts +85 -4
  65. package/dist/src/interfaces.d.ts.map +1 -1
  66. package/dist/src/interfaces.js.map +1 -1
  67. package/dist/src/metrics.d.ts +5 -0
  68. package/dist/src/metrics.d.ts.map +1 -1
  69. package/dist/src/metrics.js +14 -1
  70. package/dist/src/metrics.js.map +1 -1
  71. package/dist/src/on-error.d.ts +59 -0
  72. package/dist/src/on-error.d.ts.map +1 -0
  73. package/dist/src/on-error.js +71 -0
  74. package/dist/src/on-error.js.map +1 -0
  75. package/dist/src/otel/durable-metrics.d.ts +6 -0
  76. package/dist/src/otel/durable-metrics.d.ts.map +1 -1
  77. package/dist/src/otel/durable-metrics.js +22 -2
  78. package/dist/src/otel/durable-metrics.js.map +1 -1
  79. package/dist/src/otel/durable-otel.d.ts.map +1 -1
  80. package/dist/src/otel/durable-otel.js +15 -0
  81. package/dist/src/otel/durable-otel.js.map +1 -1
  82. package/dist/src/protocol.d.ts.map +1 -1
  83. package/dist/src/protocol.js +1 -0
  84. package/dist/src/protocol.js.map +1 -1
  85. package/dist/src/run-detail-summary.d.ts +98 -0
  86. package/dist/src/run-detail-summary.d.ts.map +1 -0
  87. package/dist/src/run-detail-summary.js +150 -0
  88. package/dist/src/run-detail-summary.js.map +1 -0
  89. package/dist/src/run-gateway/interface.d.ts +11 -0
  90. package/dist/src/run-gateway/interface.d.ts.map +1 -1
  91. package/dist/src/run-gateway/proxy-run-gateway.d.ts +3 -0
  92. package/dist/src/run-gateway/proxy-run-gateway.d.ts.map +1 -1
  93. package/dist/src/run-gateway/proxy-run-gateway.js +8 -0
  94. package/dist/src/run-gateway/proxy-run-gateway.js.map +1 -1
  95. package/dist/src/run-gateway/run-request-responder.d.ts +4 -3
  96. package/dist/src/run-gateway/run-request-responder.d.ts.map +1 -1
  97. package/dist/src/run-gateway/run-request-responder.js +23 -3
  98. package/dist/src/run-gateway/run-request-responder.js.map +1 -1
  99. package/dist/src/run-gateway/store-run-gateway.d.ts +3 -0
  100. package/dist/src/run-gateway/store-run-gateway.d.ts.map +1 -1
  101. package/dist/src/run-gateway/store-run-gateway.js +7 -0
  102. package/dist/src/run-gateway/store-run-gateway.js.map +1 -1
  103. package/dist/src/stores/lucid.d.ts +1 -0
  104. package/dist/src/stores/lucid.d.ts.map +1 -1
  105. package/dist/src/stores/lucid.js +17 -4
  106. package/dist/src/stores/lucid.js.map +1 -1
  107. package/dist/src/testing/in-memory-state-store.d.ts +1 -0
  108. package/dist/src/testing/in-memory-state-store.d.ts.map +1 -1
  109. package/dist/src/testing/in-memory-state-store.js +14 -6
  110. package/dist/src/testing/in-memory-state-store.js.map +1 -1
  111. package/dist/src/testing-kit/state-store-conformance.d.ts.map +1 -1
  112. package/dist/src/testing-kit/state-store-conformance.js +59 -0
  113. package/dist/src/testing-kit/state-store-conformance.js.map +1 -1
  114. package/dist/src/transports/bullmq/adaptive-concurrency.d.ts +144 -0
  115. package/dist/src/transports/bullmq/adaptive-concurrency.d.ts.map +1 -0
  116. package/dist/src/transports/bullmq/adaptive-concurrency.js +312 -0
  117. package/dist/src/transports/bullmq/adaptive-concurrency.js.map +1 -0
  118. package/dist/src/transports/bullmq/bullmq-transport.d.ts +18 -1
  119. package/dist/src/transports/bullmq/bullmq-transport.d.ts.map +1 -1
  120. package/dist/src/transports/bullmq/bullmq-transport.js +50 -49
  121. package/dist/src/transports/bullmq/bullmq-transport.js.map +1 -1
  122. package/dist/src/transports/bullmq/deps.d.ts +11 -1
  123. package/dist/src/transports/bullmq/deps.d.ts.map +1 -1
  124. package/dist/src/transports/bullmq/deps.js +12 -1
  125. package/dist/src/transports/bullmq/deps.js.map +1 -1
  126. package/dist/src/transports/bullmq/index.d.ts +1 -0
  127. package/dist/src/transports/bullmq/index.d.ts.map +1 -1
  128. package/dist/src/transports/bullmq/index.js +1 -0
  129. package/dist/src/transports/bullmq/index.js.map +1 -1
  130. package/dist/src/transports/db-schema.d.ts +12 -0
  131. package/dist/src/transports/db-schema.d.ts.map +1 -1
  132. package/dist/src/transports/db-schema.js +24 -0
  133. package/dist/src/transports/db-schema.js.map +1 -1
  134. package/dist/src/transports/db.d.ts +14 -0
  135. package/dist/src/transports/db.d.ts.map +1 -1
  136. package/dist/src/transports/db.js +34 -0
  137. package/dist/src/transports/db.js.map +1 -1
  138. package/dist/src/transports/event-emitter.d.ts +8 -0
  139. package/dist/src/transports/event-emitter.d.ts.map +1 -1
  140. package/dist/src/transports/event-emitter.js +27 -0
  141. package/dist/src/transports/event-emitter.js.map +1 -1
  142. package/dist/src/transports/factory.d.ts +33 -2
  143. package/dist/src/transports/factory.d.ts.map +1 -1
  144. package/dist/src/transports/factory.js +29 -1
  145. package/dist/src/transports/factory.js.map +1 -1
  146. package/dist/src/transports/queue.d.ts +19 -0
  147. package/dist/src/transports/queue.d.ts.map +1 -1
  148. package/dist/src/transports/queue.js +17 -0
  149. package/dist/src/transports/queue.js.map +1 -1
  150. package/dist/src/transports/worker-keys-db.d.ts +17 -0
  151. package/dist/src/transports/worker-keys-db.d.ts.map +1 -0
  152. package/dist/src/transports/worker-keys-db.js +43 -0
  153. package/dist/src/transports/worker-keys-db.js.map +1 -0
  154. package/dist/src/transports/worker-keys.d.ts +56 -0
  155. package/dist/src/transports/worker-keys.d.ts.map +1 -0
  156. package/dist/src/transports/worker-keys.js +92 -0
  157. package/dist/src/transports/worker-keys.js.map +1 -0
  158. package/dist/src/worker-runtime/index.d.ts +1 -1
  159. package/dist/src/worker-runtime/index.d.ts.map +1 -1
  160. package/dist/src/worker-runtime/index.js.map +1 -1
  161. package/dist/src/worker-runtime/worker-runtime.d.ts +7 -2
  162. package/dist/src/worker-runtime/worker-runtime.d.ts.map +1 -1
  163. package/dist/src/worker-runtime/worker-runtime.js +22 -20
  164. package/dist/src/worker-runtime/worker-runtime.js.map +1 -1
  165. package/dist/src/workflow-ctx.d.ts +13 -1
  166. package/dist/src/workflow-ctx.d.ts.map +1 -1
  167. package/dist/src/workflow-ctx.js +44 -6
  168. package/dist/src/workflow-ctx.js.map +1 -1
  169. package/dist/src/workflow-discovery.d.ts.map +1 -1
  170. package/dist/src/workflow-discovery.js +2 -1
  171. package/dist/src/workflow-discovery.js.map +1 -1
  172. package/dist/src/workflow-ref.d.ts +31 -0
  173. package/dist/src/workflow-ref.d.ts.map +1 -1
  174. package/dist/src/workflow-ref.js +22 -0
  175. package/dist/src/workflow-ref.js.map +1 -1
  176. package/dist/stubs/database/migrations/create_durable_transport_worker_keys.stub +24 -0
  177. package/package.json +5 -5
  178. package/skills/durable-cluster/SKILL.md +10 -0
  179. package/skills/durable-transports-stores/SKILL.md +37 -0
  180. package/skills/durable-workflows/SKILL.md +22 -1
  181. package/dist/assets/spa/assets/index-CL6q2kMB.js +0 -16
  182. package/dist/assets/spa/assets/index-CL6q2kMB.js.map +0 -1
  183. package/dist/assets/spa/assets/index-DU6cGcJz.css +0 -1
@@ -1,17 +1,19 @@
1
1
  import { InMemoryAdmissionBackend } from './admission.js';
2
2
  import { backoffDelay, MAX_BACKOFF_MS } from './backoff.js';
3
3
  import { instantCheckpoint, lostStepEvent, stepCheckpoint } from './checkpoints.js';
4
- import { isFailedCompletion } from './completion.js';
4
+ import { completionError, failedCompletion, isFailedCompletion, } from './completion.js';
5
5
  import { assertConcurrency, concurrencyTag, countRuns, resolveConcurrency, } from './concurrency.js';
6
6
  import { controlPlaneDescriptor, planDispatch, WorkflowBlocked, } from './dispatch-routing.js';
7
7
  import { parseDuration } from './duration.js';
8
8
  import { Entities } from './entities.js';
9
- import { ContinueAsNew, NonDeterminismError, RemoteStepTimeout, WorkflowSuspended, } from './errors.js';
9
+ import { ContinueAsNew, GatherError, NonDeterminismError, RemoteStepTimeout, WorkflowSuspended, } from './errors.js';
10
10
  import { EventAccumulators } from './event-accumulators.js';
11
11
  import { eventMatches, eventMatchOf, eventPrefix } from './events.js';
12
- import { FOLDED_PREFIX, fanOfWaiter, foldedChildIds } from './fan-join.js';
12
+ import { childIdsOf, FOLDED_PREFIX, fanOfWaiter, parseStepFanGroup, stepFanGroup, } from './fan-join.js';
13
+ import { aggregateAnnouncements } from './handshake/announced.js';
13
14
  import { isNonEmptyRunScope, TERMINAL_RUN_STATUSES } from './interfaces.js';
14
15
  import { asHeartbeat } from './observability-scope.js';
16
+ import { assertRoutes, codeAndDetails, HANDOFF_SEQ_BASE, handoffHandlerId, handoffName, liveHandoff, onErrorRunId, retiredHandoffName, routeFor, } from './on-error.js';
15
17
  import { breakpointToken, stepId } from './protocol.js';
16
18
  import { RemoteWorkflowExecutor } from './remote-workflow-executor.js';
17
19
  import { scanRunValueFacets } from './run-value-facets.js';
@@ -58,6 +60,8 @@ const SETTLE_ELIGIBLE_STATUSES = [
58
60
  'blocked',
59
61
  'failed',
60
62
  ];
63
+ /** How long a read of the onError routes a fleet announces is reused. */
64
+ const ANNOUNCED_ROUTES_TTL_MS = 5_000;
61
65
  const versionKey = (name, version) => `${name}@${version}`;
62
66
  /**
63
67
  * First checkpoint seq reserved for saga compensations: compensation `idx` (registration order)
@@ -66,6 +70,18 @@ const versionKey = (name, version) => `${name}@${version}`;
66
70
  * un-checkpointed `-1` undo dispatches of older engine versions.
67
71
  */
68
72
  const COMPENSATION_SEQ_BASE = -2;
73
+ /** How many checkpoint writes / dispatches a step fan sends at once. */
74
+ const STEP_FAN_CHUNK = 200;
75
+ /** The default {@link WorkflowEngineDeps.timerBatchSize}. */
76
+ const DEFAULT_TIMER_BATCH = 500;
77
+ /** Fisher–Yates, in place. */
78
+ function shuffle(items) {
79
+ for (let i = items.length - 1; i > 0; i -= 1) {
80
+ const j = Math.floor(Math.random() * (i + 1));
81
+ [items[i], items[j]] = [items[j], items[i]];
82
+ }
83
+ return items;
84
+ }
69
85
  /**
70
86
  * The routing token a dispatched step lands on: the step's own (broker-safe) name, suffixed by the
71
87
  * tenant the RUN belongs to. `runNamespace` — not the executing engine's namespace — is the tenant
@@ -129,6 +145,7 @@ export class WorkflowEngine {
129
145
  leaseMs;
130
146
  maxRecoveryAttempts;
131
147
  reconcileMs;
148
+ timerBatchSize;
132
149
  remoteRedispatchMs;
133
150
  remoteRedispatchMax;
134
151
  webhookUrl;
@@ -198,6 +215,8 @@ export class WorkflowEngine {
198
215
  updateValidators = new Map();
199
216
  /** Runs being cancelled WITH saga compensation — see `cancel({ compensate: true })`. */
200
217
  cancelRequested = new Set();
218
+ /** {@link announcedRoutes} reads, by routing token + workflow + version. */
219
+ announcedRoutesCache = new Map();
201
220
  /** The merged schedule set the worker loop ticks with (see {@link adoptSchedules}). */
202
221
  #allSchedules = [];
203
222
  /** Runtime pause overrides by schedule key (console-issued; win over the config's `paused`). */
@@ -278,6 +297,12 @@ export class WorkflowEngine {
278
297
  this.maxRecoveryAttempts = deps.maxRecoveryAttempts;
279
298
  // Default 5min; an explicit 0 disables the fallback (opt back into wake-forever-on-lost-event).
280
299
  this.reconcileMs = deps.reconcileMs === undefined ? 300_000 : deps.reconcileMs || undefined;
300
+ // A non-finite or sub-1 value falls back to the default: a NaN limit would stall timer recovery.
301
+ const batch = deps.timerBatchSize;
302
+ this.timerBatchSize =
303
+ batch !== undefined && Number.isFinite(batch) && batch >= 1
304
+ ? Math.floor(batch)
305
+ : DEFAULT_TIMER_BATCH;
281
306
  // Opt-in: unset (or 0) leaves the by-design "re-suspend a lost dispatch, never re-dispatch" behavior.
282
307
  this.remoteRedispatchMs = deps.remoteRedispatchMs || undefined;
283
308
  this.remoteRedispatchMax = Math.max(1, deps.remoteRedispatchMax ?? 10);
@@ -545,6 +570,7 @@ export class WorkflowEngine {
545
570
  name,
546
571
  version,
547
572
  fn,
573
+ onError: this.checkedRoutes(name, opts?.onError),
548
574
  tags: opts?.tags,
549
575
  origin: opts?.origin,
550
576
  singleton: opts?.singleton,
@@ -575,6 +601,7 @@ export class WorkflowEngine {
575
601
  const registered = {
576
602
  name,
577
603
  version,
604
+ onError: this.checkedRoutes(name, opts.onError),
578
605
  // A remote workflow has no in-process body; execute() branches on `remote` before this is read.
579
606
  fn: () => {
580
607
  throw new Error(`workflow ${name} is remote — it has no in-process body`);
@@ -613,6 +640,9 @@ export class WorkflowEngine {
613
640
  group: name,
614
641
  executor: new RemoteWorkflowExecutor(this.primaryTransport, name),
615
642
  });
643
+ const registered = this.workflows.get(versionKey(name, '1'));
644
+ if (registered)
645
+ registered.convention = true;
616
646
  return true;
617
647
  }
618
648
  /**
@@ -1350,6 +1380,31 @@ export class WorkflowEngine {
1350
1380
  }
1351
1381
  })());
1352
1382
  }
1383
+ /**
1384
+ * {@link recheckWaitSeqs} for the step fans a suspended turn waits on: re-drive the run once a fan
1385
+ * has no step left `pending`. The last result of a fan may have opened the join barrier while this
1386
+ * turn held the lease, and its resume no-oped against it. A fan with a step still `pending` is left
1387
+ * to that step's result.
1388
+ */
1389
+ recheckStepFans(runId, fans) {
1390
+ this.trackEffect((async () => {
1391
+ await new Promise((r) => setTimeout(r, 0));
1392
+ for (const fan of fans) {
1393
+ const pending = await this.countPendingSteps(runId, fan.from, fan.to).catch(() => 1);
1394
+ if (pending === 0) {
1395
+ await this.resumeImplicit(runId).catch(() => undefined);
1396
+ return;
1397
+ }
1398
+ }
1399
+ })());
1400
+ }
1401
+ /** {@link StateStore.countCheckpoints} of `pending` steps, or a JS count on a store without it. */
1402
+ async countPendingSteps(runId, from, to) {
1403
+ if (this.store.countCheckpoints) {
1404
+ return this.store.countCheckpoints(runId, from, to, ['pending']);
1405
+ }
1406
+ return (await this.store.listCheckpoints(runId)).filter((c) => c.seq >= from && c.seq <= to && c.status === 'pending').length;
1407
+ }
1353
1408
  /** Track an in-flight execution so {@link drain} can wait for it. */
1354
1409
  track(runId, p) {
1355
1410
  this.inflight.set(p, runId);
@@ -1625,8 +1680,10 @@ export class WorkflowEngine {
1625
1680
  * not due re-suspends cheaply without running new work.
1626
1681
  */
1627
1682
  async resumeDueTimers(nowMs = this.clock()) {
1628
- // Capped per tick (a backlog drains over several polls) — a store may ignore the cap.
1629
- const due = await asHeartbeat(() => this.store.listDueTimers(nowMs, this.namespace, 200));
1683
+ // Capped per tick (a backlog drains over several polls) — a store may ignore the cap. Shuffled:
1684
+ // every instance loads the same most-overdue batch, and walking it in the same order would have
1685
+ // them all contend for the same leases one row at a time.
1686
+ const due = shuffle(await asHeartbeat(() => this.store.listDueTimers(nowMs, this.namespace, this.timerBatchSize)));
1630
1687
  const blocked = await this.dueBlockedRuns(nowMs);
1631
1688
  return this.resumeLeased([...due, ...blocked], nowMs);
1632
1689
  }
@@ -1689,6 +1746,24 @@ export class WorkflowEngine {
1689
1746
  if (run.status === 'failed' || run.status === 'dead') {
1690
1747
  const isFailureCompletion = (v) => typeof v === 'object' && v !== null && v.ok === false;
1691
1748
  for (const cp of await this.store.listCheckpoints(runId)) {
1749
+ // A retry runs the body again, so a failure it handed to an onError handler is retired: kept in
1750
+ // the timeline under another name, never followed again, and the handler is not retried. Its
1751
+ // `onError:<code>` tag goes too: the tag says how the run ended, and that is being redone.
1752
+ const handlerId = handoffHandlerId(runId, cp);
1753
+ if (handlerId !== undefined) {
1754
+ await this.store.takeSignalWaiter(`child:${handlerId}`);
1755
+ await this.store.saveCheckpoint({
1756
+ ...cp,
1757
+ name: retiredHandoffName(handlerId),
1758
+ status: 'completed',
1759
+ });
1760
+ const tags = run.tags?.filter((t) => !t.startsWith('onError:'));
1761
+ if (tags && tags.length !== run.tags?.length) {
1762
+ await this.store.updateRun(runId, { tags, updatedAt: new Date() });
1763
+ run.tags = tags;
1764
+ }
1765
+ continue;
1766
+ }
1692
1767
  if (cp.status === 'failed') {
1693
1768
  await this.store.saveCheckpoint({
1694
1769
  ...cp,
@@ -2208,7 +2283,7 @@ export class WorkflowEngine {
2208
2283
  ? buffered.payload
2209
2284
  : settled.status === 'completed'
2210
2285
  ? { ok: true, value: settled.output }
2211
- : { ok: false, error: settled.error?.message ?? settled.status };
2286
+ : failedCompletion(settled.error ?? { message: settled.status });
2212
2287
  await this.signal(token, completion);
2213
2288
  }
2214
2289
  /**
@@ -2597,6 +2672,169 @@ export class WorkflowEngine {
2597
2672
  * {@link runExecution}'s settle/suspend; the lease is held by {@link execute}. The result that lands
2598
2673
  * us back here (a remote step finished, a timer fired) goes through `resume` like any TS workflow.
2599
2674
  */
2675
+ /**
2676
+ * The registered onError routes whose handler workflow nothing can run: not registered on this
2677
+ * engine, and no live worker group serves it. Meant for a boot-time warning: a typo in a handler's
2678
+ * name would otherwise surface only when the first run fails.
2679
+ */
2680
+ async unservedOnErrorRoutes() {
2681
+ const unserved = [];
2682
+ let groups;
2683
+ for (const registered of this.workflows.values()) {
2684
+ for (const route of registered.onError ?? []) {
2685
+ if (this.latest.has(route.workflow))
2686
+ continue;
2687
+ groups ??= await this.pool.listWorkerGroups().catch(() => []);
2688
+ if (groups.some((g) => g === route.workflow || g.startsWith(`${route.workflow}@`)))
2689
+ continue;
2690
+ unserved.push({ workflow: registered.name, route });
2691
+ }
2692
+ }
2693
+ return unserved;
2694
+ }
2695
+ /**
2696
+ * The onError routes the live workers on `token` announce for a workflow the engine serves by
2697
+ * convention: the one set they agree on, or none when they state none or cannot be read. Workers
2698
+ * that announce different sets (a rollout in progress) route nothing, and the run's failure is
2699
+ * reported as a `run.unrouted` event. Announcers of another version of the workflow are ignored. The
2700
+ * read is cached for {@link ANNOUNCED_ROUTES_TTL_MS}, so a burst of failures costs one read.
2701
+ */
2702
+ async announcedRoutes(run, token, error) {
2703
+ const key = `${token}\n${run.workflow}\n${run.workflowVersion}`;
2704
+ const now = this.clock();
2705
+ let cached = this.announcedRoutesCache.get(key);
2706
+ if (!cached || now - cached.at > ANNOUNCED_ROUTES_TTL_MS) {
2707
+ const sets = new Map();
2708
+ for (const announced of aggregateAnnouncements(await this.liveDescriptorsFor(token))) {
2709
+ if (announced.name !== run.workflow)
2710
+ continue;
2711
+ if (announced.version !== undefined && announced.version !== run.workflowVersion)
2712
+ continue;
2713
+ for (const routes of announced.onError)
2714
+ sets.set(JSON.stringify(routes), routes);
2715
+ }
2716
+ cached = { at: now, sets: [...sets.values()] };
2717
+ this.announcedRoutesCache.set(key, cached);
2718
+ }
2719
+ if (cached.sets.length > 1) {
2720
+ this.emit({
2721
+ type: 'run.unrouted',
2722
+ runId: run.id,
2723
+ workflow: run.workflow,
2724
+ namespace: run.namespace,
2725
+ error,
2726
+ reason: `live workers announce ${cached.sets.length} different onError route sets`,
2727
+ });
2728
+ return undefined;
2729
+ }
2730
+ return cached.sets[0];
2731
+ }
2732
+ /** Validate a workflow's {@link OnErrorRoute}s at registration. */
2733
+ checkedRoutes(name, routes) {
2734
+ if (!routes?.length)
2735
+ return undefined;
2736
+ assertRoutes(name, routes);
2737
+ return routes;
2738
+ }
2739
+ /**
2740
+ * Route a failed run to its {@link OnErrorRoute} handler. Starts the handler run and waits for it the
2741
+ * way a parent waits for a `ctx.child`: a `child:<handlerId>` waiter plus a `running` placeholder
2742
+ * checkpoint carrying the handler's name and input, so the handler's terminal state wakes the run
2743
+ * (buffered and reconciled like any child). From then on every execution of the run only follows the
2744
+ * handler ({@link followHandoff}); the body never runs again. Undefined when no route matches: the
2745
+ * run fails as usual.
2746
+ *
2747
+ * The placeholder sits past every seq the body used and is left out of {@link remoteHistory}.
2748
+ */
2749
+ async routeFailure(run, error) {
2750
+ const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion));
2751
+ const routes = registered?.convention
2752
+ ? await this.announcedRoutes(run, registered.remote?.group ?? run.workflow, error)
2753
+ : registered?.onError;
2754
+ const route = routeFor(routes, error);
2755
+ if (!route)
2756
+ return undefined;
2757
+ const checkpoints = await this.handoffCheckpoints(run.id);
2758
+ const live = liveHandoff(run.id, checkpoints);
2759
+ if (live)
2760
+ return this.followHandoff(run, live);
2761
+ // One handoff per attempt: a retry retires the previous one, so the next sits past it.
2762
+ const attempt = checkpoints.length;
2763
+ const seq = HANDOFF_SEQ_BASE + attempt;
2764
+ const handlerId = onErrorRunId(run.id, attempt);
2765
+ const handler = {
2766
+ workflow: route.workflow,
2767
+ input: { runId: run.id, workflow: run.workflow, input: run.input, error },
2768
+ };
2769
+ await this.store.putSignalWaiter({ token: `child:${handlerId}`, runId: run.id, seq });
2770
+ await this.store.saveCheckpoint({
2771
+ ...instantCheckpoint({
2772
+ runId: run.id,
2773
+ seq,
2774
+ name: handoffName(handlerId),
2775
+ kind: 'signal',
2776
+ status: 'running',
2777
+ }),
2778
+ input: handler,
2779
+ });
2780
+ // Tagged, so the runs whose failures were handed off are a filter away in the console.
2781
+ const tag = `onError:${route.code ?? '*'}`;
2782
+ if (!run.tags?.includes(tag)) {
2783
+ const tags = [...(run.tags ?? []), tag];
2784
+ await this.store.updateRun(run.id, { tags, updatedAt: new Date() });
2785
+ run.tags = tags;
2786
+ }
2787
+ this.emit({
2788
+ type: 'run.routed',
2789
+ runId: run.id,
2790
+ workflow: run.workflow,
2791
+ namespace: run.namespace,
2792
+ name: route.workflow,
2793
+ error,
2794
+ });
2795
+ this.startChildDeferred(handler.workflow, handler.input, handlerId, {
2796
+ namespace: run.namespace,
2797
+ });
2798
+ return { kind: 'suspended' };
2799
+ }
2800
+ /** A run's handoff checkpoints, live and retired: the only ones at {@link HANDOFF_SEQ_BASE} and up. */
2801
+ async handoffCheckpoints(runId) {
2802
+ const handoffs = await this.store.listCheckpointsByNamePrefix?.(runId, [
2803
+ `signal:child:${runId}.onError.`,
2804
+ `onError:retried:${runId}.onError.`,
2805
+ ]);
2806
+ return (handoffs ?? (await this.store.listCheckpoints(runId))).filter((cp) => cp.seq >= HANDOFF_SEQ_BASE);
2807
+ }
2808
+ /**
2809
+ * Where a run that handed its failure off stands: the handler's outcome once it settled (from the
2810
+ * wake's checkpoint, or off the handler run when the wake never arrived), else still waiting. Starts
2811
+ * the handler if the turn that handed off crashed before its deferred start ran.
2812
+ */
2813
+ async followHandoff(run, handoff) {
2814
+ if (handoff.status === 'completed') {
2815
+ const outcome = handoff.output;
2816
+ return outcome.ok
2817
+ ? { kind: 'completed', output: outcome.value }
2818
+ : { kind: 'failed', error: completionError(outcome) };
2819
+ }
2820
+ const handlerId = handoffHandlerId(run.id, handoff);
2821
+ const handlerRun = await this.store.getRun(handlerId);
2822
+ if (handlerRun?.status === 'completed')
2823
+ return { kind: 'completed', output: handlerRun.output };
2824
+ if (handlerRun && TERMINAL_RUN_STATUSES.includes(handlerRun.status)) {
2825
+ return {
2826
+ kind: 'failed',
2827
+ error: handlerRun.error ?? { message: `onError handler ${handlerId} ${handlerRun.status}` },
2828
+ };
2829
+ }
2830
+ if (!handlerRun) {
2831
+ const handler = handoff.input;
2832
+ this.startChildDeferred(handler.workflow, handler.input, handlerId, {
2833
+ namespace: run.namespace,
2834
+ });
2835
+ }
2836
+ return { kind: 'suspended' };
2837
+ }
2600
2838
  /**
2601
2839
  * Apply a terminal/suspended transition once: persist the new run status, emit the matching
2602
2840
  * lifecycle event, and (on completion/failure) wake a waiting parent. Both the TS and remote
@@ -2639,6 +2877,10 @@ export class WorkflowEngine {
2639
2877
  return { runId: run.id, status: 'completed', output: outcome.output };
2640
2878
  }
2641
2879
  if (outcome.kind === 'failed') {
2880
+ const handed = await this.routeFailure(run, outcome.error);
2881
+ if (handed && handed.kind !== 'failed')
2882
+ return this.settleRun(run, handed);
2883
+ const error = handed?.error ?? outcome.error;
2642
2884
  // Same race as the `completed` branch above — guard identically. ADDITIONALLY exclude
2643
2885
  // `failed`: a stray resume of an already-failed run (a redelivered signal, the settle-recheck
2644
2886
  // re-drive) replays and deterministically re-fails — re-applying the settle would re-emit
@@ -2649,7 +2891,7 @@ export class WorkflowEngine {
2649
2891
  // status), so it is unaffected.
2650
2892
  const applied = await this.store.updateRunIf(run.id, SETTLE_ELIGIBLE_STATUSES.filter((s) => s !== 'failed'), {
2651
2893
  status: 'failed',
2652
- error: outcome.error,
2894
+ error,
2653
2895
  updatedAt,
2654
2896
  });
2655
2897
  if (!applied)
@@ -2659,10 +2901,10 @@ export class WorkflowEngine {
2659
2901
  runId: run.id,
2660
2902
  workflow: run.workflow,
2661
2903
  namespace: run.namespace,
2662
- error: outcome.error,
2904
+ error,
2663
2905
  });
2664
- this.notifyParent(run.id, { ok: false, error: outcome.error.message });
2665
- return { runId: run.id, status: 'failed', error: outcome.error };
2906
+ this.notifyParent(run.id, failedCompletion(error));
2907
+ return { runId: run.id, status: 'failed', error };
2666
2908
  }
2667
2909
  // This outcome was computed by a turn that started from a possibly-stale run snapshot. If the run
2668
2910
  // was cancelled WHILE that turn was still executing (e.g. `ctx.all`'s failFast cancelling a
@@ -2746,7 +2988,13 @@ export class WorkflowEngine {
2746
2988
  const routePlan = await this.planRoute(remote.group, registered.requires);
2747
2989
  if (routePlan?.status === 'blocked')
2748
2990
  return this.parkBlocked(run, routePlan);
2749
- const history = await this.remoteHistory(run.id);
2991
+ const checkpoints = await this.store.listCheckpoints(run.id);
2992
+ const handoff = liveHandoff(run.id, checkpoints);
2993
+ if (handoff) {
2994
+ // A run that handed its failure to an onError handler only follows the handler from here on.
2995
+ return this.settleRun(run, await this.followHandoff(run, handoff));
2996
+ }
2997
+ const history = await this.remoteHistory(run.id, checkpoints);
2750
2998
  let decision;
2751
2999
  try {
2752
3000
  decision = await remote.executor.advance(run, history);
@@ -3007,8 +3255,8 @@ export class WorkflowEngine {
3007
3255
  }
3008
3256
  }
3009
3257
  /** The run's resolved durable ops as replay inputs: completed/failed steps + elapsed timers. */
3010
- async remoteHistory(runId) {
3011
- const checkpoints = await this.store.listCheckpoints(runId);
3258
+ async remoteHistory(runId, listed) {
3259
+ const checkpoints = listed ?? (await this.store.listCheckpoints(runId));
3012
3260
  const kindOf = {
3013
3261
  remote: 'call',
3014
3262
  local: 'step',
@@ -3021,13 +3269,16 @@ export class WorkflowEngine {
3021
3269
  // A child run resolves THROUGH the signal machinery (a `child:<id>` waiter notified on the
3022
3270
  // child's terminal state), so its checkpoint is kind `signal` with a `signal:child:` name and
3023
3271
  // a Completion payload. Surface it as a `child` event with the value/error unwrapped.
3272
+ // onError handoffs sit past every op a body can reach; no replay is told about them.
3273
+ if (cp.seq >= HANDOFF_SEQ_BASE)
3274
+ continue;
3024
3275
  if (cp.kind === 'signal' && cp.name.startsWith('signal:child:')) {
3025
3276
  const completion = cp.output;
3026
3277
  events.push({
3027
3278
  seq: cp.seq,
3028
3279
  kind: 'child',
3029
3280
  output: completion?.ok ? completion.value : undefined,
3030
- error: completion && completion.ok === false ? { message: completion.error } : undefined,
3281
+ error: completion && completion.ok === false ? completionError(completion) : undefined,
3031
3282
  });
3032
3283
  continue;
3033
3284
  }
@@ -3270,7 +3521,7 @@ export class WorkflowEngine {
3270
3521
  child.status === 'dead') {
3271
3522
  await deliverChild(child.status === 'completed'
3272
3523
  ? { ok: true, value: child.output }
3273
- : { ok: false, error: child.error?.message ?? child.status });
3524
+ : failedCompletion(child.error ?? { message: child.status }));
3274
3525
  }
3275
3526
  }
3276
3527
  }
@@ -3330,6 +3581,11 @@ export class WorkflowEngine {
3330
3581
  // counted an attempt (or dead-lettered it), flipped it to `pending` and re-enqueued a second
3331
3582
  // execution. The lease must outlive the state it protects.
3332
3583
  try {
3584
+ // A run that handed its failure to an onError handler only follows the handler from here on.
3585
+ const handoff = liveHandoff(run.id, snapshot);
3586
+ if (handoff && !this.cancelRequested.has(run.id)) {
3587
+ return await this.settleRun(run, await this.followHandoff(run, handoff));
3588
+ }
3333
3589
  // Establish the ambient ctx for the duration of this body turn so context-aware statics
3334
3590
  // (`BaseWorkflow.start`/`dispatch`) reachable from `fn` route through this run's `ctx.child`/
3335
3591
  // `ctx.startChild`. Re-set on every replay turn (each is its own async scope) — correct.
@@ -3432,6 +3688,9 @@ export class WorkflowEngine {
3432
3688
  if (settled.status === 'suspended' && err.waitSeqs?.length) {
3433
3689
  this.recheckWaitSeqs(run.id, err.waitSeqs);
3434
3690
  }
3691
+ if (settled.status === 'suspended' && err.stepFans?.length) {
3692
+ this.recheckStepFans(run.id, err.stepFans);
3693
+ }
3435
3694
  if (settled.status === 'suspended')
3436
3695
  suspendedOnFan = err.fan;
3437
3696
  return settled;
@@ -3439,6 +3698,7 @@ export class WorkflowEngine {
3439
3698
  const error = {
3440
3699
  message: err instanceof Error ? err.message : String(err),
3441
3700
  stack: err instanceof Error ? err.stack : undefined,
3701
+ ...codeAndDetails(err),
3442
3702
  };
3443
3703
  // Saga: undo completed steps in reverse, each retried up to `compensationRetries`. Outcomes
3444
3704
  // are emitted as `compensate:<step>` step events so a stranded undo is VISIBLE (not silently
@@ -3717,6 +3977,7 @@ export class WorkflowEngine {
3717
3977
  completeStep: (s) => this.completeStep(s),
3718
3978
  failStep: (s) => this.failStep(s),
3719
3979
  callRemote: (runId, seq, step, input, queue, transport, admission) => this.callRemote(runId, seq, step, input, queue, transport, replay, admission, runNamespace),
3980
+ callRemoteAll: (runId, positions, step, inputs, opts) => this.callRemoteAll(runId, positions, step, inputs, opts, runNamespace),
3720
3981
  // Defer so a fast child can't reentrantly resume a still-running parent. The child inherits the
3721
3982
  // PARENT RUN's namespace, not this engine's: an operator legitimately executes every tenant's
3722
3983
  // parents, and without this the child would fall to the operator's own pool.
@@ -3910,7 +4171,7 @@ export class WorkflowEngine {
3910
4171
  }));
3911
4172
  return blocked;
3912
4173
  }
3913
- async callRemote(runId, seq, step, input, queue, transport, replay, admission, runNamespace) {
4174
+ async callRemote(runId, seq, step, input, queue, transport, replay, admission, runNamespace, parallelGroup) {
3914
4175
  // Read the prefix from the per-execution snapshot (avoids the O(N²) replay SELECTs); a seq absent
3915
4176
  // from the snapshot — not yet dispatched, or written after the snapshot — falls back to the store.
3916
4177
  let existing = replay?.get(seq) ?? (await this.store.getCheckpoint(runId, seq));
@@ -3953,7 +4214,7 @@ export class WorkflowEngine {
3953
4214
  // The result may have settled the row since it was read: then nothing was written, and the
3954
4215
  // step is re-read from the store.
3955
4216
  if (!(await this.savePendingCheckpoint({ ...existing, wakeAt }))) {
3956
- return this.callRemote(runId, seq, step, input, queue, transport, undefined, admission, runNamespace);
4217
+ return this.callRemote(runId, seq, step, input, queue, transport, undefined, admission, runNamespace, parallelGroup);
3957
4218
  }
3958
4219
  throw new WorkflowSuspended(wakeAt, [seq]);
3959
4220
  }
@@ -3985,7 +4246,7 @@ export class WorkflowEngine {
3985
4246
  ],
3986
4247
  });
3987
4248
  if (!claimed) {
3988
- return this.callRemote(runId, seq, step, input, queue, transport, undefined, admission, runNamespace);
4249
+ return this.callRemote(runId, seq, step, input, queue, transport, undefined, admission, runNamespace, parallelGroup);
3989
4250
  }
3990
4251
  await this.dispatchRemoteTask({
3991
4252
  runId,
@@ -4081,6 +4342,7 @@ export class WorkflowEngine {
4081
4342
  // released by WHICHEVER instance receives the result — the in-memory stepQueue map only
4082
4343
  // covers the dispatching pod, and a slot released by no one starves the queue fleet-wide.
4083
4344
  queue: admittedQueue,
4345
+ ...(parallelGroup !== undefined ? { parallelGroup } : {}),
4084
4346
  enqueuedAt,
4085
4347
  startedAt: enqueuedAt, // placeholders until the worker result lands
4086
4348
  finishedAt: enqueuedAt,
@@ -4099,6 +4361,150 @@ export class WorkflowEngine {
4099
4361
  this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
4100
4362
  throw new WorkflowSuspended(undefined, [seq]);
4101
4363
  }
4364
+ /**
4365
+ * `ctx.all` over a `@Step`: one durable step per input, dispatched together, joined once. The ctx
4366
+ * reserves the whole position block up front, so replay re-derives the same seqs.
4367
+ *
4368
+ * Each turn reads the fan's checkpoints fresh, hands every step that already has one to
4369
+ * {@link callRemote} (which owns the completed / pending / failed-retry rules), and sends the steps
4370
+ * nobody has sent yet: all of them, or with `concurrency`, the next wave once the last one has
4371
+ * drained. New steps are written `pending` BEFORE any is dispatched, so a turn that runs after a
4372
+ * fast result finds them already sent instead of sending them again. The suspension carries the
4373
+ * fan's block, not its seqs, so the join barrier ({@link stepFanStillRunning}) and
4374
+ * {@link recheckStepFans} wake the parent once per wave, not once per result.
4375
+ */
4376
+ async callRemoteAll(runId, positions, step, inputs, opts, runNamespace) {
4377
+ if (step.timeoutMs) {
4378
+ throw new Error(`ctx.all: step "${step.name}" has a liveness timeoutMs; a step fan needs durable steps`);
4379
+ }
4380
+ if (opts.concurrency !== undefined &&
4381
+ (!Number.isInteger(opts.concurrency) || opts.concurrency < 1)) {
4382
+ throw new Error(`ctx.all: concurrency must be a positive integer, got ${opts.concurrency}`);
4383
+ }
4384
+ if (this.pool.size === 0)
4385
+ throw new Error('remote steps require a Transport');
4386
+ const first = positions[0];
4387
+ const last = positions[positions.length - 1];
4388
+ const group = stepFanGroup(first, positions.length, opts.mode);
4389
+ const bySeq = new Map();
4390
+ for (const cp of await this.store.listCheckpoints(runId)) {
4391
+ if (cp.seq >= first && cp.seq <= last)
4392
+ bySeq.set(cp.seq, cp);
4393
+ }
4394
+ const outputs = new Array(inputs.length);
4395
+ const failures = [];
4396
+ const unsent = [];
4397
+ let inFlight = 0;
4398
+ let wakeAt;
4399
+ for (let i = 0; i < inputs.length; i += 1) {
4400
+ const seq = positions[i];
4401
+ const cp = bySeq.get(seq);
4402
+ if (!cp) {
4403
+ unsent.push(i);
4404
+ continue;
4405
+ }
4406
+ if (cp.name !== step.name)
4407
+ throw new NonDeterminismError(runId, seq, step.name, cp.name);
4408
+ if (cp.status === 'completed') {
4409
+ outputs[i] = cp.output;
4410
+ continue;
4411
+ }
4412
+ try {
4413
+ outputs[i] = await this.callRemote(runId, seq, step, inputs[i], undefined, opts.transport, undefined, opts.admission, runNamespace, group);
4414
+ }
4415
+ catch (err) {
4416
+ if (err instanceof WorkflowSuspended) {
4417
+ inFlight += 1;
4418
+ if (err.wakeAt !== undefined)
4419
+ wakeAt = Math.min(wakeAt ?? err.wakeAt, err.wakeAt);
4420
+ }
4421
+ else if (err instanceof RemoteStepError) {
4422
+ const failure = {
4423
+ index: i,
4424
+ id: stepId(runId, seq),
4425
+ error: err.message,
4426
+ ...codeAndDetails(err),
4427
+ };
4428
+ if (opts.mode === 'failFast')
4429
+ throw new GatherError([failure], inputs.length);
4430
+ failures.push(failure);
4431
+ }
4432
+ else {
4433
+ throw err;
4434
+ }
4435
+ }
4436
+ }
4437
+ const batch = inFlight > 0
4438
+ ? []
4439
+ : opts.concurrency === undefined
4440
+ ? unsent
4441
+ : unsent.slice(0, opts.concurrency);
4442
+ if (batch.length > 0) {
4443
+ await this.sendStepFan(runId, positions, step, inputs, batch, group, opts, runNamespace);
4444
+ }
4445
+ if (inFlight + batch.length > 0) {
4446
+ throw new WorkflowSuspended(wakeAt, undefined, undefined, [{ from: first, to: last }]);
4447
+ }
4448
+ if (opts.settle) {
4449
+ const failed = new Map(failures.map((f) => [f.index, f]));
4450
+ // `outputs` has a hole at every failed index, which `map` would skip.
4451
+ return Array.from(outputs, (value, i) => {
4452
+ const f = failed.get(i);
4453
+ if (!f)
4454
+ return { status: 'fulfilled', value };
4455
+ const { index: _index, ...rejected } = f;
4456
+ return { status: 'rejected', ...rejected };
4457
+ });
4458
+ }
4459
+ if (failures.length > 0)
4460
+ throw new GatherError(failures, inputs.length);
4461
+ return outputs;
4462
+ }
4463
+ /** Write the `pending` checkpoints of a step fan's new steps, then dispatch them. */
4464
+ async sendStepFan(runId, positions, step, inputs, batch, group, opts, runNamespace) {
4465
+ const token = stepGroup(step, runNamespace);
4466
+ await this.ensureRoutable(token, step.requires);
4467
+ const input = (i) => step.input ? step.input.parse(inputs[i]) : inputs[i];
4468
+ const validInputs = new Map(batch.map((i) => [i, input(i)]));
4469
+ const enqueuedAt = new Date();
4470
+ for (let at = 0; at < batch.length; at += STEP_FAN_CHUNK) {
4471
+ await Promise.all(batch.slice(at, at + STEP_FAN_CHUNK).map((i) => {
4472
+ const seq = positions[i];
4473
+ return this.store.saveCheckpoint({
4474
+ runId,
4475
+ seq,
4476
+ name: step.name,
4477
+ kind: 'remote',
4478
+ stepId: stepId(runId, seq),
4479
+ status: 'pending',
4480
+ input: validInputs.get(i),
4481
+ attempts: 1,
4482
+ workerGroup: token,
4483
+ parallelGroup: group,
4484
+ enqueuedAt,
4485
+ startedAt: enqueuedAt,
4486
+ finishedAt: enqueuedAt,
4487
+ });
4488
+ }));
4489
+ }
4490
+ for (let at = 0; at < batch.length; at += STEP_FAN_CHUNK) {
4491
+ await Promise.all(batch.slice(at, at + STEP_FAN_CHUNK).map(async (i) => {
4492
+ const seq = positions[i];
4493
+ await this.dispatchRemoteTask({
4494
+ runId,
4495
+ seq,
4496
+ name: step.name,
4497
+ stepId: stepId(runId, seq),
4498
+ group: token,
4499
+ input: validInputs.get(i),
4500
+ priority: opts.admission?.priority,
4501
+ attempt: 1,
4502
+ transport: opts.transport,
4503
+ });
4504
+ this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
4505
+ }));
4506
+ }
4507
+ }
4102
4508
  /**
4103
4509
  * Enqueue a remote step task to its worker group via the transport pool. Shared by the initial
4104
4510
  * dispatch, the opt-in self-heal re-dispatch (see `remoteRedispatchMs`), and the explicit
@@ -4147,18 +4553,34 @@ export class WorkflowEngine {
4147
4553
  return true;
4148
4554
  }
4149
4555
  async completeRemoteResult(result) {
4150
- const runId = await this.settleRemoteCheckpoint(result);
4151
- if (runId === undefined)
4556
+ const cp = await this.settleRemoteCheckpoint(result);
4557
+ if (cp === undefined)
4152
4558
  return;
4559
+ if (await this.stepFanStillRunning(cp, result))
4560
+ return;
4561
+ const runId = result.runId;
4153
4562
  this.trackEffect(this.resumeImplicit(runId).catch((err) => {
4154
4563
  console.warn(`[adonis-durable] background resume of run ${runId} failed (the run recovers on its next reconcile wake): ${err instanceof Error ? err.message : String(err)}`);
4155
4564
  }));
4156
4565
  }
4566
+ /**
4567
+ * The join barrier of a step fan: a result for one of its steps resumes the parent only once no
4568
+ * step of the fan is still `pending` (a settled wave), or on a failure (failFast surfaces it, and a
4569
+ * retry needs the parent to schedule it). Without it every result would replay the whole parent,
4570
+ * which makes a fan of N steps cost O(N²) checkpoint reads. A redelivered result goes through it
4571
+ * too: its re-driven resume is only owed once the fan has settled.
4572
+ */
4573
+ async stepFanStillRunning(cp, result) {
4574
+ const fan = parseStepFanGroup(cp.parallelGroup);
4575
+ if (!fan || result.status === 'failed')
4576
+ return false;
4577
+ return ((await this.countPendingSteps(result.runId, fan.firstSeq, fan.firstSeq + fan.size - 1)) > 0);
4578
+ }
4157
4579
  /**
4158
4580
  * The durable half of {@link completeRemoteResult}: settle `result` onto its checkpoint (or re-drive
4159
- * the resume when a redelivered result finds it already settled). Returns the run to resume, or
4160
- * `undefined` when no resume is wanted (no checkpoint, or a late result for a terminal run —
4161
- * terminal is terminal).
4581
+ * the resume when a redelivered result finds it already settled). Returns the step's checkpoint as
4582
+ * read when its run should be resumed, or `undefined` when no resume is wanted (no checkpoint, or a
4583
+ * late result for a terminal run — terminal is terminal).
4162
4584
  */
4163
4585
  async settleRemoteCheckpoint(result) {
4164
4586
  const cp = await this.store.getCheckpoint(result.runId, result.seq);
@@ -4186,7 +4608,7 @@ export class WorkflowEngine {
4186
4608
  // explicit `requeue`/`durable:retry`, never to a stray result.
4187
4609
  if (cp.status !== 'pending') {
4188
4610
  if (!terminal)
4189
- return result.runId;
4611
+ return cp;
4190
4612
  return undefined;
4191
4613
  }
4192
4614
  // A result settling this step frees its flow-control slot (no-op if it wasn't queued). Released
@@ -4229,7 +4651,7 @@ export class WorkflowEngine {
4229
4651
  finishedAt,
4230
4652
  });
4231
4653
  if (!claimed)
4232
- return terminal ? undefined : result.runId;
4654
+ return terminal ? undefined : cp;
4233
4655
  await this.releaseQueueSlot(cp);
4234
4656
  this.emit({
4235
4657
  type: result.status === 'completed' ? 'step.completed' : 'step.failed',
@@ -4242,7 +4664,7 @@ export class WorkflowEngine {
4242
4664
  queueMs: startedAt.getTime() - cp.enqueuedAt.getTime(),
4243
4665
  durationMs: finishedAt.getTime() - startedAt.getTime(),
4244
4666
  });
4245
- return result.runId;
4667
+ return cp;
4246
4668
  }
4247
4669
  /**
4248
4670
  * A slot freed on `queue` (a fleet-wide signal): resume this instance's runs blocked on it so they
@@ -4470,17 +4892,4 @@ export class RemoteStepError extends Error {
4470
4892
  }
4471
4893
  /** Checkpoint names that record a parent→child edge: an awaited child, a folded group of them, a spawn. */
4472
4894
  const CHILD_EDGE_PREFIXES = ['signal:child:', FOLDED_PREFIX, 'spawn:'];
4473
- /** The child run ids a parent's {@link CHILD_EDGE_PREFIXES} checkpoints point at. */
4474
- function childIdsOf(checkpoints) {
4475
- const ids = [];
4476
- for (const cp of checkpoints) {
4477
- if (cp.name.startsWith('signal:child:'))
4478
- ids.push(cp.name.slice('signal:child:'.length));
4479
- if (cp.name.startsWith(FOLDED_PREFIX))
4480
- ids.push(...foldedChildIds(cp));
4481
- if (cp.name.startsWith('spawn:') && typeof cp.output === 'string')
4482
- ids.push(cp.output);
4483
- }
4484
- return ids;
4485
- }
4486
4895
  //# sourceMappingURL=engine.js.map