@adonis-agora/durable 0.46.1 → 0.48.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 (137) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/assets/spa/assets/{index-DU6cGcJz.css → index-DtGMBUDm.css} +1 -1
  3. package/dist/assets/spa/assets/{index-CL6q2kMB.js → index-_VISH9Xg.js} +4 -4
  4. package/dist/assets/spa/assets/index-_VISH9Xg.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/durable_provider.d.ts +0 -25
  10. package/dist/providers/durable_provider.d.ts.map +1 -1
  11. package/dist/providers/durable_provider.js +29 -5
  12. package/dist/providers/durable_provider.js.map +1 -1
  13. package/dist/src/codec-state-store.d.ts +3 -0
  14. package/dist/src/codec-state-store.d.ts.map +1 -1
  15. package/dist/src/codec-state-store.js +7 -0
  16. package/dist/src/codec-state-store.js.map +1 -1
  17. package/dist/src/completion.d.ts +11 -0
  18. package/dist/src/completion.d.ts.map +1 -1
  19. package/dist/src/completion.js +18 -1
  20. package/dist/src/completion.js.map +1 -1
  21. package/dist/src/dashboard/index.d.ts +1 -1
  22. package/dist/src/dashboard/index.js +1 -1
  23. package/dist/src/engine.d.ts +47 -0
  24. package/dist/src/engine.d.ts.map +1 -1
  25. package/dist/src/engine.js +251 -18
  26. package/dist/src/engine.js.map +1 -1
  27. package/dist/src/errors.d.ts +18 -11
  28. package/dist/src/errors.d.ts.map +1 -1
  29. package/dist/src/errors.js +9 -7
  30. package/dist/src/errors.js.map +1 -1
  31. package/dist/src/fan-join.d.ts +4 -3
  32. package/dist/src/fan-join.d.ts.map +1 -1
  33. package/dist/src/fan-join.js +5 -4
  34. package/dist/src/fan-join.js.map +1 -1
  35. package/dist/src/handshake/announced.d.ts +94 -0
  36. package/dist/src/handshake/announced.d.ts.map +1 -0
  37. package/dist/src/handshake/announced.js +113 -0
  38. package/dist/src/handshake/announced.js.map +1 -0
  39. package/dist/src/handshake/descriptor.d.ts +59 -0
  40. package/dist/src/handshake/descriptor.d.ts.map +1 -1
  41. package/dist/src/handshake/descriptor.js +38 -0
  42. package/dist/src/handshake/descriptor.js.map +1 -1
  43. package/dist/src/index.d.ts +3 -1
  44. package/dist/src/index.d.ts.map +1 -1
  45. package/dist/src/index.js +3 -1
  46. package/dist/src/index.js.map +1 -1
  47. package/dist/src/interfaces.d.ts +49 -4
  48. package/dist/src/interfaces.d.ts.map +1 -1
  49. package/dist/src/interfaces.js.map +1 -1
  50. package/dist/src/metrics.d.ts +5 -0
  51. package/dist/src/metrics.d.ts.map +1 -1
  52. package/dist/src/metrics.js +14 -1
  53. package/dist/src/metrics.js.map +1 -1
  54. package/dist/src/on-error.d.ts +59 -0
  55. package/dist/src/on-error.d.ts.map +1 -0
  56. package/dist/src/on-error.js +71 -0
  57. package/dist/src/on-error.js.map +1 -0
  58. package/dist/src/otel/durable-metrics.d.ts +6 -0
  59. package/dist/src/otel/durable-metrics.d.ts.map +1 -1
  60. package/dist/src/otel/durable-metrics.js +22 -2
  61. package/dist/src/otel/durable-metrics.js.map +1 -1
  62. package/dist/src/otel/durable-otel.d.ts.map +1 -1
  63. package/dist/src/otel/durable-otel.js +15 -0
  64. package/dist/src/otel/durable-otel.js.map +1 -1
  65. package/dist/src/protocol.d.ts.map +1 -1
  66. package/dist/src/protocol.js +1 -0
  67. package/dist/src/protocol.js.map +1 -1
  68. package/dist/src/stores/lucid.d.ts +1 -0
  69. package/dist/src/stores/lucid.d.ts.map +1 -1
  70. package/dist/src/stores/lucid.js +11 -1
  71. package/dist/src/stores/lucid.js.map +1 -1
  72. package/dist/src/testing/in-memory-state-store.d.ts +1 -0
  73. package/dist/src/testing/in-memory-state-store.d.ts.map +1 -1
  74. package/dist/src/testing/in-memory-state-store.js +7 -0
  75. package/dist/src/testing/in-memory-state-store.js.map +1 -1
  76. package/dist/src/testing-kit/state-store-conformance.d.ts.map +1 -1
  77. package/dist/src/testing-kit/state-store-conformance.js +19 -0
  78. package/dist/src/testing-kit/state-store-conformance.js.map +1 -1
  79. package/dist/src/transports/bullmq/bullmq-transport.d.ts.map +1 -1
  80. package/dist/src/transports/bullmq/bullmq-transport.js +13 -3
  81. package/dist/src/transports/bullmq/bullmq-transport.js.map +1 -1
  82. package/dist/src/transports/bullmq/deps.d.ts +4 -0
  83. package/dist/src/transports/bullmq/deps.d.ts.map +1 -1
  84. package/dist/src/transports/bullmq/deps.js.map +1 -1
  85. package/dist/src/transports/bullmq/serialization.d.ts +16 -0
  86. package/dist/src/transports/bullmq/serialization.d.ts.map +1 -1
  87. package/dist/src/transports/bullmq/serialization.js +8 -0
  88. package/dist/src/transports/bullmq/serialization.js.map +1 -1
  89. package/dist/src/transports/db-schema.d.ts +12 -0
  90. package/dist/src/transports/db-schema.d.ts.map +1 -1
  91. package/dist/src/transports/db-schema.js +24 -0
  92. package/dist/src/transports/db-schema.js.map +1 -1
  93. package/dist/src/transports/db.d.ts +14 -0
  94. package/dist/src/transports/db.d.ts.map +1 -1
  95. package/dist/src/transports/db.js +34 -0
  96. package/dist/src/transports/db.js.map +1 -1
  97. package/dist/src/transports/event-emitter.d.ts +8 -0
  98. package/dist/src/transports/event-emitter.d.ts.map +1 -1
  99. package/dist/src/transports/event-emitter.js +27 -0
  100. package/dist/src/transports/event-emitter.js.map +1 -1
  101. package/dist/src/transports/factory.d.ts +12 -0
  102. package/dist/src/transports/factory.d.ts.map +1 -1
  103. package/dist/src/transports/factory.js +24 -0
  104. package/dist/src/transports/factory.js.map +1 -1
  105. package/dist/src/transports/queue.d.ts +19 -0
  106. package/dist/src/transports/queue.d.ts.map +1 -1
  107. package/dist/src/transports/queue.js +17 -0
  108. package/dist/src/transports/queue.js.map +1 -1
  109. package/dist/src/transports/worker-keys-db.d.ts +17 -0
  110. package/dist/src/transports/worker-keys-db.d.ts.map +1 -0
  111. package/dist/src/transports/worker-keys-db.js +43 -0
  112. package/dist/src/transports/worker-keys-db.js.map +1 -0
  113. package/dist/src/transports/worker-keys.d.ts +56 -0
  114. package/dist/src/transports/worker-keys.d.ts.map +1 -0
  115. package/dist/src/transports/worker-keys.js +92 -0
  116. package/dist/src/transports/worker-keys.js.map +1 -0
  117. package/dist/src/worker-runtime/index.d.ts +1 -1
  118. package/dist/src/worker-runtime/index.d.ts.map +1 -1
  119. package/dist/src/worker-runtime/index.js.map +1 -1
  120. package/dist/src/worker-runtime/worker-runtime.d.ts +7 -2
  121. package/dist/src/worker-runtime/worker-runtime.d.ts.map +1 -1
  122. package/dist/src/worker-runtime/worker-runtime.js +22 -20
  123. package/dist/src/worker-runtime/worker-runtime.js.map +1 -1
  124. package/dist/src/workflow-ctx.d.ts.map +1 -1
  125. package/dist/src/workflow-ctx.js +19 -5
  126. package/dist/src/workflow-ctx.js.map +1 -1
  127. package/dist/src/workflow-discovery.d.ts.map +1 -1
  128. package/dist/src/workflow-discovery.js +2 -1
  129. package/dist/src/workflow-discovery.js.map +1 -1
  130. package/dist/src/workflow-ref.d.ts +31 -0
  131. package/dist/src/workflow-ref.d.ts.map +1 -1
  132. package/dist/src/workflow-ref.js +22 -0
  133. package/dist/src/workflow-ref.js.map +1 -1
  134. package/dist/stubs/database/migrations/create_durable_transport_worker_keys.stub +24 -0
  135. package/package.json +4 -4
  136. package/skills/durable-workflows/SKILL.md +4 -1
  137. package/dist/assets/spa/assets/index-CL6q2kMB.js.map +0 -1
