@adonis-agora/durable 0.9.1 → 0.11.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 (164) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/assets/dashboard.html +116 -3
  3. package/dist/commands/durable_worker.d.ts +25 -0
  4. package/dist/commands/durable_worker.d.ts.map +1 -0
  5. package/dist/commands/durable_worker.js +113 -0
  6. package/dist/commands/durable_worker.js.map +1 -0
  7. package/dist/commands/main.d.ts +6 -5
  8. package/dist/commands/main.d.ts.map +1 -1
  9. package/dist/commands/main.js +11 -4
  10. package/dist/commands/main.js.map +1 -1
  11. package/dist/providers/dashboard_provider.d.ts +38 -0
  12. package/dist/providers/dashboard_provider.d.ts.map +1 -1
  13. package/dist/providers/dashboard_provider.js +103 -10
  14. package/dist/providers/dashboard_provider.js.map +1 -1
  15. package/dist/providers/durable_provider.d.ts +24 -16
  16. package/dist/providers/durable_provider.d.ts.map +1 -1
  17. package/dist/providers/durable_provider.js +195 -20
  18. package/dist/providers/durable_provider.js.map +1 -1
  19. package/dist/src/config_types.d.ts +92 -0
  20. package/dist/src/config_types.d.ts.map +1 -0
  21. package/dist/src/config_types.js +2 -0
  22. package/dist/src/config_types.js.map +1 -0
  23. package/dist/src/dashboard/compat-view.d.ts +13 -0
  24. package/dist/src/dashboard/compat-view.d.ts.map +1 -0
  25. package/dist/src/dashboard/compat-view.js +32 -0
  26. package/dist/src/dashboard/compat-view.js.map +1 -0
  27. package/dist/src/dashboard/compat.d.ts +76 -0
  28. package/dist/src/dashboard/compat.d.ts.map +1 -0
  29. package/dist/src/dashboard/compat.js +130 -0
  30. package/dist/src/dashboard/compat.js.map +1 -0
  31. package/dist/src/dashboard/diagnostics-recorder.d.ts +59 -0
  32. package/dist/src/dashboard/diagnostics-recorder.d.ts.map +1 -0
  33. package/dist/src/dashboard/diagnostics-recorder.js +75 -0
  34. package/dist/src/dashboard/diagnostics-recorder.js.map +1 -0
  35. package/dist/src/dashboard/gateway-adapter.d.ts +37 -0
  36. package/dist/src/dashboard/gateway-adapter.d.ts.map +1 -0
  37. package/dist/src/dashboard/gateway-adapter.js +49 -0
  38. package/dist/src/dashboard/gateway-adapter.js.map +1 -0
  39. package/dist/src/dashboard/handlers.d.ts +38 -8
  40. package/dist/src/dashboard/handlers.d.ts.map +1 -1
  41. package/dist/src/dashboard/handlers.js +17 -1
  42. package/dist/src/dashboard/handlers.js.map +1 -1
  43. package/dist/src/dashboard/index.d.ts +10 -3
  44. package/dist/src/dashboard/index.d.ts.map +1 -1
  45. package/dist/src/dashboard/index.js +8 -2
  46. package/dist/src/dashboard/index.js.map +1 -1
  47. package/dist/src/define_config.d.ts +31 -5
  48. package/dist/src/define_config.d.ts.map +1 -1
  49. package/dist/src/define_config.js +4 -3
  50. package/dist/src/define_config.js.map +1 -1
  51. package/dist/src/dispatch-routing.d.ts +105 -0
  52. package/dist/src/dispatch-routing.d.ts.map +1 -0
  53. package/dist/src/dispatch-routing.js +114 -0
  54. package/dist/src/dispatch-routing.js.map +1 -0
  55. package/dist/src/engine.d.ts +129 -3
  56. package/dist/src/engine.d.ts.map +1 -1
  57. package/dist/src/engine.js +354 -12
  58. package/dist/src/engine.js.map +1 -1
  59. package/dist/src/handshake/descriptor.d.ts +128 -0
  60. package/dist/src/handshake/descriptor.d.ts.map +1 -0
  61. package/dist/src/handshake/descriptor.js +133 -0
  62. package/dist/src/handshake/descriptor.js.map +1 -0
  63. package/dist/src/handshake/negotiate.d.ts +83 -0
  64. package/dist/src/handshake/negotiate.d.ts.map +1 -0
  65. package/dist/src/handshake/negotiate.js +108 -0
  66. package/dist/src/handshake/negotiate.js.map +1 -0
  67. package/dist/src/handshake/routing.d.ts +51 -0
  68. package/dist/src/handshake/routing.d.ts.map +1 -0
  69. package/dist/src/handshake/routing.js +57 -0
  70. package/dist/src/handshake/routing.js.map +1 -0
  71. package/dist/src/index.d.ts +14 -2
  72. package/dist/src/index.d.ts.map +1 -1
  73. package/dist/src/index.js +12 -1
  74. package/dist/src/index.js.map +1 -1
  75. package/dist/src/interfaces.d.ts +184 -7
  76. package/dist/src/interfaces.d.ts.map +1 -1
  77. package/dist/src/remote-workflow-executor.d.ts +25 -1
  78. package/dist/src/remote-workflow-executor.d.ts.map +1 -1
  79. package/dist/src/remote-workflow-executor.js +41 -0
  80. package/dist/src/remote-workflow-executor.js.map +1 -1
  81. package/dist/src/role_bindings.d.ts +85 -0
  82. package/dist/src/role_bindings.d.ts.map +1 -0
  83. package/dist/src/role_bindings.js +73 -0
  84. package/dist/src/role_bindings.js.map +1 -0
  85. package/dist/src/run-gateway/interface.d.ts +89 -0
  86. package/dist/src/run-gateway/interface.d.ts.map +1 -0
  87. package/dist/src/run-gateway/interface.js +2 -0
  88. package/dist/src/run-gateway/interface.js.map +1 -0
  89. package/dist/src/run-gateway/proxy-run-gateway.d.ts +84 -0
  90. package/dist/src/run-gateway/proxy-run-gateway.d.ts.map +1 -0
  91. package/dist/src/run-gateway/proxy-run-gateway.js +142 -0
  92. package/dist/src/run-gateway/proxy-run-gateway.js.map +1 -0
  93. package/dist/src/run-gateway/run-request-responder.d.ts +67 -0
  94. package/dist/src/run-gateway/run-request-responder.d.ts.map +1 -0
  95. package/dist/src/run-gateway/run-request-responder.js +228 -0
  96. package/dist/src/run-gateway/run-request-responder.js.map +1 -0
  97. package/dist/src/run-gateway/store-run-gateway.d.ts +90 -0
  98. package/dist/src/run-gateway/store-run-gateway.d.ts.map +1 -0
  99. package/dist/src/run-gateway/store-run-gateway.js +80 -0
  100. package/dist/src/run-gateway/store-run-gateway.js.map +1 -0
  101. package/dist/src/run-gateway/tenant-auth.d.ts +19 -0
  102. package/dist/src/run-gateway/tenant-auth.d.ts.map +1 -0
  103. package/dist/src/run-gateway/tenant-auth.js +71 -0
  104. package/dist/src/run-gateway/tenant-auth.js.map +1 -0
  105. package/dist/src/services/main.d.ts +16 -3
  106. package/dist/src/services/main.d.ts.map +1 -1
  107. package/dist/src/services/main.js +23 -4
  108. package/dist/src/services/main.js.map +1 -1
  109. package/dist/src/step-name-symbol.d.ts +3 -0
  110. package/dist/src/step-name-symbol.d.ts.map +1 -1
  111. package/dist/src/step-name-symbol.js.map +1 -1
  112. package/dist/src/step-ref.d.ts +4 -0
  113. package/dist/src/step-ref.d.ts.map +1 -1
  114. package/dist/src/step-ref.js +2 -0
  115. package/dist/src/step-ref.js.map +1 -1
  116. package/dist/src/transports/bullmq/bullmq-transport.d.ts +130 -0
  117. package/dist/src/transports/bullmq/bullmq-transport.d.ts.map +1 -0
  118. package/dist/src/transports/bullmq/bullmq-transport.js +562 -0
  119. package/dist/src/transports/bullmq/bullmq-transport.js.map +1 -0
  120. package/dist/src/transports/bullmq/deps.d.ts +59 -0
  121. package/dist/src/transports/bullmq/deps.d.ts.map +1 -0
  122. package/dist/src/transports/bullmq/deps.js +41 -0
  123. package/dist/src/transports/bullmq/deps.js.map +1 -0
  124. package/dist/src/transports/bullmq/index.d.ts +5 -0
  125. package/dist/src/transports/bullmq/index.d.ts.map +1 -0
  126. package/dist/src/transports/bullmq/index.js +5 -0
  127. package/dist/src/transports/bullmq/index.js.map +1 -0
  128. package/dist/src/transports/bullmq/naming.d.ts +71 -0
  129. package/dist/src/transports/bullmq/naming.d.ts.map +1 -0
  130. package/dist/src/transports/bullmq/naming.js +101 -0
  131. package/dist/src/transports/bullmq/naming.js.map +1 -0
  132. package/dist/src/transports/bullmq/serialization.d.ts +56 -0
  133. package/dist/src/transports/bullmq/serialization.d.ts.map +1 -0
  134. package/dist/src/transports/bullmq/serialization.js +101 -0
  135. package/dist/src/transports/bullmq/serialization.js.map +1 -0
  136. package/dist/src/transports/factory.d.ts +31 -0
  137. package/dist/src/transports/factory.d.ts.map +1 -1
  138. package/dist/src/transports/factory.js +21 -0
  139. package/dist/src/transports/factory.js.map +1 -1
  140. package/dist/src/worker-runtime/index.d.ts +22 -0
  141. package/dist/src/worker-runtime/index.d.ts.map +1 -0
  142. package/dist/src/worker-runtime/index.js +27 -0
  143. package/dist/src/worker-runtime/index.js.map +1 -0
  144. package/dist/src/worker-runtime/naming.d.ts +24 -0
  145. package/dist/src/worker-runtime/naming.d.ts.map +1 -0
  146. package/dist/src/worker-runtime/naming.js +28 -0
  147. package/dist/src/worker-runtime/naming.js.map +1 -0
  148. package/dist/src/worker-runtime/registry.d.ts +81 -0
  149. package/dist/src/worker-runtime/registry.d.ts.map +1 -0
  150. package/dist/src/worker-runtime/registry.js +44 -0
  151. package/dist/src/worker-runtime/registry.js.map +1 -0
  152. package/dist/src/worker-runtime/worker-runtime.d.ts +155 -0
  153. package/dist/src/worker-runtime/worker-runtime.d.ts.map +1 -0
  154. package/dist/src/worker-runtime/worker-runtime.js +265 -0
  155. package/dist/src/worker-runtime/worker-runtime.js.map +1 -0
  156. package/dist/src/workflow-ctx.d.ts +9 -0
  157. package/dist/src/workflow-ctx.d.ts.map +1 -1
  158. package/dist/src/workflow-ctx.js +78 -27
  159. package/dist/src/workflow-ctx.js.map +1 -1
  160. package/dist/src/workflow-turn.d.ts +167 -0
  161. package/dist/src/workflow-turn.d.ts.map +1 -0
  162. package/dist/src/workflow-turn.js +372 -0
  163. package/dist/src/workflow-turn.js.map +1 -0
  164. package/package.json +7 -1