@@ -1,7 +1,7 @@
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';
@@ -10,8 +10,10 @@ import { ContinueAsNew, NonDeterminismError, RemoteStepTimeout, WorkflowSuspende
10
10
  import { EventAccumulators } from './event-accumulators.js';
11
11
  import { eventMatches, eventMatchOf, eventPrefix } from './events.js';
12
12
  import { FOLDED_PREFIX, fanOfWaiter, foldedChildIds } 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)
@@ -198,6 +202,8 @@ export class WorkflowEngine {
198
202
  updateValidators = new Map();
199
203
  /** Runs being cancelled WITH saga compensation — see `cancel({ compensate: true })`. */
200
204
  cancelRequested = new Set();
205
+ /** {@link announcedRoutes} reads, by routing token + workflow + version. */
206
+ announcedRoutesCache = new Map();
201
207
  /** The merged schedule set the worker loop ticks with (see {@link adoptSchedules}). */
202
208
  #allSchedules = [];
203
209
  /** Runtime pause overrides by schedule key (console-issued; win over the config's `paused`). */
@@ -545,6 +551,7 @@ export class WorkflowEngine {
545
551
  name,
546
552
  version,
547
553
  fn,
554
+ onError: this.checkedRoutes(name, opts?.onError),
548
555
  tags: opts?.tags,
549
556
  origin: opts?.origin,
550
557
  singleton: opts?.singleton,
@@ -575,6 +582,7 @@ export class WorkflowEngine {
575
582
  const registered = {
576
583
  name,
577
584
  version,
585
+ onError: this.checkedRoutes(name, opts.onError),
578
586
  // A remote workflow has no in-process body; execute() branches on `remote` before this is read.
579
587
  fn: () => {
580
588
  throw new Error(`workflow ${name} is remote — it has no in-process body`);
@@ -613,6 +621,9 @@ export class WorkflowEngine {
613
621
  group: name,
614
622
  executor: new RemoteWorkflowExecutor(this.primaryTransport, name),
615
623
  });
624
+ const registered = this.workflows.get(versionKey(name, '1'));
625
+ if (registered)
626
+ registered.convention = true;
616
627
  return true;
617
628
  }
618
629
  /**
@@ -1689,6 +1700,24 @@ export class WorkflowEngine {
1689
1700
  if (run.status === 'failed' || run.status === 'dead') {
1690
1701
  const isFailureCompletion = (v) => typeof v === 'object' && v !== null && v.ok === false;
1691
1702
  for (const cp of await this.store.listCheckpoints(runId)) {
1703
+ // A retry runs the body again, so a failure it handed to an onError handler is retired: kept in
1704
+ // the timeline under another name, never followed again, and the handler is not retried. Its
1705
+ // `onError:<code>` tag goes too: the tag says how the run ended, and that is being redone.
1706
+ const handlerId = handoffHandlerId(runId, cp);
1707
+ if (handlerId !== undefined) {
1708
+ await this.store.takeSignalWaiter(`child:${handlerId}`);
1709
+ await this.store.saveCheckpoint({
1710
+ ...cp,
1711
+ name: retiredHandoffName(handlerId),
1712
+ status: 'completed',
1713
+ });
1714
+ const tags = run.tags?.filter((t) => !t.startsWith('onError:'));
1715
+ if (tags && tags.length !== run.tags?.length) {
1716
+ await this.store.updateRun(runId, { tags, updatedAt: new Date() });
1717
+ run.tags = tags;
1718
+ }
1719
+ continue;
1720
+ }
1692
1721
  if (cp.status === 'failed') {
1693
1722
  await this.store.saveCheckpoint({
1694
1723
  ...cp,
@@ -2208,7 +2237,7 @@ export class WorkflowEngine {
2208
2237
  ? buffered.payload
2209
2238
  : settled.status === 'completed'
2210
2239
  ? { ok: true, value: settled.output }
2211
- : { ok: false, error: settled.error?.message ?? settled.status };
2240
+ : failedCompletion(settled.error ?? { message: settled.status });
2212
2241
  await this.signal(token, completion);
2213
2242
  }
2214
2243
  /**
@@ -2597,6 +2626,169 @@ export class WorkflowEngine {
2597
2626
  * {@link runExecution}'s settle/suspend; the lease is held by {@link execute}. The result that lands
2598
2627
  * us back here (a remote step finished, a timer fired) goes through `resume` like any TS workflow.
2599
2628
  */
2629
+ /**
2630
+ * The registered onError routes whose handler workflow nothing can run: not registered on this
2631
+ * engine, and no live worker group serves it. Meant for a boot-time warning: a typo in a handler's
2632
+ * name would otherwise surface only when the first run fails.
2633
+ */
2634
+ async unservedOnErrorRoutes() {
2635
+ const unserved = [];
2636
+ let groups;
2637
+ for (const registered of this.workflows.values()) {
2638
+ for (const route of registered.onError ?? []) {
2639
+ if (this.latest.has(route.workflow))
2640
+ continue;
2641
+ groups ??= await this.pool.listWorkerGroups().catch(() => []);
2642
+ if (groups.some((g) => g === route.workflow || g.startsWith(`${route.workflow}@`)))
2643
+ continue;
2644
+ unserved.push({ workflow: registered.name, route });
2645
+ }
2646
+ }
2647
+ return unserved;
2648
+ }
2649
+ /**
2650
+ * The onError routes the live workers on `token` announce for a workflow the engine serves by
2651
+ * convention: the one set they agree on, or none when they state none or cannot be read. Workers
2652
+ * that announce different sets (a rollout in progress) route nothing, and the run's failure is
2653
+ * reported as a `run.unrouted` event. Announcers of another version of the workflow are ignored. The
2654
+ * read is cached for {@link ANNOUNCED_ROUTES_TTL_MS}, so a burst of failures costs one read.
2655
+ */
2656
+ async announcedRoutes(run, token, error) {
2657
+ const key = `${token}\n${run.workflow}\n${run.workflowVersion}`;
2658
+ const now = this.clock();
2659
+ let cached = this.announcedRoutesCache.get(key);
2660
+ if (!cached || now - cached.at > ANNOUNCED_ROUTES_TTL_MS) {
2661
+ const sets = new Map();
2662
+ for (const announced of aggregateAnnouncements(await this.liveDescriptorsFor(token))) {
2663
+ if (announced.name !== run.workflow)
2664
+ continue;
2665
+ if (announced.version !== undefined && announced.version !== run.workflowVersion)
2666
+ continue;
2667
+ for (const routes of announced.onError)
2668
+ sets.set(JSON.stringify(routes), routes);
2669
+ }
2670
+ cached = { at: now, sets: [...sets.values()] };
2671
+ this.announcedRoutesCache.set(key, cached);
2672
+ }
2673
+ if (cached.sets.length > 1) {
2674
+ this.emit({
2675
+ type: 'run.unrouted',
2676
+ runId: run.id,
2677
+ workflow: run.workflow,
2678
+ namespace: run.namespace,
2679
+ error,
2680
+ reason: `live workers announce ${cached.sets.length} different onError route sets`,
2681
+ });
2682
+ return undefined;
2683
+ }
2684
+ return cached.sets[0];
2685
+ }
2686
+ /** Validate a workflow's {@link OnErrorRoute}s at registration. */
2687
+ checkedRoutes(name, routes) {
2688
+ if (!routes?.length)
2689
+ return undefined;
2690
+ assertRoutes(name, routes);
2691
+ return routes;
2692
+ }
2693
+ /**
2694
+ * Route a failed run to its {@link OnErrorRoute} handler. Starts the handler run and waits for it the
2695
+ * way a parent waits for a `ctx.child`: a `child:<handlerId>` waiter plus a `running` placeholder
2696
+ * checkpoint carrying the handler's name and input, so the handler's terminal state wakes the run
2697
+ * (buffered and reconciled like any child). From then on every execution of the run only follows the
2698
+ * handler ({@link followHandoff}); the body never runs again. Undefined when no route matches: the
2699
+ * run fails as usual.
2700
+ *
2701
+ * The placeholder sits past every seq the body used and is left out of {@link remoteHistory}.
2702
+ */
2703
+ async routeFailure(run, error) {
2704
+ const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion));
2705
+ const routes = registered?.convention
2706
+ ? await this.announcedRoutes(run, registered.remote?.group ?? run.workflow, error)
2707
+ : registered?.onError;
2708
+ const route = routeFor(routes, error);
2709
+ if (!route)
2710
+ return undefined;
2711
+ const checkpoints = await this.handoffCheckpoints(run.id);
2712
+ const live = liveHandoff(run.id, checkpoints);
2713
+ if (live)
2714
+ return this.followHandoff(run, live);
2715
+ // One handoff per attempt: a retry retires the previous one, so the next sits past it.
2716
+ const attempt = checkpoints.length;
2717
+ const seq = HANDOFF_SEQ_BASE + attempt;
2718
+ const handlerId = onErrorRunId(run.id, attempt);
2719
+ const handler = {
2720
+ workflow: route.workflow,
2721
+ input: { runId: run.id, workflow: run.workflow, input: run.input, error },
2722
+ };
2723
+ await this.store.putSignalWaiter({ token: `child:${handlerId}`, runId: run.id, seq });
2724
+ await this.store.saveCheckpoint({
2725
+ ...instantCheckpoint({
2726
+ runId: run.id,
2727
+ seq,
2728
+ name: handoffName(handlerId),
2729
+ kind: 'signal',
2730
+ status: 'running',
2731
+ }),
2732
+ input: handler,
2733
+ });
2734
+ // Tagged, so the runs whose failures were handed off are a filter away in the console.
2735
+ const tag = `onError:${route.code ?? '*'}`;
2736
+ if (!run.tags?.includes(tag)) {
2737
+ const tags = [...(run.tags ?? []), tag];
2738
+ await this.store.updateRun(run.id, { tags, updatedAt: new Date() });
2739
+ run.tags = tags;
2740
+ }
2741
+ this.emit({
2742
+ type: 'run.routed',
2743
+ runId: run.id,
2744
+ workflow: run.workflow,
2745
+ namespace: run.namespace,
2746
+ name: route.workflow,
2747
+ error,
2748
+ });
2749
+ this.startChildDeferred(handler.workflow, handler.input, handlerId, {
2750
+ namespace: run.namespace,
2751
+ });
2752
+ return { kind: 'suspended' };
2753
+ }
2754
+ /** A run's handoff checkpoints, live and retired: the only ones at {@link HANDOFF_SEQ_BASE} and up. */
2755
+ async handoffCheckpoints(runId) {
2756
+ const handoffs = await this.store.listCheckpointsByNamePrefix?.(runId, [
2757
+ `signal:child:${runId}.onError.`,
2758
+ `onError:retried:${runId}.onError.`,
2759
+ ]);
2760
+ return (handoffs ?? (await this.store.listCheckpoints(runId))).filter((cp) => cp.seq >= HANDOFF_SEQ_BASE);
2761
+ }
2762
+ /**
2763
+ * Where a run that handed its failure off stands: the handler's outcome once it settled (from the
2764
+ * wake's checkpoint, or off the handler run when the wake never arrived), else still waiting. Starts
2765
+ * the handler if the turn that handed off crashed before its deferred start ran.
2766
+ */
2767
+ async followHandoff(run, handoff) {
2768
+ if (handoff.status === 'completed') {
2769
+ const outcome = handoff.output;
2770
+ return outcome.ok
2771
+ ? { kind: 'completed', output: outcome.value }
2772
+ : { kind: 'failed', error: completionError(outcome) };
2773
+ }
2774
+ const handlerId = handoffHandlerId(run.id, handoff);
2775
+ const handlerRun = await this.store.getRun(handlerId);
2776
+ if (handlerRun?.status === 'completed')
2777
+ return { kind: 'completed', output: handlerRun.output };
2778
+ if (handlerRun && TERMINAL_RUN_STATUSES.includes(handlerRun.status)) {
2779
+ return {
2780
+ kind: 'failed',
2781
+ error: handlerRun.error ?? { message: `onError handler ${handlerId} ${handlerRun.status}` },
2782
+ };
2783
+ }
2784
+ if (!handlerRun) {
2785
+ const handler = handoff.input;
2786
+ this.startChildDeferred(handler.workflow, handler.input, handlerId, {
2787
+ namespace: run.namespace,
2788
+ });
2789
+ }
2790
+ return { kind: 'suspended' };
2791
+ }
2600
2792
  /**
2601
2793
  * Apply a terminal/suspended transition once: persist the new run status, emit the matching
2602
2794
  * lifecycle event, and (on completion/failure) wake a waiting parent. Both the TS and remote
@@ -2639,6 +2831,10 @@ export class WorkflowEngine {
2639
2831
  return { runId: run.id, status: 'completed', output: outcome.output };
2640
2832
  }
2641
2833
  if (outcome.kind === 'failed') {
2834
+ const handed = await this.routeFailure(run, outcome.error);
2835
+ if (handed && handed.kind !== 'failed')
2836
+ return this.settleRun(run, handed);
2837
+ const error = handed?.error ?? outcome.error;
2642
2838
  // Same race as the `completed` branch above — guard identically. ADDITIONALLY exclude
2643
2839
  // `failed`: a stray resume of an already-failed run (a redelivered signal, the settle-recheck
2644
2840
  // re-drive) replays and deterministically re-fails — re-applying the settle would re-emit
@@ -2649,7 +2845,7 @@ export class WorkflowEngine {
2649
2845
  // status), so it is unaffected.
2650
2846
  const applied = await this.store.updateRunIf(run.id, SETTLE_ELIGIBLE_STATUSES.filter((s) => s !== 'failed'), {
2651
2847
  status: 'failed',
2652
- error: outcome.error,
2848
+ error,
2653
2849
  updatedAt,
2654
2850
  });
2655
2851
  if (!applied)
@@ -2659,10 +2855,10 @@ export class WorkflowEngine {
2659
2855
  runId: run.id,
2660
2856
  workflow: run.workflow,
2661
2857
  namespace: run.namespace,
2662
- error: outcome.error,
2858
+ error,
2663
2859
  });
2664
- this.notifyParent(run.id, { ok: false, error: outcome.error.message });
2665
- return { runId: run.id, status: 'failed', error: outcome.error };
2860
+ this.notifyParent(run.id, failedCompletion(error));
2861
+ return { runId: run.id, status: 'failed', error };
2666
2862
  }
2667
2863
  // This outcome was computed by a turn that started from a possibly-stale run snapshot. If the run
2668
2864
  // was cancelled WHILE that turn was still executing (e.g. `ctx.all`'s failFast cancelling a
@@ -2746,7 +2942,13 @@ export class WorkflowEngine {
2746
2942
  const routePlan = await this.planRoute(remote.group, registered.requires);
2747
2943
  if (routePlan?.status === 'blocked')
2748
2944
  return this.parkBlocked(run, routePlan);
2749
- const history = await this.remoteHistory(run.id);
2945
+ const checkpoints = await this.store.listCheckpoints(run.id);
2946
+ const handoff = liveHandoff(run.id, checkpoints);
2947
+ if (handoff) {
2948
+ // A run that handed its failure to an onError handler only follows the handler from here on.
2949
+ return this.settleRun(run, await this.followHandoff(run, handoff));
2950
+ }
2951
+ const history = await this.remoteHistory(run.id, checkpoints);
2750
2952
  let decision;
2751
2953
  try {
2752
2954
  decision = await remote.executor.advance(run, history);
@@ -3007,8 +3209,8 @@ export class WorkflowEngine {
3007
3209
  }
3008
3210
  }
3009
3211
  /** 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);
3212
+ async remoteHistory(runId, listed) {
3213
+ const checkpoints = listed ?? (await this.store.listCheckpoints(runId));
3012
3214
  const kindOf = {
3013
3215
  remote: 'call',
3014
3216
  local: 'step',
@@ -3021,13 +3223,16 @@ export class WorkflowEngine {
3021
3223
  // A child run resolves THROUGH the signal machinery (a `child:<id>` waiter notified on the
3022
3224
  // child's terminal state), so its checkpoint is kind `signal` with a `signal:child:` name and
3023
3225
  // a Completion payload. Surface it as a `child` event with the value/error unwrapped.
3226
+ // onError handoffs sit past every op a body can reach; no replay is told about them.
3227
+ if (cp.seq >= HANDOFF_SEQ_BASE)
3228
+ continue;
3024
3229
  if (cp.kind === 'signal' && cp.name.startsWith('signal:child:')) {
3025
3230
  const completion = cp.output;
3026
3231
  events.push({
3027
3232
  seq: cp.seq,
3028
3233
  kind: 'child',
3029
3234
  output: completion?.ok ? completion.value : undefined,
3030
- error: completion && completion.ok === false ? { message: completion.error } : undefined,
3235
+ error: completion && completion.ok === false ? completionError(completion) : undefined,
3031
3236
  });
3032
3237
  continue;
3033
3238
  }
@@ -3270,7 +3475,7 @@ export class WorkflowEngine {
3270
3475
  child.status === 'dead') {
3271
3476
  await deliverChild(child.status === 'completed'
3272
3477
  ? { ok: true, value: child.output }
3273
- : { ok: false, error: child.error?.message ?? child.status });
3478
+ : failedCompletion(child.error ?? { message: child.status }));
3274
3479
  }
3275
3480
  }
3276
3481
  }
@@ -3330,6 +3535,11 @@ export class WorkflowEngine {
3330
3535
  // counted an attempt (or dead-lettered it), flipped it to `pending` and re-enqueued a second
3331
3536
  // execution. The lease must outlive the state it protects.
3332
3537
  try {
3538
+ // A run that handed its failure to an onError handler only follows the handler from here on.
3539
+ const handoff = liveHandoff(run.id, snapshot);
3540
+ if (handoff && !this.cancelRequested.has(run.id)) {
3541
+ return await this.settleRun(run, await this.followHandoff(run, handoff));
3542
+ }
3333
3543
  // Establish the ambient ctx for the duration of this body turn so context-aware statics
3334
3544
  // (`BaseWorkflow.start`/`dispatch`) reachable from `fn` route through this run's `ctx.child`/
3335
3545
  // `ctx.startChild`. Re-set on every replay turn (each is its own async scope) — correct.
@@ -3439,6 +3649,7 @@ export class WorkflowEngine {
3439
3649
  const error = {
3440
3650
  message: err instanceof Error ? err.message : String(err),
3441
3651
  stack: err instanceof Error ? err.stack : undefined,
3652
+ ...codeAndDetails(err),
3442
3653
  };
3443
3654
  // Saga: undo completed steps in reverse, each retried up to `compensationRetries`. Outcomes
3444
3655
  // are emitted as `compensate:<step>` step events so a stranded undo is VISIBLE (not silently
@@ -3950,7 +4161,11 @@ export class WorkflowEngine {
3950
4161
  // becomes this deadline, so a reconcile re-drive lands exactly when it's due to re-dispatch.
3951
4162
  if (existing.wakeAt == null) {
3952
4163
  const wakeAt = this.clock() + this.remoteRedispatchMs;
3953
- await this.store.saveCheckpoint({ ...existing, wakeAt });
4164
+ // The result may have settled the row since it was read: then nothing was written, and the
4165
+ // step is re-read from the store.
4166
+ if (!(await this.savePendingCheckpoint({ ...existing, wakeAt }))) {
4167
+ return this.callRemote(runId, seq, step, input, queue, transport, undefined, admission, runNamespace);
4168
+ }
3954
4169
  throw new WorkflowSuspended(wakeAt, [seq]);
3955
4170
  }
3956
4171
  if (this.clock() < existing.wakeAt)
@@ -3966,7 +4181,7 @@ export class WorkflowEngine {
3966
4181
  const reAttempt = existing.attempts + 1;
3967
4182
  const nextDeadline = this.clock() + this.remoteRedispatchMs;
3968
4183
  const reEnqueuedAt = new Date();
3969
- await this.store.saveCheckpoint({
4184
+ const claimed = await this.savePendingCheckpoint({
3970
4185
  ...existing,
3971
4186
  attempts: reAttempt,
3972
4187
  wakeAt: nextDeadline,
@@ -3980,6 +4195,9 @@ export class WorkflowEngine {
3980
4195
  lostStepEvent(this.clock(), reAttempt, 'redispatched'),
3981
4196
  ],
3982
4197
  });
4198
+ if (!claimed) {
4199
+ return this.callRemote(runId, seq, step, input, queue, transport, undefined, admission, runNamespace);
4200
+ }
3983
4201
  await this.dispatchRemoteTask({
3984
4202
  runId,
3985
4203
  seq,
@@ -4132,6 +4350,13 @@ export class WorkflowEngine {
4132
4350
  * (never an unhandled rejection) — a failure warns and surfaces as a still-`suspended` run that
4133
4351
  * recovery re-drives, never as a silent strand.
4134
4352
  */
4353
+ /** {@link StateStore.saveCheckpointIfPending}, or a plain save on a store without it. */
4354
+ async savePendingCheckpoint(checkpoint) {
4355
+ if (this.store.saveCheckpointIfPending)
4356
+ return this.store.saveCheckpointIfPending(checkpoint);
4357
+ await this.store.saveCheckpoint(checkpoint);
4358
+ return true;
4359
+ }
4135
4360
  async completeRemoteResult(result) {
4136
4361
  const runId = await this.settleRemoteCheckpoint(result);
4137
4362
  if (runId === undefined)
@@ -4175,17 +4400,20 @@ export class WorkflowEngine {
4175
4400
  return result.runId;
4176
4401
  return undefined;
4177
4402
  }
4178
- // A result settling this step frees its flow-control slot (no-op if it wasn't queued). Done
4179
- // before the terminal-run early-returns below, so a cancellation can't leak the slot.
4180
- await this.releaseQueueSlot(cp);
4403
+ // A result settling this step frees its flow-control slot (no-op if it wasn't queued). Released
4404
+ // on every early return below too, so a cancellation can't leak the slot; on the settling path
4405
+ // only by the delivery that claims the row, so a duplicated result can't free it twice.
4181
4406
  // Drop a late result for a run that was cancelled/finished meanwhile — don't complete the step
4182
4407
  // or resume (the run is already terminal). This is the engine side of cooperative cancellation.
4183
- if (run && (run.status === 'cancelled' || run.status === 'completed'))
4408
+ if (run && (run.status === 'cancelled' || run.status === 'completed')) {
4409
+ await this.releaseQueueSlot(cp);
4184
4410
  return undefined;
4411
+ }
4185
4412
  // A late SUCCESS for a failed/dead run: salvage the finished work onto the checkpoint — an
4186
4413
  // explicit retry's replay then short-circuits this step instead of re-running it (the work may
4187
4414
  // be minutes of real compute) — but never resume. A late FAILURE has nothing to salvage: drop.
4188
4415
  if (run && (run.status === 'failed' || run.status === 'dead')) {
4416
+ await this.releaseQueueSlot(cp);
4189
4417
  if (result.status === 'completed') {
4190
4418
  await this.store.saveCheckpoint({
4191
4419
  ...cp,
@@ -4200,7 +4428,9 @@ export class WorkflowEngine {
4200
4428
  }
4201
4429
  const finishedAt = new Date();
4202
4430
  const startedAt = result.startedAt ? new Date(result.startedAt) : cp.startedAt;
4203
- await this.store.saveCheckpoint({
4431
+ // Claimed, not blindly written: another delivery settled it first, which is the redelivery case
4432
+ // above — re-drive the resume rather than settle twice.
4433
+ const claimed = await this.savePendingCheckpoint({
4204
4434
  ...cp,
4205
4435
  status: result.status,
4206
4436
  output: result.status === 'completed' ? result.output : cp.output,
@@ -4209,6 +4439,9 @@ export class WorkflowEngine {
4209
4439
  startedAt,
4210
4440
  finishedAt,
4211
4441
  });
4442
+ if (!claimed)
4443
+ return terminal ? undefined : result.runId;
4444
+ await this.releaseQueueSlot(cp);
4212
4445
  this.emit({
4213
4446
  type: result.status === 'completed' ? 'step.completed' : 'step.failed',
4214
4447
  runId: result.runId,