@@ -1,6 +1,7 @@
1
1
  import { InMemoryAdmissionBackend } from './admission.js';
2
2
  import { backoffDelay } from './backoff.js';
3
3
  import { instantCheckpoint, stepCheckpoint } from './checkpoints.js';
4
+ import { WorkflowBlocked, controlPlaneDescriptor, planDispatch, } from './dispatch-routing.js';
4
5
  import { parseDuration } from './duration.js';
5
6
  import { Entities } from './entities.js';
6
7
  import { ContinueAsNew, NonDeterminismError, RemoteStepTimeout, WorkflowSuspended, } from './errors.js';
@@ -67,6 +68,9 @@ export class WorkflowEngine {
67
68
  namespace;
68
69
  leaseMs;
69
70
  maxRecoveryAttempts;
71
+ reconcileMs;
72
+ remoteRedispatchMs;
73
+ remoteRedispatchMax;
70
74
  webhookUrl;
71
75
  traceparent;
72
76
  context;
@@ -77,6 +81,11 @@ export class WorkflowEngine {
77
81
  trackStepStart;
78
82
  /** Where a freshly-started run executes — in-process by default (see {@link RunDispatcher}). */
79
83
  runDispatcher;
84
+ /** The control-plane's OWN handshake descriptor — negotiated against each worker's descriptor to
85
+ * detect protocol incompatibility before dispatch (design §7.3/§7.4). Built once at construction. */
86
+ cpDescriptor;
87
+ /** How long a `blocked` run waits before the recovery poll re-checks the live fleet (design §7.5). */
88
+ blockedPollMs;
80
89
  /** Per-key serialization for singleton workflows (admission, back-pressure, notify-on-release). */
81
90
  singletons;
82
91
  /** Every registered workflow, keyed by `name@version` — so old versions stay runnable. */
@@ -128,12 +137,29 @@ export class WorkflowEngine {
128
137
  this.admission.onFreed?.((queue) => this.wakeQueueWaiters(queue));
129
138
  this.instanceId = deps.instanceId ?? globalThis.crypto.randomUUID();
130
139
  this.namespace = deps.namespace ?? 'default';
140
+ // The control-plane's own handshake descriptor — the side negotiated AGAINST each worker so a
141
+ // protocol-incompatible fleet is detected before dispatch (design §7.3). Scoped to this engine's
142
+ // namespace/partition; capabilities/protocol are config-driven (default: current single major).
143
+ this.cpDescriptor = controlPlaneDescriptor({
144
+ instanceId: this.instanceId,
145
+ ...(deps.controlPlaneCapabilities !== undefined
146
+ ? { capabilities: deps.controlPlaneCapabilities }
147
+ : {}),
148
+ ...(deps.controlPlaneProtocol !== undefined ? { protocol: deps.controlPlaneProtocol } : {}),
149
+ ...(this.namespace !== 'default' ? { namespace: this.namespace } : {}),
150
+ });
151
+ this.blockedPollMs = deps.blockedPollMs ?? 5000;
131
152
  // Propagate the engine's namespace to its transport(s) so the SAME namespace that partitions the
132
153
  // store also partitions the transport's queues/keys — set once on the engine, applied everywhere.
133
154
  // The transport makes "default" a no-op (byte-identical names); an empty pool is a no-op too.
134
155
  this.pool.useNamespace(this.namespace);
135
156
  this.leaseMs = deps.leaseMs ?? 30_000;
136
157
  this.maxRecoveryAttempts = deps.maxRecoveryAttempts;
158
+ // Default 5min; an explicit 0 disables the fallback (opt back into wake-forever-on-lost-event).
159
+ this.reconcileMs = deps.reconcileMs === undefined ? 300_000 : deps.reconcileMs || undefined;
160
+ // Opt-in: unset (or 0) leaves the by-design "re-suspend a lost dispatch, never re-dispatch" behavior.
161
+ this.remoteRedispatchMs = deps.remoteRedispatchMs || undefined;
162
+ this.remoteRedispatchMax = Math.max(1, deps.remoteRedispatchMax ?? 10);
137
163
  this.webhookUrl = deps.webhookUrl;
138
164
  this.traceparent = deps.traceparent;
139
165
  this.context = deps.context;
@@ -267,6 +293,7 @@ export class WorkflowEngine {
267
293
  validateInput: opts?.validateInput,
268
294
  onEvent: opts?.onEvent,
269
295
  eventBatch: opts?.eventBatch,
296
+ requires: opts?.requires,
270
297
  };
271
298
  this.workflows.set(versionKey(name, version), registered);
272
299
  const current = this.latest.get(name);
@@ -297,6 +324,7 @@ export class WorkflowEngine {
297
324
  executionTimeoutMs: opts.executionTimeout != null ? parseDuration(opts.executionTimeout) : undefined,
298
325
  validateInput: opts.validateInput,
299
326
  remote: { group: opts.group, executor: opts.executor },
327
+ requires: opts.requires,
300
328
  };
301
329
  this.workflows.set(versionKey(name, version), registered);
302
330
  const current = this.latest.get(name);
@@ -661,11 +689,16 @@ export class WorkflowEngine {
661
689
  return undefined;
662
690
  }
663
691
  /**
664
- * Resume every suspended run whose durable timer is due. Call periodically (a poller) and on
665
- * boot. A run still not due re-suspends cheaply without running new work.
692
+ * Resume every suspended run whose durable timer is due AND every run parked `blocked` (no
693
+ * capable/compatible worker, design §7.5) whose recovery poll is due. Call periodically (a poller)
694
+ * and on boot. A blocked run re-drives to re-check the live fleet: it dispatches the moment a
695
+ * capable+compatible worker has appeared, else re-parks `blocked` with a fresh `wakeAt`. A run still
696
+ * not due re-suspends cheaply without running new work.
666
697
  */
667
698
  async resumeDueTimers(nowMs = this.clock()) {
668
- return this.resumeLeased(await this.store.listDueTimers(nowMs, this.namespace), nowMs);
699
+ const due = await this.store.listDueTimers(nowMs, this.namespace);
700
+ const blocked = await this.dueBlockedRuns(nowMs);
701
+ return this.resumeLeased([...due, ...blocked], nowMs);
669
702
  }
670
703
  /**
671
704
  * Lease and execute one run by id — the worker side of dispatch. Acquires the recovery lease (so
@@ -695,15 +728,124 @@ export class WorkflowEngine {
695
728
  * inline — or null if the run is unknown. The dashboard "retry" goes through here so it can't block
696
729
  * the HTTP request on workflow execution.
697
730
  */
698
- async requeue(runId) {
731
+ async requeue(runId, _seen) {
732
+ const seen = _seen ?? new Set();
733
+ if (seen.has(runId))
734
+ return null;
735
+ seen.add(runId);
699
736
  const run = await this.store.getRun(runId);
700
737
  if (!run)
701
738
  return null;
739
+ // A FAILED/DEAD run's replay deterministically re-throws its recorded failures — an exhausted
740
+ // failed checkpoint rethrows, and an awaited child's failure completion rethrows through
741
+ // unwrapCompletion — so "retry" without resetting that state is a no-op that re-fails in
742
+ // milliseconds. Reset the failure state first so replay RE-EXECUTES the failed parts:
743
+ // - a `failed` checkpoint becomes retryable-now (attempts 0, wake immediately, `retryable`
744
+ // forced back on): the durable-retry machinery re-dispatches it as a fresh first attempt;
745
+ // - an awaited-child `signal:child:` checkpoint holding a FAILURE completion returns to its
746
+ // live `running` placeholder, so replay re-registers the `child:<id>` waiter — a separately
747
+ // retried child's completion (already buffered, or still to come) then resumes this run.
748
+ // Retry child and parent in either order; signal buffering makes it converge.
749
+ if (run.status === 'failed' || run.status === 'dead') {
750
+ const isFailureCompletion = (v) => typeof v === 'object' && v !== null && v.ok === false;
751
+ for (const cp of await this.store.listCheckpoints(runId)) {
752
+ if (cp.status === 'failed') {
753
+ await this.store.saveCheckpoint({
754
+ ...cp,
755
+ attempts: 0,
756
+ wakeAt: this.clock(),
757
+ ...(cp.error ? { error: { ...cp.error, retryable: true } } : {}),
758
+ });
759
+ }
760
+ else if (cp.kind === 'signal' &&
761
+ cp.name.startsWith('signal:child:') &&
762
+ cp.status === 'completed' &&
763
+ isFailureCompletion(cp.output)) {
764
+ await this.store.saveCheckpoint({ ...cp, status: 'running', output: undefined });
765
+ // CASCADE: retrying only the parent is useless in a live engine — replay re-registers
766
+ // the `child:<id>` waiter and the reconciler re-delivers the child's still-FAILED
767
+ // terminal state within seconds, re-failing the parent with the same error. Requeue the
768
+ // failed child too so the pair converges: whichever finishes the handshake last (waiter
769
+ // registered vs completion buffered) resumes the parent.
770
+ //
771
+ // Exception: a SUCCESS already buffered on this token (a completed `~retry~` fix of the
772
+ // child — see notifyParent's origin delivery) is the outcome the replay will consume, so
773
+ // re-running the failed origin would be pure waste. Peek = take + re-buffer.
774
+ const childId = cp.name.slice('signal:child:'.length);
775
+ // Drain-and-restore keeps relative order (a partial take+re-buffer would rotate it).
776
+ const parked = [];
777
+ for (;;) {
778
+ const buffered = await this.store.takeBufferedSignal(`child:${childId}`);
779
+ if (!buffered)
780
+ break;
781
+ parked.push(buffered.payload);
782
+ }
783
+ for (const payload of parked)
784
+ await this.store.bufferSignal(`child:${childId}`, payload);
785
+ const bufferedSuccess = parked.some((payload) => typeof payload === 'object' &&
786
+ payload !== null &&
787
+ payload.ok === true);
788
+ if (!bufferedSuccess) {
789
+ const child = await this.store.getRun(childId);
790
+ if (child && (child.status === 'failed' || child.status === 'dead')) {
791
+ await this.requeue(childId, seen);
792
+ }
793
+ }
794
+ }
795
+ }
796
+ }
702
797
  await this.store.releaseRunLock(runId);
703
- await this.store.updateRun(runId, { status: 'pending', updatedAt: new Date() });
798
+ // Explicit `error: undefined` CLEARS the stale failure (the store spreads the patch over the
799
+ // run) — otherwise the dashboard keeps showing the old error while the run is re-executing.
800
+ await this.store.updateRun(runId, {
801
+ status: 'pending',
802
+ error: undefined,
803
+ updatedAt: new Date(),
804
+ });
704
805
  await this.runDispatcher.dispatch(runId);
705
806
  return { runId, status: 'pending' };
706
807
  }
808
+ /**
809
+ * Explicitly re-dispatch every remote step of `runId` stuck `pending` — the OPERATOR escape hatch for
810
+ * a run whose dispatched step job was LOST (worker crashed with no result, or a transport that dropped
811
+ * the job). No automatic recovery re-drives these: a reconcile re-drive re-suspends a still-pending
812
+ * step by design (unless `remoteRedispatchMs` is set), `recoverIncomplete` only reclaims LEASED runs,
813
+ * and `requeue` just replays back to the pending guard. This re-enqueues the same `stepId` so the
814
+ * (idempotent) step re-runs and its result resumes the run. Safe to call on a healthy run — it re-runs
815
+ * only in-flight `pending` remote steps, which are idempotent by the durable contract. Returns the
816
+ * run's current status (with the count re-dispatched), or null if the run is unknown.
817
+ */
818
+ async redispatchPending(runId) {
819
+ const run = await this.store.getRun(runId);
820
+ if (!run)
821
+ return null;
822
+ const pending = (await this.store.listCheckpoints(runId)).filter((cp) => cp.kind === 'remote' && cp.status === 'pending');
823
+ let redispatched = 0;
824
+ for (const cp of pending) {
825
+ if (!cp.stepId)
826
+ continue;
827
+ const reAttempt = cp.attempts + 1;
828
+ const reEnqueuedAt = new Date();
829
+ await this.store.saveCheckpoint({
830
+ ...cp,
831
+ attempts: reAttempt,
832
+ enqueuedAt: reEnqueuedAt,
833
+ startedAt: reEnqueuedAt,
834
+ finishedAt: reEnqueuedAt,
835
+ });
836
+ await this.dispatchRemoteTask({
837
+ runId,
838
+ seq: cp.seq,
839
+ name: cp.name,
840
+ stepId: cp.stepId,
841
+ group: cp.workerGroup ?? sanitizeQueueToken(cp.name),
842
+ input: cp.input,
843
+ attempt: reAttempt,
844
+ });
845
+ redispatched += 1;
846
+ }
847
+ return { runId, status: run.status, redispatched };
848
+ }
707
849
  /**
708
850
  * **Fix-and-replay**: re-run a run (typically a `dead`/`failed` one) with a corrected `input`, as a
709
851
  * fresh run with clean history. It's a NEW run — `newRunId` defaults to `<runId>~retry~<uuid>` — so
@@ -982,6 +1124,15 @@ export class WorkflowEngine {
982
1124
  */
983
1125
  notifyParent(runId, completion) {
984
1126
  void this.signal(`child:${runId}`, completion).catch(() => undefined);
1127
+ // A fix-and-replay run (`<origin>~retry~<hash>`) is standalone, but its SUCCESS is the
1128
+ // origin's outcome for all practical purposes: deliver it on the ORIGIN's token too, so a
1129
+ // parent that failed on that child and is retried later consumes this success (buffered or
1130
+ // live) instead of waiting on a child nobody re-runs. Failures stay retry-only — a failed
1131
+ // fix attempt must not poison the origin's token.
1132
+ const at = runId.lastIndexOf('~retry~');
1133
+ if (at !== -1 && completion.ok) {
1134
+ void this.signal(`child:${runId.slice(0, at)}`, completion).catch(() => undefined);
1135
+ }
985
1136
  }
986
1137
  /**
987
1138
  * Deferred child start shared by the in-process ctx host and the remote `startChild` command.
@@ -1393,7 +1544,11 @@ export class WorkflowEngine {
1393
1544
  if (latest?.status === 'cancelled') {
1394
1545
  return { runId: run.id, status: 'cancelled', error: latest.error };
1395
1546
  }
1396
- await this.store.updateRun(run.id, { status: 'suspended', wakeAt: outcome.wakeAt, updatedAt });
1547
+ await this.store.updateRun(run.id, {
1548
+ status: 'suspended',
1549
+ wakeAt: this.reconcileWakeAt(outcome.wakeAt),
1550
+ updatedAt,
1551
+ });
1397
1552
  this.emit({
1398
1553
  type: 'run.suspended',
1399
1554
  runId: run.id,
@@ -1402,6 +1557,17 @@ export class WorkflowEngine {
1402
1557
  });
1403
1558
  return { runId: run.id, status: 'suspended' };
1404
1559
  }
1560
+ /**
1561
+ * A wakeAt to persist on a suspend: the natural timer if the turn produced one (a `ctx.sleep`, a
1562
+ * remote-step deadline), else the {@link reconcileMs} fallback so an event-waiting run (child/signal/
1563
+ * timeout-less remote step) can't be orphaned forever if its wake is lost. `undefined` only when the
1564
+ * fallback is disabled (`reconcileMs: 0`). See {@link WorkflowEngineDeps.reconcileMs}.
1565
+ */
1566
+ reconcileWakeAt(wakeAt) {
1567
+ if (wakeAt != null)
1568
+ return wakeAt;
1569
+ return this.reconcileMs != null ? this.clock() + this.reconcileMs : undefined;
1570
+ }
1405
1571
  async runRemoteExecution(run, registered) {
1406
1572
  const remote = registered.remote;
1407
1573
  if (run.status === 'pending') {
@@ -1426,6 +1592,12 @@ export class WorkflowEngine {
1426
1592
  await this.store.releaseRunLock(run.id);
1427
1593
  return { runId: run.id, status: 'suspended' };
1428
1594
  }
1595
+ // Capability/protocol guard (design §7.5): a remote workflow's turn dispatches a WorkflowTask to
1596
+ // its group — if no live worker on that group can run it, park `blocked` before advancing rather
1597
+ // than handing the task to the executor to enqueue into a void. Re-checked each turn (recovery).
1598
+ const routePlan = await this.planRoute(remote.group, registered.requires);
1599
+ if (routePlan?.status === 'blocked')
1600
+ return this.parkBlocked(run, routePlan);
1429
1601
  const history = await this.remoteHistory(run.id);
1430
1602
  let decision;
1431
1603
  try {
@@ -1757,6 +1929,12 @@ export class WorkflowEngine {
1757
1929
  queueMicrotask(() => void this.start(run.workflow, err.input, nextId).catch(() => undefined));
1758
1930
  return { runId: run.id, status: 'completed' };
1759
1931
  }
1932
+ if (err instanceof WorkflowBlocked) {
1933
+ // No live worker can run the next dispatch (capability/protocol) — park `blocked` with the
1934
+ // structured reason + diagnostics instead of suspending on a timer that would re-drive into the
1935
+ // same void. The blocked-recovery poll re-drives it when a capable+compatible worker appears.
1936
+ return this.parkBlocked(run, err.plan);
1937
+ }
1760
1938
  if (err instanceof WorkflowSuspended) {
1761
1939
  // A compensating cancel resumed this run to reach here: the replay re-registered the saga,
1762
1940
  // so undo the completed steps in reverse and mark it cancelled instead of re-suspending.
@@ -1899,6 +2077,12 @@ export class WorkflowEngine {
1899
2077
  startChild: (workflow, input, id, priority) => {
1900
2078
  this.startChildDeferred(workflow, input, id, { priority });
1901
2079
  },
2080
+ // Deferred for the same reentrancy reason as `startChild` above. `cancel()` is already
2081
+ // idempotent on a terminal/cancelled run (returns its existing status without side effects), so
2082
+ // no extra guard is needed here for the failFast replay case (re-issuing the same cancel calls).
2083
+ cancelChild: (childId) => {
2084
+ queueMicrotask(() => void this.cancel(childId).catch(() => undefined));
2085
+ },
1902
2086
  // Shallow-merge into the run's searchAttributes (the ctx primitive makes this exactly-once).
1903
2087
  upsertSearchAttributes: async (runId, attrs) => {
1904
2088
  const run = await this.store.getRun(runId);
@@ -1978,6 +2162,89 @@ export class WorkflowEngine {
1978
2162
  const result = await this.signal(`update:${runId}:${name}`, arg);
1979
2163
  return { accepted: true, run: result };
1980
2164
  }
2165
+ // ---------------------------------------------------------------------------
2166
+ // Capability- & protocol-aware dispatch guard (design §7.5/§7.6)
2167
+ // ---------------------------------------------------------------------------
2168
+ /** The LIVE worker handshake descriptors advertised on `token`, read from the primary transport's
2169
+ * descriptor keyspace. Empty when no transport can introspect it (in-process / pre-handshake broker)
2170
+ * OR nobody is publishing — either way the guard is skipped and dispatch proceeds legacy
2171
+ * assume-compatible (design §7.7). Never throws (the transport degrades to `[]`). */
2172
+ async liveDescriptorsFor(token) {
2173
+ const read = this.primaryTransport?.listWorkerDescriptors;
2174
+ if (!read)
2175
+ return [];
2176
+ try {
2177
+ return await read.call(this.primaryTransport, token);
2178
+ }
2179
+ catch {
2180
+ return [];
2181
+ }
2182
+ }
2183
+ /**
2184
+ * Plan a dispatch to `token` for a handler requiring `requires` (design §7.5). Returns:
2185
+ * - `undefined` — NO live descriptors published on this token: the guard does not engage, so a
2186
+ * pre-handshake fleet (or an in-process transport) keeps flowing exactly as before (design §7.7);
2187
+ * - a `routable` plan — ≥1 live worker is capability-capable AND protocol-compatible (dispatch);
2188
+ * - a `blocked` plan — descriptors exist but none can run it (park the run, never dispatch into it).
2189
+ */
2190
+ async planRoute(token, requires) {
2191
+ const descriptors = await this.liveDescriptorsFor(token);
2192
+ if (descriptors.length === 0)
2193
+ return undefined; // legacy/no-runtime path — skip the guard
2194
+ return planDispatch(requires ?? [], descriptors, this.cpDescriptor, token);
2195
+ }
2196
+ /** Throw {@link WorkflowBlocked} when no live worker can run a dispatch to `token` — so the caller's
2197
+ * run-execution catch parks the run `blocked` (with the plan) instead of persisting a `pending`
2198
+ * checkpoint + dispatching into a queue nobody consumes (design §7.5). A no-op when the guard does
2199
+ * not engage (no descriptors) or a capable+compatible worker exists. */
2200
+ async ensureRoutable(token, requires) {
2201
+ const plan = await this.planRoute(token, requires);
2202
+ if (plan?.status === 'blocked')
2203
+ throw new WorkflowBlocked(plan);
2204
+ }
2205
+ /**
2206
+ * Park a run `blocked`: no live worker can run its next dispatch (design §7.5/§7.6). Persists the
2207
+ * `blocked` status with a human reason + a `wakeAt` so the blocked-recovery poll re-drives it, emits
2208
+ * the LOUD structured `capability.unavailable`/`protocol.incompatible` diagnostics event (carrying
2209
+ * both descriptors + the precise delta), and releases the run lease so any instance can re-drive it.
2210
+ * Re-checks the persisted status first so a concurrent `cancel` is never clobbered.
2211
+ */
2212
+ async parkBlocked(run, blocked) {
2213
+ const latest = await this.store.getRun(run.id);
2214
+ if (latest && (latest.status === 'cancelled' || latest.status === 'completed')) {
2215
+ await this.store.releaseRunLock(run.id);
2216
+ return { runId: run.id, status: latest.status, output: latest.output, error: latest.error };
2217
+ }
2218
+ const error = { message: blocked.reason, retryable: true };
2219
+ const wakeAt = this.clock() + this.blockedPollMs;
2220
+ await this.store.updateRun(run.id, {
2221
+ status: 'blocked',
2222
+ error,
2223
+ wakeAt,
2224
+ updatedAt: new Date(),
2225
+ });
2226
+ this.emit({
2227
+ type: blocked.code,
2228
+ runId: run.id,
2229
+ workflow: run.workflow,
2230
+ namespace: run.namespace,
2231
+ error,
2232
+ diagnostics: blocked.diagnostics,
2233
+ });
2234
+ // Release the lease so the blocked-recovery poll (this or any instance) can re-drive it promptly
2235
+ // once a capable+compatible worker appears — mirrors the suspended path's release.
2236
+ await this.store.releaseRunLock(run.id);
2237
+ return { runId: run.id, status: 'blocked', error };
2238
+ }
2239
+ /** Runs parked `blocked` whose recovery poll is due (`wakeAt <= nowMs`), namespace-scoped like
2240
+ * {@link resumeDueTimers}. Re-driving one re-checks the live fleet: it dispatches if a
2241
+ * capable+compatible worker has appeared, else re-parks with a fresh `wakeAt` (design §7.5). */
2242
+ async dueBlockedRuns(nowMs) {
2243
+ const blocked = await this.store.listRuns({ statuses: ['blocked'] });
2244
+ return blocked.filter((r) => (r.namespace === undefined || r.namespace === this.namespace) &&
2245
+ r.wakeAt !== undefined &&
2246
+ r.wakeAt <= nowMs);
2247
+ }
1981
2248
  async callRemote(runId, seq, step, input, queue, transport, replay, admission) {
1982
2249
  // Read the prefix from the per-execution snapshot (avoids the O(N²) replay SELECTs); a seq absent
1983
2250
  // from the snapshot — not yet dispatched, or written after the snapshot — falls back to the store.
@@ -1995,8 +2262,57 @@ export class WorkflowEngine {
1995
2262
  // pod can scale down or crash mid-step without losing the run or re-running completed work.
1996
2263
  if (step.timeoutMs)
1997
2264
  return this.callRemoteInMemory(runId, seq, step, input, transport);
1998
- if (existing?.status === 'pending')
1999
- throw new WorkflowSuspended(); // dispatched; keep waiting
2265
+ if (existing?.status === 'pending') {
2266
+ // Dispatched; normally we just keep waiting for the result to resume the run. But a LOST dispatch
2267
+ // (worker crashed with no result, or the transport dropped the job) would hang here forever — a
2268
+ // reconcile re-drive replays straight back to this guard. Opt-in self-heal: once the step has been
2269
+ // pending longer than `remoteRedispatchMs`, re-dispatch the same stepId (the idempotent step
2270
+ // re-runs, its result resumes the run), bounded by `remoteRedispatchMax` so a never-settling step
2271
+ // fails instead of looping. Unset (default) keeps the by-design "re-suspend, never re-dispatch".
2272
+ if (this.remoteRedispatchMs == null)
2273
+ throw new WorkflowSuspended();
2274
+ // Stamp a redispatch deadline (clock-space, persisted) the first time we see this pending step,
2275
+ // stable across replays and crashes — mirrors the failed-retry backoff below. The run's wakeAt
2276
+ // becomes this deadline, so a reconcile re-drive lands exactly when it's due to re-dispatch.
2277
+ if (existing.wakeAt == null) {
2278
+ const wakeAt = this.clock() + this.remoteRedispatchMs;
2279
+ await this.store.saveCheckpoint({ ...existing, wakeAt });
2280
+ throw new WorkflowSuspended(wakeAt);
2281
+ }
2282
+ if (this.clock() < existing.wakeAt)
2283
+ throw new WorkflowSuspended(existing.wakeAt);
2284
+ // Past the deadline with no result — the dispatch is presumed lost. Re-dispatch (bounded by
2285
+ // `remoteRedispatchMax` so a step that never settles fails the run instead of looping forever).
2286
+ if (existing.attempts >= this.remoteRedispatchMax) {
2287
+ throw new RemoteStepError({
2288
+ message: `remote step "${step.name}" lost — no result after ${existing.attempts} re-dispatch(es)`,
2289
+ code: 'remote_step_lost',
2290
+ });
2291
+ }
2292
+ const reAttempt = existing.attempts + 1;
2293
+ const nextDeadline = this.clock() + this.remoteRedispatchMs;
2294
+ const reEnqueuedAt = new Date();
2295
+ await this.store.saveCheckpoint({
2296
+ ...existing,
2297
+ attempts: reAttempt,
2298
+ wakeAt: nextDeadline,
2299
+ enqueuedAt: reEnqueuedAt,
2300
+ startedAt: reEnqueuedAt,
2301
+ finishedAt: reEnqueuedAt,
2302
+ });
2303
+ await this.dispatchRemoteTask({
2304
+ runId,
2305
+ seq,
2306
+ name: step.name,
2307
+ stepId: existing.stepId ?? stepId(runId, seq),
2308
+ group: existing.workerGroup ?? tenantGroup(sanitizeQueueToken(step.name), step.partition),
2309
+ input: existing.input,
2310
+ priority: admission?.priority,
2311
+ attempt: reAttempt,
2312
+ transport,
2313
+ });
2314
+ throw new WorkflowSuspended(nextDeadline);
2315
+ }
2000
2316
  // Durable retry: a failed attempt re-dispatches up to `retries`, spacing attempts by `backoff` —
2001
2317
  // unless the worker marked the error non-retryable (a deterministic verdict like a declined card).
2002
2318
  // The retry deadline is stamped on the failed checkpoint as `wakeAt` (clock-space, persisted) the
@@ -2047,6 +2363,11 @@ export class WorkflowEngine {
2047
2363
  // used at BOTH the checkpoint's workerGroup and the dispatched task's routing `group`, so the same
2048
2364
  // token the worker subscribes to per handler name serves this step.
2049
2365
  const token = tenantGroup(sanitizeQueueToken(step.name), step.partition);
2366
+ // Capability/protocol guard (design §7.5): if the live fleet on this token can't run the step,
2367
+ // throw WorkflowBlocked BEFORE the pending checkpoint + dispatch — so the run parks `blocked`
2368
+ // (recovered by the blocked poll) instead of writing a `pending` row a `WorkflowSuspended` re-drive
2369
+ // would mistake for "already dispatched" and wait on forever. A no-op when no descriptors exist.
2370
+ await this.ensureRoutable(token, step.requires);
2050
2371
  const enqueuedAt = new Date();
2051
2372
  // Persist the pending checkpoint BEFORE dispatching, so a fast result always finds it to complete.
2052
2373
  await this.store.saveCheckpoint({
@@ -2063,21 +2384,39 @@ export class WorkflowEngine {
2063
2384
  startedAt: enqueuedAt, // placeholders until the worker result lands
2064
2385
  finishedAt: enqueuedAt,
2065
2386
  });
2066
- await this.pool.dispatch({
2387
+ await this.dispatchRemoteTask({
2067
2388
  runId,
2068
2389
  seq,
2069
2390
  name: step.name,
2070
2391
  stepId: id,
2071
2392
  group: token,
2072
2393
  input: validInput,
2073
- traceparent: this.traceparent?.(),
2074
- context: this.context?.(),
2075
2394
  priority: admission?.priority,
2076
2395
  attempt,
2077
- }, transport);
2396
+ transport,
2397
+ });
2078
2398
  this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
2079
2399
  throw new WorkflowSuspended();
2080
2400
  }
2401
+ /**
2402
+ * Enqueue a remote step task to its worker group via the transport pool. Shared by the initial
2403
+ * dispatch, the opt-in self-heal re-dispatch (see `remoteRedispatchMs`), and the explicit
2404
+ * {@link redispatchPending}. Carries the current trace/context so a re-dispatch is traceable.
2405
+ */
2406
+ dispatchRemoteTask(task) {
2407
+ return this.pool.dispatch({
2408
+ runId: task.runId,
2409
+ seq: task.seq,
2410
+ name: task.name,
2411
+ stepId: task.stepId,
2412
+ group: task.group,
2413
+ input: task.input,
2414
+ traceparent: this.traceparent?.(),
2415
+ context: this.context?.(),
2416
+ priority: task.priority,
2417
+ attempt: task.attempt,
2418
+ }, task.transport);
2419
+ }
2081
2420
  /**
2082
2421
  * Complete a durable remote step from its worker result and resume the run — runs on whichever
2083
2422
  * instance receives the result (the dispatching one may be gone), so the run is crash/scale-safe.
@@ -2157,6 +2496,9 @@ export class WorkflowEngine {
2157
2496
  throw new Error('remote steps require a Transport');
2158
2497
  const validInput = step.input ? step.input.parse(input) : input;
2159
2498
  const token = tenantGroup(sanitizeQueueToken(step.name), step.partition);
2499
+ // Capability/protocol guard (design §7.5) — same as the durable path, before the `step.started`
2500
+ // emit + dispatch, so a step nobody can run parks `blocked` instead of awaiting a phantom worker.
2501
+ await this.ensureRoutable(token, step.requires);
2160
2502
  const id = stepId(runId, seq);
2161
2503
  const enqueuedAt = new Date();
2162
2504
  this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });