@adonis-agora/durable 0.35.1 → 0.36.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 (139) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/dist/assets/spa/assets/index-DU6cGcJz.css +1 -0
  3. package/dist/assets/spa/assets/index-Dx5lG7Vu.js +16 -0
  4. package/dist/assets/spa/assets/index-Dx5lG7Vu.js.map +1 -0
  5. package/dist/assets/spa/index.html +2 -2
  6. package/dist/commands/export_run.d.ts +18 -0
  7. package/dist/commands/export_run.d.ts.map +1 -0
  8. package/dist/commands/export_run.js +45 -0
  9. package/dist/commands/export_run.js.map +1 -0
  10. package/dist/commands/main.d.ts +1 -1
  11. package/dist/commands/main.d.ts.map +1 -1
  12. package/dist/commands/main.js +3 -1
  13. package/dist/commands/main.js.map +1 -1
  14. package/dist/providers/dashboard_provider.d.ts +7 -0
  15. package/dist/providers/dashboard_provider.d.ts.map +1 -1
  16. package/dist/providers/dashboard_provider.js +36 -1
  17. package/dist/providers/dashboard_provider.js.map +1 -1
  18. package/dist/providers/durable_provider.d.ts.map +1 -1
  19. package/dist/providers/durable_provider.js +18 -0
  20. package/dist/providers/durable_provider.js.map +1 -1
  21. package/dist/src/admission-redis/redis-admission-backend.d.ts +2 -1
  22. package/dist/src/admission-redis/redis-admission-backend.d.ts.map +1 -1
  23. package/dist/src/admission-redis/redis-admission-backend.js +35 -8
  24. package/dist/src/admission-redis/redis-admission-backend.js.map +1 -1
  25. package/dist/src/backoff.d.ts +7 -0
  26. package/dist/src/backoff.d.ts.map +1 -1
  27. package/dist/src/backoff.js +12 -2
  28. package/dist/src/backoff.js.map +1 -1
  29. package/dist/src/codec-state-store.d.ts +4 -2
  30. package/dist/src/codec-state-store.d.ts.map +1 -1
  31. package/dist/src/codec-state-store.js +23 -4
  32. package/dist/src/codec-state-store.js.map +1 -1
  33. package/dist/src/commands/worker.d.ts +2 -0
  34. package/dist/src/commands/worker.d.ts.map +1 -1
  35. package/dist/src/commands/worker.js +18 -1
  36. package/dist/src/commands/worker.js.map +1 -1
  37. package/dist/src/dashboard/gateway-adapter.d.ts +13 -1
  38. package/dist/src/dashboard/gateway-adapter.d.ts.map +1 -1
  39. package/dist/src/dashboard/gateway-adapter.js +12 -0
  40. package/dist/src/dashboard/gateway-adapter.js.map +1 -1
  41. package/dist/src/dashboard/handlers.d.ts +47 -1
  42. package/dist/src/dashboard/handlers.d.ts.map +1 -1
  43. package/dist/src/dashboard/handlers.js +141 -8
  44. package/dist/src/dashboard/handlers.js.map +1 -1
  45. package/dist/src/dashboard/index.d.ts +1 -1
  46. package/dist/src/dashboard/index.js +1 -1
  47. package/dist/src/dashboard/openapi.d.ts +13 -0
  48. package/dist/src/dashboard/openapi.d.ts.map +1 -0
  49. package/dist/src/dashboard/openapi.js +395 -0
  50. package/dist/src/dashboard/openapi.js.map +1 -0
  51. package/dist/src/dashboard/run-filter.d.ts +8 -3
  52. package/dist/src/dashboard/run-filter.d.ts.map +1 -1
  53. package/dist/src/dashboard/run-filter.js +35 -4
  54. package/dist/src/dashboard/run-filter.js.map +1 -1
  55. package/dist/src/define_config.d.ts +24 -0
  56. package/dist/src/define_config.d.ts.map +1 -1
  57. package/dist/src/define_config.js.map +1 -1
  58. package/dist/src/engine.d.ts +204 -7
  59. package/dist/src/engine.d.ts.map +1 -1
  60. package/dist/src/engine.js +820 -115
  61. package/dist/src/engine.js.map +1 -1
  62. package/dist/src/errors.d.ts +9 -1
  63. package/dist/src/errors.d.ts.map +1 -1
  64. package/dist/src/errors.js +10 -1
  65. package/dist/src/errors.js.map +1 -1
  66. package/dist/src/index.d.ts +1 -1
  67. package/dist/src/index.js +1 -1
  68. package/dist/src/interfaces.d.ts +116 -7
  69. package/dist/src/interfaces.d.ts.map +1 -1
  70. package/dist/src/pollers.d.ts +26 -2
  71. package/dist/src/pollers.d.ts.map +1 -1
  72. package/dist/src/pollers.js +47 -4
  73. package/dist/src/pollers.js.map +1 -1
  74. package/dist/src/queue.d.ts +9 -1
  75. package/dist/src/queue.d.ts.map +1 -1
  76. package/dist/src/queue.js +26 -7
  77. package/dist/src/queue.js.map +1 -1
  78. package/dist/src/run-value-facets.d.ts +1 -1
  79. package/dist/src/run-value-facets.d.ts.map +1 -1
  80. package/dist/src/run-value-facets.js +7 -1
  81. package/dist/src/run-value-facets.js.map +1 -1
  82. package/dist/src/scheduler.d.ts +15 -1
  83. package/dist/src/scheduler.d.ts.map +1 -1
  84. package/dist/src/scheduler.js +36 -1
  85. package/dist/src/scheduler.js.map +1 -1
  86. package/dist/src/stores/lucid-mappers.d.ts +5 -0
  87. package/dist/src/stores/lucid-mappers.d.ts.map +1 -1
  88. package/dist/src/stores/lucid-mappers.js +8 -0
  89. package/dist/src/stores/lucid-mappers.js.map +1 -1
  90. package/dist/src/stores/lucid-schema.d.ts.map +1 -1
  91. package/dist/src/stores/lucid-schema.js +53 -7
  92. package/dist/src/stores/lucid-schema.js.map +1 -1
  93. package/dist/src/stores/lucid.d.ts +10 -3
  94. package/dist/src/stores/lucid.d.ts.map +1 -1
  95. package/dist/src/stores/lucid.js +74 -67
  96. package/dist/src/stores/lucid.js.map +1 -1
  97. package/dist/src/testing/in-memory-state-store.d.ts +4 -2
  98. package/dist/src/testing/in-memory-state-store.d.ts.map +1 -1
  99. package/dist/src/testing/in-memory-state-store.js +54 -8
  100. package/dist/src/testing/in-memory-state-store.js.map +1 -1
  101. package/dist/src/testing-kit/replay.d.ts +12 -0
  102. package/dist/src/testing-kit/replay.d.ts.map +1 -1
  103. package/dist/src/testing-kit/replay.js +33 -0
  104. package/dist/src/testing-kit/replay.js.map +1 -1
  105. package/dist/src/testing-kit/state-store-conformance.d.ts.map +1 -1
  106. package/dist/src/testing-kit/state-store-conformance.js +68 -0
  107. package/dist/src/testing-kit/state-store-conformance.js.map +1 -1
  108. package/dist/src/transports/bullmq/bullmq-transport.d.ts +0 -5
  109. package/dist/src/transports/bullmq/bullmq-transport.d.ts.map +1 -1
  110. package/dist/src/transports/bullmq/bullmq-transport.js +92 -10
  111. package/dist/src/transports/bullmq/bullmq-transport.js.map +1 -1
  112. package/dist/src/transports/bullmq/deps.d.ts +2 -0
  113. package/dist/src/transports/bullmq/deps.d.ts.map +1 -1
  114. package/dist/src/transports/bullmq/deps.js.map +1 -1
  115. package/dist/src/transports/bullmq/serialization.d.ts +2 -1
  116. package/dist/src/transports/bullmq/serialization.d.ts.map +1 -1
  117. package/dist/src/transports/bullmq/serialization.js +9 -3
  118. package/dist/src/transports/bullmq/serialization.js.map +1 -1
  119. package/dist/src/transports/db.d.ts +3 -1
  120. package/dist/src/transports/db.d.ts.map +1 -1
  121. package/dist/src/transports/db.js +89 -35
  122. package/dist/src/transports/db.js.map +1 -1
  123. package/dist/src/transports/queue.d.ts +7 -1
  124. package/dist/src/transports/queue.d.ts.map +1 -1
  125. package/dist/src/transports/queue.js +23 -1
  126. package/dist/src/transports/queue.js.map +1 -1
  127. package/dist/src/workflow-ctx.d.ts.map +1 -1
  128. package/dist/src/workflow-ctx.js +77 -14
  129. package/dist/src/workflow-ctx.js.map +1 -1
  130. package/dist/src/workflow-discovery.d.ts.map +1 -1
  131. package/dist/src/workflow-discovery.js +1 -0
  132. package/dist/src/workflow-discovery.js.map +1 -1
  133. package/dist/src/workflow-ref.d.ts +6 -0
  134. package/dist/src/workflow-ref.d.ts.map +1 -1
  135. package/dist/src/workflow-ref.js.map +1 -1
  136. package/package.json +2 -2
  137. package/dist/assets/spa/assets/index-CNnTzTNK.css +0 -1
  138. package/dist/assets/spa/assets/index-DxLx4LRM.js +0 -16
  139. package/dist/assets/spa/assets/index-DxLx4LRM.js.map +0 -1
@@ -1,5 +1,5 @@
1
1
  import { InMemoryAdmissionBackend } from './admission.js';
2
- import { backoffDelay } from './backoff.js';
2
+ import { backoffDelay, MAX_BACKOFF_MS } from './backoff.js';
3
3
  import { instantCheckpoint, stepCheckpoint } from './checkpoints.js';
4
4
  import { controlPlaneDescriptor, planDispatch, WorkflowBlocked, } from './dispatch-routing.js';
5
5
  import { parseDuration } from './duration.js';
@@ -11,6 +11,7 @@ import { asHeartbeat } from './observability-scope.js';
11
11
  import { breakpointToken, stepId } from './protocol.js';
12
12
  import { RemoteWorkflowExecutor } from './remote-workflow-executor.js';
13
13
  import { scanRunValueFacets } from './run-value-facets.js';
14
+ import { scheduleWindow } from './scheduler.js';
14
15
  import { SingletonGate } from './singleton-gate.js';
15
16
  import { sanitizeQueueToken, tenantGroup } from './tenant-group.js';
16
17
  import { TransportPool } from './transport-pool.js';
@@ -52,6 +53,13 @@ const SETTLE_ELIGIBLE_STATUSES = [
52
53
  'failed',
53
54
  ];
54
55
  const versionKey = (name, version) => `${name}@${version}`;
56
+ /**
57
+ * First checkpoint seq reserved for saga compensations: compensation `idx` (registration order)
58
+ * persists at `COMPENSATION_SEQ_BASE - idx` (-2, -3, …). Negative so it can never collide with the
59
+ * positional replay's `>= 0` seqs, and starting at -2 so it also never collides with the legacy
60
+ * un-checkpointed `-1` undo dispatches of older engine versions.
61
+ */
62
+ const COMPENSATION_SEQ_BASE = -2;
55
63
  /**
56
64
  * The routing token a dispatched step lands on: the step's own (broker-safe) name, suffixed by the
57
65
  * tenant the RUN belongs to. `runNamespace` — not the executing engine's namespace — is the tenant
@@ -115,6 +123,18 @@ export class WorkflowEngine {
115
123
  /** Establish the originating context ambiently around a local step body (see {@link WorkflowEngineDeps.rehydrate}). Default passthrough. */
116
124
  rehydrate;
117
125
  compensationRetries;
126
+ compensationTimeoutMs;
127
+ retention;
128
+ // -Infinity so the FIRST sweep always runs (a fake/test clock may start near 0).
129
+ #lastRetentionSweepAt = Number.NEGATIVE_INFINITY;
130
+ /** Archival hooks run (awaited, in order) before a retention eviction deletes a run. */
131
+ evictListeners = new Set();
132
+ stalledAfterMs;
133
+ /** Pager hooks for the stranded-run signature (see {@link onStalled}). */
134
+ stalledListeners = new Set();
135
+ /** `runId → run.updatedAt ms` at last stalled notification, so one episode pages once. */
136
+ #notifiedStalled = new Map();
137
+ #lastStalledSweepAt = Number.NEGATIVE_INFINITY;
118
138
  /** Persist a `running` checkpoint at the start of a local step body (see {@link WorkflowEngineDeps.trackStepStart}). */
119
139
  trackStepStart;
120
140
  /** Where a freshly-started run executes — in-process by default (see {@link RunDispatcher}). */
@@ -163,6 +183,10 @@ export class WorkflowEngine {
163
183
  updateValidators = new Map();
164
184
  /** Runs being cancelled WITH saga compensation — see `cancel({ compensate: true })`. */
165
185
  cancelRequested = new Set();
186
+ /** The merged schedule set the worker loop ticks with (see {@link adoptSchedules}). */
187
+ #allSchedules = [];
188
+ /** Runtime pause overrides by schedule key (console-issued; win over the config's `paused`). */
189
+ #schedulePauseOverrides = new Map();
166
190
  /** Flow-control admission backend for remote steps (see {@link registerQueue}). */
167
191
  admission;
168
192
  /** Runs on THIS instance blocked on admission, by queue — woken early on a freed-slot signal. */
@@ -171,6 +195,14 @@ export class WorkflowEngine {
171
195
  stepQueue = new Map();
172
196
  /** Executions currently in flight, so a graceful shutdown can wait for them to settle. */
173
197
  inflight = new Map();
198
+ /**
199
+ * Runs whose recovery lease this instance LOST while their turn was still executing — a renew
200
+ * came back false (another instance took over after expiry), or drain released the lease on
201
+ * timeout. A fenced turn must not settle: its outcome was computed by a now-zombie executor, and
202
+ * the new owner is (re)executing the same run — writing would clobber/interleave with the owner's
203
+ * writes. `settleRun` checks this and degrades to an echo; `execute`'s finally clears the mark.
204
+ */
205
+ leaseLostRuns = new Set();
174
206
  /**
175
207
  * Post-settle side effects (parent notify, singleton wake) fired fire-and-forget AFTER a run's
176
208
  * status was persisted. They run OFF the `execute()` path — the caller of `execute()` must never
@@ -229,6 +261,9 @@ export class WorkflowEngine {
229
261
  // Default passthrough: with no bridge supplied, a local step body runs exactly as before.
230
262
  this.rehydrate = deps.rehydrate ?? ((_carrier, fn) => fn());
231
263
  this.compensationRetries = Math.max(1, deps.compensationRetries ?? 1);
264
+ this.compensationTimeoutMs = Math.max(1_000, deps.compensationTimeoutMs ?? 300_000);
265
+ this.retention = deps.retention;
266
+ this.stalledAfterMs = Math.max(60_000, deps.stalledAfterMs ?? 900_000);
232
267
  this.trackStepStart = deps.trackStepStart ?? true;
233
268
  // Default: execute the run on this instance, asynchronously, so `start` never blocks on the body.
234
269
  // A failed pickup is swallowed here (the run stays `pending` for a `runPending` poll to retry);
@@ -291,6 +326,11 @@ export class WorkflowEngine {
291
326
  else if (msg.kind === 'cancel') {
292
327
  this.notifyCancelled(msg.runId);
293
328
  }
329
+ else if (msg.kind === 'schedulePause') {
330
+ // A console on another instance paused/resumed a schedule — apply it here so THIS ticker
331
+ // honors it too (runtime-only; a deploy resets to the config).
332
+ this.#schedulePauseOverrides.set(msg.key, msg.paused);
333
+ }
294
334
  else if (msg.kind === 'enqueued') {
295
335
  // A run was enqueued on another instance — let worker subscribers pick it up immediately
296
336
  // instead of waiting for the next poll. (Self-broadcasts are already filtered above.)
@@ -322,6 +362,83 @@ export class WorkflowEngine {
322
362
  * and fires. Idempotent per `key`: the first schedule registered for a key wins and a later duplicate
323
363
  * key is ignored with a warning (so re-scanning, or a config entry overriding it, can't double-fire).
324
364
  */
365
+ /**
366
+ * Adopt the FULL schedule set the worker loop ticks with (config + discovered, already merged) so
367
+ * the runtime schedule-control surface (list/pause/resume/trigger) operates on exactly what fires.
368
+ * Called by `runTick`; idempotent.
369
+ */
370
+ adoptSchedules(schedules) {
371
+ this.#allSchedules = schedules;
372
+ }
373
+ /**
374
+ * The runtime pause override for a schedule key: `true`/`false` when the console set one (wins
375
+ * over the config's `paused`), `undefined` when untouched. Runtime-only — a deploy resets it.
376
+ */
377
+ schedulePauseOverride(key) {
378
+ return this.#schedulePauseOverrides.get(key);
379
+ }
380
+ /**
381
+ * The schedules this engine ticks, with their live control state and fire windows — what a
382
+ * console's Schedules tab lists. `paused` reflects the EFFECTIVE state (runtime override, else
383
+ * config); `lastRunStatus` is the current window's run, when it exists.
384
+ */
385
+ async listSchedules(now = this.clock()) {
386
+ const out = [];
387
+ for (const s of [...this.#allSchedules, ...this.#discoveredSchedules]) {
388
+ if (out.some((x) => x.key === s.key))
389
+ continue; // adopted set already contains discovered ones post-merge
390
+ const window = scheduleWindow(s, now);
391
+ const currentRun = await this.store.getRun(window.runId).catch(() => null);
392
+ const override = this.#schedulePauseOverrides.get(s.key);
393
+ out.push({
394
+ key: s.key,
395
+ workflow: s.workflow,
396
+ cron: s.cron,
397
+ everyMs: s.everyMs,
398
+ timezone: s.timezone,
399
+ overlap: s.overlap,
400
+ namespace: s.namespace,
401
+ paused: override ?? s.paused ?? false,
402
+ pausedAtRuntime: override !== undefined,
403
+ lastFireAt: window.lastFireAt,
404
+ nextFireAt: window.nextFireAt,
405
+ currentWindowRunId: window.runId,
406
+ lastRunStatus: currentRun?.status,
407
+ });
408
+ }
409
+ return out;
410
+ }
411
+ /**
412
+ * Pause/resume a schedule at RUNTIME, fleet-wide: applied on this instance and broadcast on the
413
+ * control plane so every ticking peer applies it too. Runtime-only state — a deploy resets it to
414
+ * the config's `paused`; pin the config for permanence. Returns false for an unknown key.
415
+ */
416
+ setSchedulePaused(key, paused) {
417
+ const known = this.#allSchedules.some((s) => s.key === key) ||
418
+ this.#discoveredSchedules.some((s) => s.key === key);
419
+ if (!known)
420
+ return false;
421
+ this.#schedulePauseOverrides.set(key, paused);
422
+ if (this.controlPlane) {
423
+ void this.controlPlane
424
+ .publishControl({ kind: 'schedulePause', key, paused, from: this.instanceId })
425
+ .catch(() => undefined);
426
+ }
427
+ return true;
428
+ }
429
+ /**
430
+ * Fire a schedule's CURRENT window now (console "run now"). Idempotent by the window's
431
+ * deterministic run id — triggering a window that already fired returns the existing run instead
432
+ * of forking a duplicate. Returns null for an unknown key.
433
+ */
434
+ async triggerSchedule(key, now = this.clock()) {
435
+ const s = this.#allSchedules.find((x) => x.key === key) ??
436
+ this.#discoveredSchedules.find((x) => x.key === key);
437
+ if (!s)
438
+ return null;
439
+ const window = scheduleWindow(s, now);
440
+ return this.start(s.workflow, s.input, window.runId, { namespace: s.namespace });
441
+ }
325
442
  registerSchedules(schedules) {
326
443
  const seen = new Set(this.#discoveredSchedules.map((s) => s.key));
327
444
  for (const schedule of schedules) {
@@ -387,6 +504,7 @@ export class WorkflowEngine {
387
504
  version,
388
505
  fn,
389
506
  tags: opts?.tags,
507
+ origin: opts?.origin,
390
508
  singleton: opts?.singleton,
391
509
  executionTimeoutMs: opts?.executionTimeout != null ? parseDuration(opts.executionTimeout) : undefined,
392
510
  validateInput: opts?.validateInput,
@@ -620,6 +738,13 @@ export class WorkflowEngine {
620
738
  // the same id is a no-op, returning the existing run's state instead of starting a duplicate.
621
739
  const prior = await this.store.getRun(runId);
622
740
  if (prior) {
741
+ // A PENDING prior still needs an executor: re-nudge the dispatcher so a redelivered trigger —
742
+ // or a continuation persisted pre-settle (see the ContinueAsNew handoff) picked up by a custom
743
+ // dispatcher — doesn't sit `pending` until the next poll. Idempotent: execution is leased, so
744
+ // a duplicate dispatch is a cheap no-op at the worker.
745
+ if (dispatch && prior.status === 'pending') {
746
+ await this.runDispatcher.dispatch(runId);
747
+ }
623
748
  return { runId, status: prior.status, output: prior.output, error: prior.error };
624
749
  }
625
750
  const now = new Date();
@@ -633,11 +758,19 @@ export class WorkflowEngine {
633
758
  if (registered.singleton) {
634
759
  await this.singletons.assertCapacity(name, registered.singleton, input);
635
760
  }
761
+ // Delayed start: park the run on its durable wake timer instead of dispatching now (see
762
+ // StartOptions.startAt). The timer poller path that wakes sleeps does the rest.
763
+ const startAtMs = opts?.startAt instanceof Date ? opts.startAt.getTime() : opts?.startAt;
764
+ // Compared against the ENGINE clock (injectable), not wall time — the same clock the timer
765
+ // poller will use to decide the run is due.
766
+ const delayed = startAtMs !== undefined && startAtMs > this.clock();
636
767
  const run = {
637
768
  id: runId,
638
769
  workflow: name,
639
770
  workflowVersion: registered.version,
640
- status: 'pending',
771
+ status: delayed ? 'suspended' : 'pending',
772
+ ...(delayed ? { wakeAt: startAtMs } : {}),
773
+ origin: opts?.origin ?? registered.origin,
641
774
  namespace: opts?.namespace ?? this.namespace,
642
775
  input,
643
776
  tags,
@@ -646,11 +779,27 @@ export class WorkflowEngine {
646
779
  createdAt: now,
647
780
  updatedAt: now,
648
781
  };
649
- await this.store.createRun(run);
782
+ try {
783
+ await this.store.createRun(run);
784
+ }
785
+ catch (err) {
786
+ // Lost a true createRun RACE (two pods scheduler-ticking the same window, concurrent
787
+ // `signalWithStart`s for one entity id): the read-before-create above saw no prior, but the
788
+ // store's duplicate-id rejection says someone else won the insert in the gap. Converge on the
789
+ // winner — re-read and report ITS state, exactly like the prior-run branch — instead of
790
+ // surfacing a unique-constraint error (which would abort a scheduler tick, or worse, lose the
791
+ // signal half of a signalWithStart). A re-read that finds nothing means the throw was a real
792
+ // store failure: rethrow it.
793
+ const winner = await this.store.getRun(runId);
794
+ if (winner) {
795
+ return { runId, status: winner.status, output: winner.output, error: winner.error };
796
+ }
797
+ throw err;
798
+ }
650
799
  // The run is durably enqueued; a dispatcher (in-process by default) executes it — `start` does
651
800
  // NOT run the body inline. Await the terminal/suspended state with `waitForRun(runId)` if needed.
652
801
  // An internal in-process handoff persists with `dispatch: false` and drives the pickup itself.
653
- if (dispatch) {
802
+ if (dispatch && !delayed) {
654
803
  await this.runDispatcher.dispatch(runId);
655
804
  // Nudge worker instances to pick it up now instead of on their next poll (no-op without a control
656
805
  // plane; self-receipt is filtered, so it only helps OTHER pods — e.g. an API pod's enqueue).
@@ -660,7 +809,7 @@ export class WorkflowEngine {
660
809
  .catch(() => undefined);
661
810
  }
662
811
  }
663
- return { runId, status: 'pending' };
812
+ return { runId, status: run.status };
664
813
  }
665
814
  /**
666
815
  * Bridge an INTERNAL run handoff — the next execution of a `continue-as-new`, or a deferred child
@@ -745,6 +894,142 @@ export class WorkflowEngine {
745
894
  * deleted (the parent→children edge is read from them). Use for retention/cleanup; this is a
746
895
  * destructive store operation, not a cancellation — a running run should be {@link cancel}led first.
747
896
  */
897
+ /**
898
+ * Register a pager hook for STRANDED runs — the failure `docs/reliability/failure-modes.mdx` calls
899
+ * "the stranded signature": an in-flight run untouched past `stalledAfterMs` that is either
900
+ * suspended with NO wake timer (wake-forever) or parked on an old pending remote step whose worker
901
+ * heartbeat has gone silent. The worker tick sweeps for these (throttled to one pass a minute) and
902
+ * notifies each episode ONCE (re-notifies only after the run progresses and strands again). Wire
903
+ * your alerting here exactly like {@link onDead}. Returns an unsubscribe fn.
904
+ */
905
+ onStalled(listener) {
906
+ this.stalledListeners.add(listener);
907
+ return () => this.stalledListeners.delete(listener);
908
+ }
909
+ /**
910
+ * Detect stranded in-flight runs and notify {@link onStalled} listeners. Self-throttled to one
911
+ * pass a minute; a no-op without listeners. Returns the runs notified THIS pass.
912
+ */
913
+ async sweepStalled(now = this.clock()) {
914
+ if (this.stalledListeners.size === 0 || this.draining)
915
+ return [];
916
+ if (now - this.#lastStalledSweepAt < 60_000)
917
+ return [];
918
+ this.#lastStalledSweepAt = now;
919
+ const candidates = await this.store.listRuns({
920
+ statuses: ['suspended', 'running'],
921
+ namespace: this.namespace,
922
+ updatedBefore: now - this.stalledAfterMs,
923
+ limit: 100,
924
+ });
925
+ const notified = [];
926
+ for (const run of candidates) {
927
+ // One notification per episode: skip a run we already paged about that hasn't moved since.
928
+ if (this.#notifiedStalled.get(run.id) === run.updatedAt.getTime())
929
+ continue;
930
+ // The stranded signature, disambiguated from healthy long work:
931
+ // (a) suspended with NO wake timer — nothing will ever re-drive it (reconcileMs: 0 deployments);
932
+ // (b) an old pending REMOTE step whose worker heartbeat is silent — dispatched and lost.
933
+ // A long `ctx.sleep` (wakeAt in the future) and a beating long step are NOT stalled.
934
+ let stalePending = null;
935
+ for (const cp of await this.store.listCheckpoints(run.id)) {
936
+ if (cp.kind !== 'remote' || cp.status !== 'pending')
937
+ continue;
938
+ const ageMs = now - cp.enqueuedAt.getTime();
939
+ const heartbeatAgeMs = cp.lastHeartbeatAt ? now - cp.lastHeartbeatAt.getTime() : null;
940
+ if (ageMs < this.stalledAfterMs)
941
+ continue;
942
+ if (heartbeatAgeMs !== null && heartbeatAgeMs < this.stalledAfterMs)
943
+ continue; // alive mid-flight
944
+ if (!stalePending || ageMs > stalePending.ageMs) {
945
+ stalePending = {
946
+ seq: cp.seq,
947
+ name: cp.name,
948
+ attempts: cp.attempts,
949
+ ageMs,
950
+ heartbeatAgeMs,
951
+ };
952
+ }
953
+ }
954
+ const wakeForever = run.status === 'suspended' && run.wakeAt === undefined;
955
+ if (!wakeForever && !stalePending)
956
+ continue;
957
+ const info = {
958
+ run,
959
+ ageMs: now - run.updatedAt.getTime(),
960
+ stalePending,
961
+ wakeForever,
962
+ };
963
+ this.#notifiedStalled.set(run.id, run.updatedAt.getTime());
964
+ if (this.#notifiedStalled.size > 1_000)
965
+ this.#notifiedStalled.clear(); // bounded; re-pages at worst
966
+ for (const listener of this.stalledListeners) {
967
+ try {
968
+ listener(info);
969
+ }
970
+ catch {
971
+ /* a pager hook must not break the sweep */
972
+ }
973
+ }
974
+ notified.push(info);
975
+ }
976
+ return notified;
977
+ }
978
+ /**
979
+ * Register an archival hook run before a retention eviction deletes a run (it receives the run
980
+ * and its full checkpoint timeline — e.g. write them to S3/file). Awaited in registration order;
981
+ * a hook that THROWS skips that run's deletion, so a broken archive never loses data. Returns an
982
+ * unsubscribe fn.
983
+ */
984
+ onEvict(listener) {
985
+ this.evictListeners.add(listener);
986
+ return () => this.evictListeners.delete(listener);
987
+ }
988
+ /**
989
+ * Retention sweep: hard-delete terminal runs whose last activity is older than the configured age
990
+ * (see {@link WorkflowEngineDeps.retention}). Self-throttled to one pass per minute per instance —
991
+ * call it every tick, it's a no-op most of the time. Returns how many runs were deleted (subtree
992
+ * counts included). Bounded per pass (100 candidates per status); a large backlog drains across
993
+ * passes.
994
+ *
995
+ * Caveat: eviction cascades DOWN (a deleted run takes its child subtree) but does not know about
996
+ * a still-live PARENT awaiting a custom-id child — size the ages well beyond your longest-running
997
+ * parents.
998
+ */
999
+ async sweepRetention(now = this.clock()) {
1000
+ if (!this.retention || this.draining)
1001
+ return 0;
1002
+ if (now - this.#lastRetentionSweepAt < 60_000)
1003
+ return 0;
1004
+ this.#lastRetentionSweepAt = now;
1005
+ let evicted = 0;
1006
+ for (const status of ['completed', 'failed', 'cancelled', 'dead']) {
1007
+ const maxAgeMs = this.retention[status];
1008
+ if (maxAgeMs == null || maxAgeMs <= 0)
1009
+ continue;
1010
+ const candidates = await this.store.listRuns({
1011
+ statuses: [status],
1012
+ namespace: this.namespace,
1013
+ updatedBefore: now - maxAgeMs,
1014
+ limit: 100,
1015
+ });
1016
+ for (const run of candidates) {
1017
+ try {
1018
+ if (this.evictListeners.size > 0) {
1019
+ const checkpoints = await this.store.listCheckpoints(run.id);
1020
+ for (const listener of this.evictListeners)
1021
+ await listener(run, checkpoints);
1022
+ }
1023
+ evicted += await this.deleteRun(run.id);
1024
+ }
1025
+ catch (err) {
1026
+ // An archival hook (or the delete itself) failed — keep the run; the next sweep retries.
1027
+ console.warn(`[adonis-durable] retention eviction of run ${run.id} skipped: ${err instanceof Error ? err.message : String(err)}`);
1028
+ }
1029
+ }
1030
+ }
1031
+ return evicted;
1032
+ }
748
1033
  async deleteRun(runId) {
749
1034
  const run = await this.store.getRun(runId);
750
1035
  if (!run)
@@ -755,6 +1040,17 @@ export class WorkflowEngine {
755
1040
  deleted += await this.deleteRun(childId);
756
1041
  }
757
1042
  await this.store.deleteRun(runId);
1043
+ // Sweep this run's token-keyed buffered signals too — they are not run-scoped rows, so the
1044
+ // store's deleteRun can't cascade them. Without this every fire-and-forget child that was never
1045
+ // joined leaks its buffered `child:<id>` completion FOREVER (one row per spawn), and a raced
1046
+ // compensate-cancel can leave a `cancel:<id>` marker behind.
1047
+ for (const token of [`child:${runId}`, `cancel:${runId}`]) {
1048
+ for (;;) {
1049
+ const buffered = await this.store.takeBufferedSignal(token).catch(() => null);
1050
+ if (!buffered)
1051
+ break;
1052
+ }
1053
+ }
758
1054
  return deleted + 1;
759
1055
  }
760
1056
  async resume(runId) {
@@ -775,7 +1071,7 @@ export class WorkflowEngine {
775
1071
  if (this.namespace !== undefined &&
776
1072
  run.namespace !== undefined &&
777
1073
  run.namespace !== this.namespace) {
778
- await this.store.releaseRunLock(runId);
1074
+ await this.store.releaseRunLock(runId, this.instanceId);
779
1075
  throw new NamespaceMismatch();
780
1076
  }
781
1077
  // Pin to the version the run STARTED on — replay is positional, so running a changed
@@ -793,6 +1089,24 @@ export class WorkflowEngine {
793
1089
  }
794
1090
  return this.track(runId, this.execute(run, registered.fn));
795
1091
  }
1092
+ /**
1093
+ * Deferred re-drive after a suspended settle whose waited checkpoint(s) already settled — the
1094
+ * delivery's own resume no-oped against this turn's still-held lease (see the WorkflowSuspended
1095
+ * catch in runExecution). Deferred one macrotask so the caller's `finally` releases the lease
1096
+ * first; tracked so `drain()` waits for the re-driven turn's writes.
1097
+ */
1098
+ recheckWaitSeqs(runId, seqs) {
1099
+ this.trackEffect((async () => {
1100
+ await new Promise((r) => setTimeout(r, 0));
1101
+ for (const seq of seqs) {
1102
+ const cp = await this.store.getCheckpoint(runId, seq).catch(() => null);
1103
+ if (cp && (cp.status === 'completed' || cp.status === 'failed')) {
1104
+ await this.resume(runId).catch(() => undefined);
1105
+ return;
1106
+ }
1107
+ }
1108
+ })());
1109
+ }
796
1110
  /** Track an in-flight execution so {@link drain} can wait for it. */
797
1111
  track(runId, p) {
798
1112
  this.inflight.set(p, runId);
@@ -860,7 +1174,11 @@ export class WorkflowEngine {
860
1174
  async releaseInflightLocks() {
861
1175
  for (const runId of [...this.inflight.values()]) {
862
1176
  try {
863
- await this.store.releaseRunLock(runId);
1177
+ // Owner-scoped: only clear leases THIS instance holds. Also fence the still-running turn —
1178
+ // the next pod may re-lease immediately, so this turn's eventual settle must become a no-op
1179
+ // echo instead of clobbering whatever the new owner writes (see settleRun's guard).
1180
+ this.leaseLostRuns.add(runId);
1181
+ await this.store.releaseRunLock(runId, this.instanceId);
864
1182
  }
865
1183
  catch {
866
1184
  /* best-effort: drain() must never throw because of this */
@@ -876,21 +1194,24 @@ export class WorkflowEngine {
876
1194
  for (const reg of new Set(this.latest.values())) {
877
1195
  if (reg.executionTimeoutMs == null)
878
1196
  continue;
879
- const deadline = now - reg.executionTimeoutMs;
880
- const inflight = [
881
- ...(await this.store.listRuns({
882
- workflow: reg.name,
883
- status: 'running',
884
- namespace: this.namespace,
885
- })),
886
- ...(await this.store.listRuns({
887
- workflow: reg.name,
888
- status: 'suspended',
889
- namespace: this.namespace,
890
- })),
891
- ];
1197
+ // Push the age predicate into the store: only candidates already past the LATEST version's
1198
+ // timeout come back (bounded), instead of every in-flight run of the workflow per tick. A
1199
+ // run pinned to an older version with a SHORTER timeout is swept a little later (when it
1200
+ // crosses this bound) — the per-run check below still applies its own version's timeout.
1201
+ const inflight = await this.store.listRuns({
1202
+ workflow: reg.name,
1203
+ statuses: ['running', 'suspended'],
1204
+ namespace: this.namespace,
1205
+ createdBefore: now - reg.executionTimeoutMs,
1206
+ limit: 200,
1207
+ });
892
1208
  for (const run of inflight) {
893
- if (run.createdAt.getTime() > deadline)
1209
+ // Time out against the version the run STARTED on when that registration is known — a fleet
1210
+ // mid-deploy must not sweep old-version runs by the newest version's (possibly shorter)
1211
+ // timeout. Falls back to the latest registration's timeout for an unknown version.
1212
+ const timeoutMs = this.workflows.get(versionKey(run.workflow, run.workflowVersion))?.executionTimeoutMs ??
1213
+ reg.executionTimeoutMs;
1214
+ if (timeoutMs == null || run.createdAt.getTime() > now - timeoutMs)
894
1215
  continue;
895
1216
  const error = { message: 'execution timeout', code: 'execution_timeout' };
896
1217
  // Conditional on the exact statuses just queried: an await separates this loop's iterations
@@ -911,6 +1232,20 @@ export class WorkflowEngine {
911
1232
  namespace: run.namespace,
912
1233
  error,
913
1234
  });
1235
+ // A sweep is a cancellation — give it `cancel()`'s full semantics, not just the status write:
1236
+ // cascade to the child subtree (they'd otherwise keep running, orphaned but live), tell the
1237
+ // instance/worker actually executing the run to abort cooperatively, wake a waiting parent
1238
+ // (it consumes this like a failed child instead of hanging to its reconcile), and free a
1239
+ // singleton slot.
1240
+ await this.cancelChildren(run.id, undefined);
1241
+ this.notifyCancelled(run.id);
1242
+ if (this.controlPlane) {
1243
+ void this.controlPlane
1244
+ .publishControl({ kind: 'cancel', runId: run.id, from: this.instanceId })
1245
+ .catch(() => undefined);
1246
+ }
1247
+ this.notifyParent(run.id, { ok: false, error: error.message });
1248
+ this.trackEffect(this.singletons.wakeNext(run));
914
1249
  }
915
1250
  }
916
1251
  }
@@ -922,7 +1257,17 @@ export class WorkflowEngine {
922
1257
  if (this.draining)
923
1258
  return [];
924
1259
  const results = [];
925
- const incomplete = await asHeartbeat(() => this.store.listIncompleteRuns(this.namespace));
1260
+ // Ask the store for ORPHANS only (running + free/expired lease, bounded): on a healthy fleet
1261
+ // that's ~zero rows per tick, instead of fetching every running run and issuing one doomed
1262
+ // lock-probe UPDATE per row per worker per second. Falls back to the old full-list + in-process
1263
+ // filter for a custom store without the optimized method.
1264
+ const incomplete = await asHeartbeat(async () => {
1265
+ const nowMs = this.clock();
1266
+ if (this.store.listOrphanedRuns) {
1267
+ return this.store.listOrphanedRuns(nowMs, 100, this.namespace);
1268
+ }
1269
+ return (await this.store.listIncompleteRuns(this.namespace)).filter((r) => r.lockedUntil === undefined || r.lockedUntil <= nowMs);
1270
+ });
926
1271
  for (const run of incomplete) {
927
1272
  // A live worker renews its lease, so an acquirable lease means the run is genuinely orphaned
928
1273
  // (its worker crashed). Skip the ones still owned.
@@ -937,8 +1282,20 @@ export class WorkflowEngine {
937
1282
  }
938
1283
  // Re-enqueue rather than resume inline: recovery must NOT block (boot, or a poll tick) on a
939
1284
  // long workflow step. A dispatcher/worker re-runs it, replaying its checkpoints.
940
- await this.store.releaseRunLock(run.id);
941
- await this.store.updateRun(run.id, { status: 'pending', updatedAt: new Date() });
1285
+ //
1286
+ // Conditional on still-`running`, WHILE the lease is held: a redelivered worker result (or any
1287
+ // other resume path) could have driven this run to a terminal state between the listing above
1288
+ // and here — an unconditional write would clobber `completed` back to `pending` and re-execute
1289
+ // the whole run (double `run.completed`, double parent notify). Same reasoning as every settle
1290
+ // path's SETTLE_ELIGIBLE_STATUSES guard. The lock is released only AFTER the flip (owner-scoped,
1291
+ // so a takeover can't be wiped), closing the release→flip gap the old order left open.
1292
+ const applied = await this.store.updateRunIf(run.id, ['running'], {
1293
+ status: 'pending',
1294
+ updatedAt: new Date(),
1295
+ });
1296
+ await this.store.releaseRunLock(run.id, this.instanceId);
1297
+ if (!applied)
1298
+ continue;
942
1299
  await this.runDispatcher.dispatch(run.id);
943
1300
  results.push({ runId: run.id, status: 'pending' });
944
1301
  }
@@ -957,7 +1314,7 @@ export class WorkflowEngine {
957
1314
  code: 'max_recovery_attempts',
958
1315
  };
959
1316
  await this.store.updateRun(run.id, { status: 'dead', error, updatedAt: new Date() });
960
- await this.store.releaseRunLock(run.id);
1317
+ await this.store.releaseRunLock(run.id, this.instanceId);
961
1318
  this.emit({
962
1319
  type: 'run.failed',
963
1320
  runId: run.id,
@@ -980,7 +1337,8 @@ export class WorkflowEngine {
980
1337
  * not due re-suspends cheaply without running new work.
981
1338
  */
982
1339
  async resumeDueTimers(nowMs = this.clock()) {
983
- const due = await asHeartbeat(() => this.store.listDueTimers(nowMs, this.namespace));
1340
+ // Capped per tick (a backlog drains over several polls) — a store may ignore the cap.
1341
+ const due = await asHeartbeat(() => this.store.listDueTimers(nowMs, this.namespace, 200));
984
1342
  const blocked = await this.dueBlockedRuns(nowMs);
985
1343
  return this.resumeLeased([...due, ...blocked], nowMs);
986
1344
  }
@@ -1210,10 +1568,15 @@ export class WorkflowEngine {
1210
1568
  resolve(toResult(run));
1211
1569
  };
1212
1570
  const check = () => {
1213
- void this.store.getRun(runId).then((run) => {
1571
+ // Swallow a transient store error: the next settling event re-checks, and the timeout still
1572
+ // bounds the wait. Unhandled, one hiccup here was a process-fatal unhandled rejection.
1573
+ void this.store
1574
+ .getRun(runId)
1575
+ .then((run) => {
1214
1576
  if (run && shouldResolve(run.status))
1215
1577
  finish(run);
1216
- });
1578
+ })
1579
+ .catch(() => undefined);
1217
1580
  };
1218
1581
  // React only to this run's settling events (not its every step event), and subscribe BEFORE the
1219
1582
  // initial read so a run that settles in between isn't missed.
@@ -1234,14 +1597,26 @@ export class WorkflowEngine {
1234
1597
  if (this.draining)
1235
1598
  return []; // shutting down — don't pick up new runs
1236
1599
  const results = [];
1237
- for (const run of runs) {
1238
- const acquired = await this.store.tryLockRun(run.id, this.instanceId, nowMs + this.leaseMs, nowMs);
1239
- if (!acquired)
1240
- continue;
1241
- // A per-run hook (recovery counting / dead-lettering) may settle the run terminally instead.
1242
- const settled = onLocked ? await onLocked(run) : undefined;
1243
- results.push(settled ?? (await this.resume(run.id)));
1244
- }
1600
+ // Bounded parallelism: one slow turn (a long local step) must not serialize the other 99 picked
1601
+ // up this tick behind it. The lease acquire stays inside each task, so exactly-one-executor is
1602
+ // unchanged; 8 concurrent turns is enough to hide one straggler without stampeding the store.
1603
+ const CONCURRENCY = 8;
1604
+ let next = 0;
1605
+ const worker = async () => {
1606
+ for (;;) {
1607
+ const run = runs[next];
1608
+ next += 1;
1609
+ if (!run)
1610
+ return;
1611
+ const acquired = await this.store.tryLockRun(run.id, this.instanceId, nowMs + this.leaseMs, nowMs);
1612
+ if (!acquired)
1613
+ continue;
1614
+ // A per-run hook (recovery counting / dead-lettering) may settle the run terminally instead.
1615
+ const settled = onLocked ? await onLocked(run) : undefined;
1616
+ results.push(settled ?? (await this.resume(run.id)));
1617
+ }
1618
+ };
1619
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, runs.length) }, () => worker()));
1245
1620
  return results;
1246
1621
  }
1247
1622
  /**
@@ -1420,7 +1795,14 @@ export class WorkflowEngine {
1420
1795
  notifyParent(runId, completion) {
1421
1796
  // Tracked, not bare fire-and-forget: `signal` can wake a suspended parent and resume it, issuing
1422
1797
  // store writes that must be awaited by `drain()` even though the settling child returned already.
1423
- this.trackEffect(this.signal(`child:${runId}`, completion));
1798
+ // One retry + a warning before giving up: a transiently-failed delivery here used to vanish into
1799
+ // trackEffect's swallow, stranding the parent until its reconcile wake. The parent's `ctx.child`
1800
+ // re-registration now also RE-DERIVES a terminal child's completion from the run row itself, so
1801
+ // a lost notify self-heals — but that costs a reconcile interval; don't lose it silently.
1802
+ const deliver = (token) => this.signal(token, completion).catch(() => this.signal(token, completion).catch((err) => {
1803
+ console.warn(`[adonis-durable] failed to deliver child completion on ${token} (the parent recovers on its next reconcile wake): ${err instanceof Error ? err.message : String(err)}`);
1804
+ }));
1805
+ this.trackEffect(deliver(`child:${runId}`));
1424
1806
  // A fix-and-replay run (`<origin>~retry~<hash>`) is standalone, but its SUCCESS is the
1425
1807
  // origin's outcome for all practical purposes: deliver it on the ORIGIN's token too, so a
1426
1808
  // parent that failed on that child and is retried later consumes this success (buffered or
@@ -1428,7 +1810,7 @@ export class WorkflowEngine {
1428
1810
  // fix attempt must not poison the origin's token.
1429
1811
  const at = runId.lastIndexOf('~retry~');
1430
1812
  if (at !== -1 && completion.ok) {
1431
- this.trackEffect(this.signal(`child:${runId.slice(0, at)}`, completion));
1813
+ this.trackEffect(deliver(`child:${runId.slice(0, at)}`));
1432
1814
  }
1433
1815
  }
1434
1816
  /**
@@ -1477,6 +1859,11 @@ export class WorkflowEngine {
1477
1859
  // (its lease acquire fails and it no-ops); the broadcast tells that worker to abort cooperatively.
1478
1860
  if (opts?.compensate && (run.status === 'suspended' || run.status === 'running')) {
1479
1861
  this.cancelRequested.add(runId);
1862
+ // DURABLE cancel intent: the in-memory flag above only survives on this pod, this boot. The
1863
+ // buffered `cancel:<id>` marker is what lets the compensating unwind survive a crash or be
1864
+ // picked up by ANOTHER pod (its resume replays into the WorkflowSuspended catch, which checks
1865
+ // the marker when the flag is absent). Consumed there once `cancelled` is durably recorded.
1866
+ await this.store.bufferSignal(`cancel:${runId}`, true).catch(() => undefined);
1480
1867
  if (this.controlPlane) {
1481
1868
  void this.controlPlane
1482
1869
  .publishControl({ kind: 'cancel', runId, from: this.instanceId })
@@ -1563,9 +1950,15 @@ export class WorkflowEngine {
1563
1950
  */
1564
1951
  async getRunChildren(parentRunId) {
1565
1952
  const childIds = new Set();
1566
- for (const w of await this.store.listSignalWaiters('child:')) {
1567
- if (w.runId === parentRunId)
1953
+ // Targeted per-run lookup when the store has it (an indexed `WHERE run_id`); the prefix scan
1954
+ // fallback walks EVERY `child:` waiter row for one parent.
1955
+ const waiters = this.store.listSignalWaitersByRunIds
1956
+ ? await this.store.listSignalWaitersByRunIds([parentRunId])
1957
+ : await this.store.listSignalWaiters('child:');
1958
+ for (const w of waiters) {
1959
+ if (w.runId === parentRunId && w.token.startsWith('child:')) {
1568
1960
  childIds.add(w.token.slice('child:'.length));
1961
+ }
1569
1962
  }
1570
1963
  // Targeted read: only the `signal:child:` / `spawn:` checkpoints, not the whole history. Falls
1571
1964
  // back to a full listCheckpoints + in-JS prefix scan for a custom store that omits the method.
@@ -1592,6 +1985,20 @@ export class WorkflowEngine {
1592
1985
  listSignalWaiters(prefix) {
1593
1986
  return this.store.listSignalWaiters(prefix);
1594
1987
  }
1988
+ /**
1989
+ * Targeted per-run waiter lookup (`run_id IN (...)`) for the dashboard's page-scoped `waiting`
1990
+ * stamp. Prefers the store's indexed method; falls back to filtering the full scan for a custom
1991
+ * store without it.
1992
+ */
1993
+ async listSignalWaitersByRunIds(runIds) {
1994
+ if (runIds.length === 0)
1995
+ return [];
1996
+ if (this.store.listSignalWaitersByRunIds) {
1997
+ return this.store.listSignalWaitersByRunIds(runIds);
1998
+ }
1999
+ const ids = new Set(runIds);
2000
+ return (await this.store.listSignalWaiters('')).filter((w) => ids.has(w.runId));
2001
+ }
1595
2002
  /** The worker groups this engine dispatches to: every registered remote workflow's group, plus any
1596
2003
  * `extra` the caller declares. Local-step groups (a group consumed by in-process `@DurableStep`
1597
2004
  * workers, e.g. `pipeline`) aren't derivable from registrations — pass them via `extra` so a group
@@ -1611,13 +2018,10 @@ export class WorkflowEngine {
1611
2018
  * (only the BullMQ transport implements `groupHealth`). */
1612
2019
  async workerHealth(extra = []) {
1613
2020
  const groups = new Set([...this.knownGroups(extra), ...(await this.pool.listWorkerGroups())]);
1614
- const out = [];
1615
- for (const group of groups) {
1616
- const health = await this.pool.groupHealth(group);
1617
- if (health)
1618
- out.push(health);
1619
- }
1620
- return out;
2021
+ // One round per group, in parallel — the dashboard polls this, and each group's health is an
2022
+ // independent SCAN + job-count query; serial rounds made /workers latency scale linearly.
2023
+ const healths = await Promise.all([...groups].map((group) => this.pool.groupHealth(group)));
2024
+ return healths.filter((h) => h != null);
1621
2025
  }
1622
2026
  /**
1623
2027
  * Persist a streamed local-step lifecycle event from a remote workflow worker (see
@@ -1779,8 +2183,17 @@ export class WorkflowEngine {
1779
2183
  }
1780
2184
  }
1781
2185
  const renew = setInterval(() => {
2186
+ // A renew that returns FALSE means the lease was taken over (this turn stalled past expiry
2187
+ // and another instance reclaimed the run) — this executor is now a zombie. Fence it: mark
2188
+ // the run so its eventual settle degrades to a no-op echo instead of clobbering the new
2189
+ // owner's writes. A renew that THROWS (transient store error) is not evidence of takeover,
2190
+ // so it stays swallowed — the next interval retries.
1782
2191
  void this.store
1783
2192
  .renewRunLock(run.id, this.instanceId, this.clock() + this.leaseMs)
2193
+ .then((renewed) => {
2194
+ if (!renewed)
2195
+ this.leaseLostRuns.add(run.id);
2196
+ })
1784
2197
  .catch(() => undefined);
1785
2198
  }, Math.max(50, Math.floor(this.leaseMs / 2)));
1786
2199
  renew.unref?.();
@@ -1805,6 +2218,7 @@ export class WorkflowEngine {
1805
2218
  }
1806
2219
  finally {
1807
2220
  clearInterval(renew);
2221
+ this.leaseLostRuns.delete(run.id);
1808
2222
  }
1809
2223
  }
1810
2224
  /**
@@ -1821,6 +2235,13 @@ export class WorkflowEngine {
1821
2235
  * keeps its own lease handling (the TS executor's `finally`, the remote path's caller).
1822
2236
  */
1823
2237
  async settleRun(run, outcome) {
2238
+ // Zombie fence: this turn's lease was lost mid-execution (renew returned false, or drain
2239
+ // released it on timeout) — another instance owns and is (re)executing the run. This outcome
2240
+ // was computed by a stale executor; report the store's current state instead of writing. The
2241
+ // status-CAS below would NOT catch this case: the new owner's run is legitimately back in a
2242
+ // settle-eligible status while it executes.
2243
+ if (this.leaseLostRuns.has(run.id))
2244
+ return this.echoCurrentStatus(run.id);
1824
2245
  const updatedAt = new Date();
1825
2246
  if (outcome.kind === 'completed') {
1826
2247
  // This outcome was computed by a turn that started from a possibly-stale run snapshot. If the
@@ -1848,8 +2269,15 @@ export class WorkflowEngine {
1848
2269
  return { runId: run.id, status: 'completed', output: outcome.output };
1849
2270
  }
1850
2271
  if (outcome.kind === 'failed') {
1851
- // Same race as the `completed` branch above — guard identically.
1852
- const applied = await this.store.updateRunIf(run.id, SETTLE_ELIGIBLE_STATUSES, {
2272
+ // Same race as the `completed` branch above — guard identically. ADDITIONALLY exclude
2273
+ // `failed`: a stray resume of an already-failed run (a redelivered signal, the settle-recheck
2274
+ // re-drive) replays and deterministically re-fails — re-applying the settle would re-emit
2275
+ // `run.failed` and RE-NOTIFY the parent, whose waiter the first notify already consumed, so
2276
+ // the duplicate gets BUFFERED as a stale failure that poisons a later retry's rendezvous
2277
+ // (observed: a cascade-requeued child's fresh success lost to the stale buffered failure). A
2278
+ // legitimate retry always transitions through `pending`/`running` first (requeue flips the
2279
+ // status), so it is unaffected.
2280
+ const applied = await this.store.updateRunIf(run.id, SETTLE_ELIGIBLE_STATUSES.filter((s) => s !== 'failed'), {
1853
2281
  status: 'failed',
1854
2282
  error: outcome.error,
1855
2283
  updatedAt,
@@ -2200,10 +2628,48 @@ export class WorkflowEngine {
2200
2628
  // dashboard renders the fan as one parallel group. Undefined for a lone (non-fan) child.
2201
2629
  parallelGroup: cmd.parallelGroup,
2202
2630
  });
2203
- if (!(await this.store.getRun(childId))) {
2204
- // Same inheritance rule as the in-process `ctx.child` path: the child belongs to the run
2205
- // that spawned it, not to whichever engine happened to advance that run.
2206
- this.startChildDeferred(cmd.workflow, cmd.input, childId, { namespace: run.namespace });
2631
+ // Lost-wake guard (mirrors the in-process `ctx.child`/`ctx.all`): the child may have ALREADY
2632
+ // finished while no waiter was registered — consume its buffered completion, or re-derive the
2633
+ // completion from the child's own terminal run row when even the buffer was lost (crash
2634
+ // between the child's terminal write and its parent notify). Without this the re-registered
2635
+ // waiter waits forever on a signal that already fired.
2636
+ const deliverChild = async (payload) => {
2637
+ await this.store.removeSignalWaiter({
2638
+ token: `child:${childId}`,
2639
+ runId: run.id,
2640
+ seq: cmd.seq,
2641
+ });
2642
+ await this.store.saveCheckpoint(instantCheckpoint({
2643
+ runId: run.id,
2644
+ seq: cmd.seq,
2645
+ name: `signal:child:${childId}`,
2646
+ kind: 'signal',
2647
+ output: payload,
2648
+ parallelGroup: cmd.parallelGroup,
2649
+ }));
2650
+ // Re-drive on a macrotask, AFTER this turn suspends and frees the run lock (same reorder
2651
+ // as the buffered `waitSignal` delivery above).
2652
+ setTimeout(() => void this.resume(run.id).catch(() => undefined), 0);
2653
+ };
2654
+ const bufferedChild = await this.store.takeBufferedSignal(`child:${childId}`);
2655
+ if (bufferedChild) {
2656
+ await deliverChild(bufferedChild.payload);
2657
+ }
2658
+ else {
2659
+ const child = await this.store.getRun(childId);
2660
+ if (!child) {
2661
+ // Same inheritance rule as the in-process `ctx.child` path: the child belongs to the run
2662
+ // that spawned it, not to whichever engine happened to advance that run.
2663
+ this.startChildDeferred(cmd.workflow, cmd.input, childId, { namespace: run.namespace });
2664
+ }
2665
+ else if (child.status === 'completed' ||
2666
+ child.status === 'failed' ||
2667
+ child.status === 'cancelled' ||
2668
+ child.status === 'dead') {
2669
+ await deliverChild(child.status === 'completed'
2670
+ ? { ok: true, value: child.output }
2671
+ : { ok: false, error: child.error?.message ?? child.status });
2672
+ }
2207
2673
  }
2208
2674
  }
2209
2675
  else {
@@ -2238,7 +2704,7 @@ export class WorkflowEngine {
2238
2704
  workflow: run.workflow,
2239
2705
  namespace: run.namespace,
2240
2706
  });
2241
- await this.store.releaseRunLock(run.id);
2707
+ await this.store.releaseRunLock(run.id, this.instanceId);
2242
2708
  return { runId: run.id, status: 'suspended' };
2243
2709
  }
2244
2710
  // Saga compensations registered by completed steps; run in reverse if the run later fails.
@@ -2262,9 +2728,22 @@ export class WorkflowEngine {
2262
2728
  }
2263
2729
  catch (err) {
2264
2730
  if (err instanceof ContinueAsNew) {
2265
- // Hand off to a fresh execution with a clean history: complete this run, then start the next
2266
- // (`<id>~N`) with the new input. Deferred + idempotent by the continuation id, so a crash
2267
- // mid-handoff re-derives the same next run instead of forking.
2731
+ // Hand off to a fresh execution with a clean history: persist the next run (`<id>~N`), then
2732
+ // complete this one. Zombie fence first — a fenced turn must not fork a continuation either.
2733
+ if (this.leaseLostRuns.has(run.id))
2734
+ return this.echoCurrentStatus(run.id);
2735
+ const nextId = nextContinuationId(run.id);
2736
+ // Persist the continuation BEFORE the parent's terminal write — the durable ordering that
2737
+ // makes the chain crash-safe. The old order (complete parent, then create the continuation
2738
+ // from an in-memory deferred start) left a window where a hard crash (SIGKILL/OOM) lost the
2739
+ // chain forever: a `completed` parent with no continuation is indistinguishable from a
2740
+ // normally-finished run, and no recovery path re-derives the next link (recoverIncomplete
2741
+ // only scans `running`). With the continuation persisted first, a crash in the gap leaves
2742
+ // the parent `running` — recovery replays it, ContinueAsNew re-throws, and this createRun
2743
+ // converges on the already-persisted continuation (idempotent by the continuation id).
2744
+ // `dispatch: false`: the tracked handoff below owns the pickup. The continuation inherits
2745
+ // the parent RUN's namespace so a tenant's chain stays in its worker-pool partition.
2746
+ await this.startCore(run.workflow, err.input, nextId, { namespace: run.namespace }, false);
2268
2747
  await this.store.updateRun(run.id, {
2269
2748
  status: 'completed',
2270
2749
  output: undefined,
@@ -2278,11 +2757,11 @@ export class WorkflowEngine {
2278
2757
  namespace: run.namespace,
2279
2758
  });
2280
2759
  this.notifyParent(run.id, { ok: true, value: undefined });
2281
- const nextId = nextContinuationId(run.id);
2282
2760
  // Tracked handoff (see {@link handoffRun}): held in `postSettle` from this settle until the
2283
2761
  // continuation has entered `inflight` and settled, so `drain()` can't return before the next
2284
- // run's store writes land. Still deferred + idempotent by the continuation id.
2285
- this.handoffRun(run.workflow, err.input, nextId);
2762
+ // run's store writes land. Still deferred; its startCore call converges on the run persisted
2763
+ // above (prior-run branch) and drives the pickup.
2764
+ this.handoffRun(run.workflow, err.input, nextId, { namespace: run.namespace });
2286
2765
  return { runId: run.id, status: 'completed' };
2287
2766
  }
2288
2767
  if (err instanceof WorkflowBlocked) {
@@ -2294,15 +2773,37 @@ export class WorkflowEngine {
2294
2773
  if (err instanceof WorkflowSuspended) {
2295
2774
  // A compensating cancel resumed this run to reach here: the replay re-registered the saga,
2296
2775
  // so undo the completed steps in reverse and mark it cancelled instead of re-suspending.
2297
- if (this.cancelRequested.has(run.id)) {
2298
- this.cancelRequested.delete(run.id);
2776
+ // The intent lives in TWO places: the in-memory `cancelRequested` fast path (same pod, same
2777
+ // boot), and the DURABLE buffered `cancel:<id>` marker `cancel()` persisted — so a crash or
2778
+ // a cross-pod handoff mid-cancellation can't silently forget the cancel and settle the run
2779
+ // back to `suspended` as if nothing happened. Peek the marker (take + re-buffer) rather than
2780
+ // consume it: it must survive a crash mid-unwind; it's drained below once `cancelled` is
2781
+ // durably recorded.
2782
+ let cancelling = this.cancelRequested.delete(run.id);
2783
+ if (!cancelling) {
2784
+ const marker = await this.store.takeBufferedSignal(`cancel:${run.id}`);
2785
+ if (marker) {
2786
+ cancelling = true;
2787
+ await this.store
2788
+ .bufferSignal(`cancel:${run.id}`, marker.payload)
2789
+ .catch(() => undefined);
2790
+ }
2791
+ }
2792
+ if (cancelling) {
2299
2793
  for (let i = compensations.length - 1; i >= 0; i -= 1) {
2300
2794
  const comp = compensations[i];
2301
2795
  if (comp)
2302
- await this.runCompensation(run, comp);
2796
+ await this.runCompensation(run, comp, i);
2303
2797
  }
2304
2798
  const error = { message: 'cancelled' };
2305
2799
  await this.store.updateRun(run.id, { status: 'cancelled', error, updatedAt: new Date() });
2800
+ // The cancellation is durably recorded — consume the marker(s) so a later retry of this
2801
+ // run id doesn't get spuriously re-cancelled.
2802
+ for (;;) {
2803
+ const m = await this.store.takeBufferedSignal(`cancel:${run.id}`).catch(() => null);
2804
+ if (!m)
2805
+ break;
2806
+ }
2306
2807
  this.emit({
2307
2808
  type: 'run.failed',
2308
2809
  runId: run.id,
@@ -2312,7 +2813,16 @@ export class WorkflowEngine {
2312
2813
  });
2313
2814
  return { runId: run.id, status: 'cancelled', error };
2314
2815
  }
2315
- return this.settleRun(run, { kind: 'suspended', wakeAt: err.wakeAt });
2816
+ const settled = await this.settleRun(run, { kind: 'suspended', wakeAt: err.wakeAt });
2817
+ // Close the settle-vs-delivery race: a signal/result that landed WHILE this turn was still
2818
+ // executing wrote its checkpoint durably, but its resume no-oped against our held lease —
2819
+ // and this settle just parked the run on nothing but the reconcile fallback (or nothing at
2820
+ // all with reconcileMs: 0). Re-check the seqs this suspension waits on and re-drive if one
2821
+ // already settled.
2822
+ if (settled.status === 'suspended' && err.waitSeqs?.length) {
2823
+ this.recheckWaitSeqs(run.id, err.waitSeqs);
2824
+ }
2825
+ return settled;
2316
2826
  }
2317
2827
  const error = {
2318
2828
  message: err instanceof Error ? err.message : String(err),
@@ -2326,14 +2836,15 @@ export class WorkflowEngine {
2326
2836
  const comp = compensations[i];
2327
2837
  if (!comp)
2328
2838
  continue;
2329
- await this.runCompensation(run, comp);
2839
+ await this.runCompensation(run, comp, i);
2330
2840
  }
2331
2841
  return this.settleRun(run, { kind: 'failed', error });
2332
2842
  }
2333
2843
  finally {
2334
2844
  // Release the recovery lease once the run reaches a terminal/suspended state, so the
2335
- // next instance (or the timer poller) can pick it up promptly.
2336
- await this.store.releaseRunLock(run.id);
2845
+ // next instance (or the timer poller) can pick it up promptly. Owner-scoped: if this turn's
2846
+ // lease was already taken over (zombie executor), the new owner's lease is left untouched.
2847
+ await this.store.releaseRunLock(run.id, this.instanceId);
2337
2848
  }
2338
2849
  }
2339
2850
  /**
@@ -2342,17 +2853,39 @@ export class WorkflowEngine {
2342
2853
  * compensation is skipped so it can't mask the original failure. Handles both compensation shapes:
2343
2854
  * a LOCAL `fn` (run in-process) and a DISPATCHED undo (an ordinary step def sent to a worker and
2344
2855
  * awaited inline — the worker serving its name runs it with the {@link StepUndo} envelope).
2345
- */
2346
- async runCompensation(run, comp) {
2856
+ *
2857
+ * DURABLE: each compensation owns a reserved NEGATIVE checkpoint seq (`-2 - idx`, disjoint from
2858
+ * the positional replay's `>= 0` space and the legacy `-1`), written `pending` before a dispatch
2859
+ * and settled on its outcome. A re-driven unwind — crash mid-unwind, a lease takeover replaying
2860
+ * the failing body back into this catch, a worker result consumed by another pod — therefore
2861
+ * SKIPS undos already done and RESUMES the attempt count, instead of re-running the whole unwind
2862
+ * blind from in-memory state that no longer exists.
2863
+ */
2864
+ async runCompensation(run, comp, idx) {
2347
2865
  const name = `compensate:${comp.name}`;
2348
- for (let attempt = 1; attempt <= this.compensationRetries; attempt += 1) {
2866
+ const seq = COMPENSATION_SEQ_BASE - idx;
2867
+ const existing = await this.store.getCheckpoint(run.id, seq);
2868
+ // Already undone by a previous drive of this unwind (possibly on another pod) — skip.
2869
+ if (existing?.status === 'completed')
2870
+ return;
2871
+ const priorAttempts = existing?.attempts ?? 0;
2872
+ // Permanently failed and already reported — skip (never mask the original failure).
2873
+ if (existing?.status === 'failed' && priorAttempts >= this.compensationRetries)
2874
+ return;
2875
+ // A `pending` row re-enters AT its recorded attempt (await the in-flight dispatch rather than
2876
+ // blindly re-issuing); a `failed` row resumes the count after its recorded attempts.
2877
+ const firstAttempt = existing?.status === 'pending' ? Math.max(1, priorAttempts) : priorAttempts + 1;
2878
+ for (let attempt = firstAttempt; attempt <= this.compensationRetries; attempt += 1) {
2349
2879
  const startedAt = Date.now();
2350
2880
  try {
2351
2881
  if ('fn' in comp) {
2352
2882
  await comp.fn();
2883
+ // Persist the completed undo so a re-driven unwind never re-runs it — crash-safe skipping
2884
+ // instead of leaning solely on the "compensations are idempotent" contract.
2885
+ await this.store.saveCheckpoint(instantCheckpoint({ runId: run.id, seq, name, kind: 'local' }));
2353
2886
  }
2354
2887
  else {
2355
- await this.dispatchCompensation(run, comp.dispatch.def, comp.dispatch.args, attempt);
2888
+ await this.dispatchCompensation(run, comp.dispatch.def, comp.dispatch.args, attempt, seq);
2356
2889
  }
2357
2890
  this.emit({
2358
2891
  type: 'step.completed',
@@ -2365,6 +2898,24 @@ export class WorkflowEngine {
2365
2898
  return;
2366
2899
  }
2367
2900
  catch (err) {
2901
+ // Record the failed attempt (best-effort) so a re-driven unwind resumes the count.
2902
+ const error = { message: err instanceof Error ? err.message : String(err) };
2903
+ const at = new Date();
2904
+ await this.store
2905
+ .saveCheckpoint({
2906
+ runId: run.id,
2907
+ seq,
2908
+ name,
2909
+ kind: 'fn' in comp ? 'local' : 'remote',
2910
+ stepId: `${run.id}:compensate:${comp.name}:${attempt}`,
2911
+ status: 'failed',
2912
+ attempts: attempt,
2913
+ error,
2914
+ enqueuedAt: existing?.enqueuedAt ?? at,
2915
+ startedAt: at,
2916
+ finishedAt: at,
2917
+ })
2918
+ .catch(() => undefined);
2368
2919
  if (attempt >= this.compensationRetries) {
2369
2920
  this.emit({
2370
2921
  type: 'step.failed',
@@ -2372,7 +2923,7 @@ export class WorkflowEngine {
2372
2923
  workflow: run.workflow,
2373
2924
  name,
2374
2925
  kind: 'local',
2375
- error: { message: err instanceof Error ? err.message : String(err) },
2926
+ error,
2376
2927
  durationMs: Date.now() - startedAt,
2377
2928
  });
2378
2929
  }
@@ -2380,42 +2931,146 @@ export class WorkflowEngine {
2380
2931
  }
2381
2932
  }
2382
2933
  /**
2383
- * Dispatch a saga undo step to a worker and await its result INLINE (the saga unwind already runs
2384
- * outside the positional replay, so this is not checkpointed — it's a one-shot dispatch + await).
2385
- * Routes by the undo def's own name/partition token, applies its own optional input schema, and
2386
- * honours its own liveness `timeoutMs` via the heartbeat window. Throws on a worker-reported failure
2934
+ * Dispatch a saga undo step to a worker and await its result. Routes by the undo def's own
2935
+ * name/partition token, applies its own optional input schema, and honours its own liveness
2936
+ * `timeoutMs` via the heartbeat window (else the engine's `compensationTimeoutMs` bounds the
2937
+ * wait, so a lost result can't hold the unwind open forever). Throws on a worker-reported failure
2387
2938
  * or timeout so `runCompensation`'s retry/skip logic applies uniformly to both compensation shapes.
2939
+ *
2940
+ * The undo is CHECKPOINTED at its reserved negative `seq` (pending before dispatch, settled on the
2941
+ * outcome), which is what makes the await multi-pod-safe: a result popped by ANOTHER engine
2942
+ * instance sharing the results queue lands in its `completeRemoteResult`, settles this row, and
2943
+ * the checkpoint poll below observes it — the old in-memory-only waiter simply dropped that result
2944
+ * (`getCheckpoint(runId, -1)` → null) and hung the failing run's unwind forever.
2388
2945
  */
2389
- async dispatchCompensation(run, def, args, attempt) {
2946
+ async dispatchCompensation(run, def, args, attempt, seq) {
2390
2947
  if (this.pool.size === 0)
2391
2948
  throw new Error('dispatched compensation requires a Transport');
2392
2949
  const token = stepGroup(def, run.namespace);
2393
2950
  const validInput = def.input ? def.input.parse(args) : args;
2394
2951
  const id = `${run.id}:compensate:${def.name}:${attempt}`;
2952
+ const name = `compensate:${def.name}`;
2953
+ const existing = await this.store.getCheckpoint(run.id, seq);
2395
2954
  const resultPromise = new Promise((resolve, reject) => {
2396
2955
  this.pending.set(id, { resolve, reject });
2397
2956
  });
2957
+ const enqueuedAt = new Date();
2398
2958
  try {
2399
- await this.pool.dispatch({
2400
- runId: run.id,
2401
- seq: -1,
2402
- name: def.name,
2403
- stepId: id,
2404
- group: token,
2405
- input: validInput,
2406
- traceparent: this.traceparent?.(),
2407
- context: this.context?.(),
2408
- attempt: 1,
2409
- });
2410
- await (def.timeoutMs
2959
+ // A pending row already at (or past) this attempt means the dispatch itself already happened
2960
+ // (crash after dispatch, or a re-drive while the worker is mid-undo): re-await the in-flight
2961
+ // undo instead of double-issuing it.
2962
+ const alreadyDispatched = existing?.status === 'pending' && existing.attempts >= attempt;
2963
+ if (!alreadyDispatched) {
2964
+ // Persist the pending checkpoint BEFORE dispatching — the durable anchor for the cross-pod
2965
+ // settle and for re-drive resumption.
2966
+ await this.store.saveCheckpoint({
2967
+ runId: run.id,
2968
+ seq,
2969
+ name,
2970
+ kind: 'remote',
2971
+ stepId: id,
2972
+ status: 'pending',
2973
+ input: validInput,
2974
+ attempts: attempt,
2975
+ workerGroup: token,
2976
+ enqueuedAt,
2977
+ startedAt: enqueuedAt,
2978
+ finishedAt: enqueuedAt,
2979
+ });
2980
+ await this.pool.dispatch({
2981
+ runId: run.id,
2982
+ seq,
2983
+ name: def.name,
2984
+ stepId: id,
2985
+ group: token,
2986
+ input: validInput,
2987
+ traceparent: this.traceparent?.(),
2988
+ context: this.context?.(),
2989
+ attempt: 1,
2990
+ });
2991
+ }
2992
+ const awaited = def.timeoutMs
2411
2993
  ? this.awaitWithHeartbeat(id, resultPromise, def.timeoutMs)
2412
- : resultPromise);
2994
+ : resultPromise;
2995
+ await this.awaitCompensationOutcome(run.id, seq, name, id, token, attempt, awaited,
2996
+ // With a heartbeat window the def's own liveness handling governs; otherwise bound the wait.
2997
+ def.timeoutMs ? undefined : this.compensationTimeoutMs, enqueuedAt);
2413
2998
  }
2414
- catch (err) {
2999
+ finally {
2415
3000
  this.pending.delete(id);
2416
- throw err;
2417
3001
  }
2418
3002
  }
3003
+ /**
3004
+ * Resolve one dispatched undo attempt from EITHER source: the in-memory waiter (the result landed
3005
+ * on THIS pod — persist the settled row ourselves, since the waiter path bypasses
3006
+ * `completeRemoteResult`) or the checkpoint row (settled by another instance sharing the results
3007
+ * queue, where the in-memory waiter never fires). Bounded by `timeoutMs` when given. Resolves on a
3008
+ * completed undo; rejects on a worker-reported failure or timeout.
3009
+ */
3010
+ awaitCompensationOutcome(runId, seq, name, stepId, token, attempt, awaited, timeoutMs, enqueuedAt) {
3011
+ return new Promise((resolve, reject) => {
3012
+ let done = false;
3013
+ let timer;
3014
+ let poll;
3015
+ const cleanup = () => {
3016
+ if (poll)
3017
+ clearInterval(poll);
3018
+ if (timer)
3019
+ clearTimeout(timer);
3020
+ };
3021
+ const finish = (fn) => {
3022
+ if (done)
3023
+ return;
3024
+ done = true;
3025
+ cleanup();
3026
+ fn();
3027
+ };
3028
+ poll = setInterval(() => {
3029
+ void this.store
3030
+ .getCheckpoint(runId, seq)
3031
+ .then((cp) => {
3032
+ if (!cp || cp.attempts < attempt)
3033
+ return;
3034
+ if (cp.status === 'completed')
3035
+ finish(resolve);
3036
+ else if (cp.status === 'failed') {
3037
+ finish(() => reject(new RemoteStepError(cp.error)));
3038
+ }
3039
+ })
3040
+ .catch(() => undefined);
3041
+ }, 1_000);
3042
+ poll.unref?.();
3043
+ if (timeoutMs != null) {
3044
+ timer = setTimeout(() => {
3045
+ finish(() => reject(new RemoteStepError({
3046
+ message: `compensation "${name}" timed out after ${timeoutMs}ms with no worker result`,
3047
+ code: 'compensation_timeout',
3048
+ })));
3049
+ }, timeoutMs);
3050
+ timer.unref?.();
3051
+ }
3052
+ awaited.then((resolution) => {
3053
+ void this.store
3054
+ .saveCheckpoint({
3055
+ runId,
3056
+ seq,
3057
+ name,
3058
+ kind: 'remote',
3059
+ stepId,
3060
+ status: 'completed',
3061
+ output: resolution.output,
3062
+ events: resolution.events,
3063
+ attempts: attempt,
3064
+ workerGroup: token,
3065
+ enqueuedAt,
3066
+ startedAt: resolution.startedAt ? new Date(resolution.startedAt) : enqueuedAt,
3067
+ finishedAt: new Date(),
3068
+ })
3069
+ .catch(() => undefined)
3070
+ .then(() => finish(resolve));
3071
+ }, (err) => finish(() => reject(err)));
3072
+ });
3073
+ }
2419
3074
  /** The seam handed to {@link createWorkflowCtx}: the authoring API reaches durability + lifecycle
2420
3075
  * (checkpointing, dispatch, child start) through this, so the ctx primitives live in their own
2421
3076
  * module and the engine stays the orchestrator. */
@@ -2609,15 +3264,18 @@ export class WorkflowEngine {
2609
3264
  * {@link resumeDueTimers}. Re-driving one re-checks the live fleet: it dispatches if a
2610
3265
  * capable+compatible worker has appeared, else re-parks with a fresh `wakeAt` (design §7.5). */
2611
3266
  async dueBlockedRuns(nowMs) {
2612
- const blocked = await asHeartbeat(() => this.store.listRuns({ statuses: ['blocked'] }));
2613
- // Unlike the store-side poll paths, this filters in memory, so the operator case is explicit:
2614
- // an engine with no namespace drives every pool's blocked runs, exactly as it drives their
2615
- // pending and due ones.
2616
- return blocked.filter((r) => (this.namespace === undefined ||
2617
- r.namespace === undefined ||
2618
- r.namespace === this.namespace) &&
2619
- r.wakeAt !== undefined &&
2620
- r.wakeAt <= nowMs);
3267
+ // Both predicates push into the store now (`wakeBefore` + namespace + a bound): only DUE
3268
+ // blocked runs come back, instead of every blocked run per tick filtered in memory. An engine
3269
+ // with no namespace still drives every pool's blocked runs (the filter is simply omitted); a
3270
+ // legacy run row without a namespace reads back as 'default' from every shipped store, so the
3271
+ // pushdown matches the old in-memory semantics.
3272
+ const blocked = await asHeartbeat(() => this.store.listRuns({
3273
+ statuses: ['blocked'],
3274
+ namespace: this.namespace,
3275
+ wakeBefore: nowMs,
3276
+ limit: 200,
3277
+ }));
3278
+ return blocked;
2621
3279
  }
2622
3280
  async callRemote(runId, seq, step, input, queue, transport, replay, admission, runNamespace) {
2623
3281
  // Read the prefix from the per-execution snapshot (avoids the O(N²) replay SELECTs); a seq absent
@@ -2635,7 +3293,7 @@ export class WorkflowEngine {
2635
3293
  // checkpoint, and let the result resume the run on whichever instance receives it — so a worker
2636
3294
  // pod can scale down or crash mid-step without losing the run or re-running completed work.
2637
3295
  if (step.timeoutMs)
2638
- return this.callRemoteInMemory(runId, seq, step, input, transport, runNamespace);
3296
+ return this.callRemoteInMemory(runId, seq, step, input, transport, runNamespace, queue, admission);
2639
3297
  if (existing?.status === 'pending') {
2640
3298
  // Dispatched; normally we just keep waiting for the result to resume the run. But a LOST dispatch
2641
3299
  // (worker crashed with no result, or the transport dropped the job) would hang here forever — a
@@ -2644,17 +3302,17 @@ export class WorkflowEngine {
2644
3302
  // re-runs, its result resumes the run), bounded by `remoteRedispatchMax` so a never-settling step
2645
3303
  // fails instead of looping. Unset (default) keeps the by-design "re-suspend, never re-dispatch".
2646
3304
  if (this.remoteRedispatchMs == null)
2647
- throw new WorkflowSuspended();
3305
+ throw new WorkflowSuspended(undefined, [seq]);
2648
3306
  // Stamp a redispatch deadline (clock-space, persisted) the first time we see this pending step,
2649
3307
  // stable across replays and crashes — mirrors the failed-retry backoff below. The run's wakeAt
2650
3308
  // becomes this deadline, so a reconcile re-drive lands exactly when it's due to re-dispatch.
2651
3309
  if (existing.wakeAt == null) {
2652
3310
  const wakeAt = this.clock() + this.remoteRedispatchMs;
2653
3311
  await this.store.saveCheckpoint({ ...existing, wakeAt });
2654
- throw new WorkflowSuspended(wakeAt);
3312
+ throw new WorkflowSuspended(wakeAt, [seq]);
2655
3313
  }
2656
3314
  if (this.clock() < existing.wakeAt)
2657
- throw new WorkflowSuspended(existing.wakeAt);
3315
+ throw new WorkflowSuspended(existing.wakeAt, [seq]);
2658
3316
  // Past the deadline with no result — the dispatch is presumed lost. Re-dispatch (bounded by
2659
3317
  // `remoteRedispatchMax` so a step that never settles fails the run instead of looping forever).
2660
3318
  if (existing.attempts >= this.remoteRedispatchMax) {
@@ -2685,7 +3343,7 @@ export class WorkflowEngine {
2685
3343
  attempt: reAttempt,
2686
3344
  transport,
2687
3345
  });
2688
- throw new WorkflowSuspended(nextDeadline);
3346
+ throw new WorkflowSuspended(nextDeadline, [seq]);
2689
3347
  }
2690
3348
  // Durable retry: a failed attempt re-dispatches up to `retries`, spacing attempts by `backoff` —
2691
3349
  // unless the worker marked the error non-retryable (a deterministic verdict like a declined card).
@@ -2710,6 +3368,7 @@ export class WorkflowEngine {
2710
3368
  // Flow control: a queued call that can't be admitted (concurrency/rate) does NOT dispatch — the
2711
3369
  // run re-suspends with the queue's retry time and the timer poller re-tries admission later, so
2712
3370
  // the limit is durable. The admitted slot is released when the result lands (completeRemoteResult).
3371
+ let admittedQueue;
2713
3372
  if (queue && this.admission.handles(queue)) {
2714
3373
  // Admission carries the per-call priority + fairness key (default the runId so each run is its
2715
3374
  // own fairness bucket), and the stepId as a STABLE waiter id so the backend tracks one waiter
@@ -2729,6 +3388,7 @@ export class WorkflowEngine {
2729
3388
  }
2730
3389
  this.queueWaiters.get(queue)?.delete(runId);
2731
3390
  this.stepQueue.set(id, queue);
3391
+ admittedQueue = queue;
2732
3392
  }
2733
3393
  // Optional runtime schema: validate only when the def carries one (a bare `@Step()` / string-name
2734
3394
  // call carries none — the engine passes the input through untouched then).
@@ -2754,6 +3414,10 @@ export class WorkflowEngine {
2754
3414
  input: validInput,
2755
3415
  attempts: attempt,
2756
3416
  workerGroup: token,
3417
+ // Persist which flow-control queue this step's admitted slot belongs to, so the slot is
3418
+ // released by WHICHEVER instance receives the result — the in-memory stepQueue map only
3419
+ // covers the dispatching pod, and a slot released by no one starves the queue fleet-wide.
3420
+ queue: admittedQueue,
2757
3421
  enqueuedAt,
2758
3422
  startedAt: enqueuedAt, // placeholders until the worker result lands
2759
3423
  finishedAt: enqueuedAt,
@@ -2770,7 +3434,7 @@ export class WorkflowEngine {
2770
3434
  transport,
2771
3435
  });
2772
3436
  this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
2773
- throw new WorkflowSuspended();
3437
+ throw new WorkflowSuspended(undefined, [seq]);
2774
3438
  }
2775
3439
  /**
2776
3440
  * Enqueue a remote step task to its worker group via the transport pool. Shared by the initial
@@ -2825,7 +3489,7 @@ export class WorkflowEngine {
2825
3489
  }
2826
3490
  // A result settling this step frees its flow-control slot (no-op if it wasn't queued). Done
2827
3491
  // before the terminal-run early-returns below, so a cancellation can't leak the slot.
2828
- await this.releaseQueueSlot(cp.stepId);
3492
+ await this.releaseQueueSlot(cp);
2829
3493
  // Drop a late result for a run that was cancelled/finished meanwhile — don't complete the step
2830
3494
  // or resume (the run is already terminal). This is the engine side of cooperative cancellation.
2831
3495
  if (run && (run.status === 'cancelled' || run.status === 'completed'))
@@ -2884,16 +3548,22 @@ export class WorkflowEngine {
2884
3548
  for (const runId of runIds)
2885
3549
  void this.resume(runId).catch(() => undefined);
2886
3550
  }
2887
- /** Release the flow-control slot a dispatched step held (if any), by its stepId. */
2888
- async releaseQueueSlot(id) {
2889
- const queue = this.stepQueue.get(id);
3551
+ /**
3552
+ * Release the flow-control slot a dispatched step held (if any). The queue comes from the
3553
+ * CHECKPOINT (durable — works on whichever instance receives the result), falling back to the
3554
+ * dispatching pod's in-memory map for checkpoints written before the `queue` column existed.
3555
+ * Exactly-once is anchored on the caller's pending→settled transition: only the delivery that
3556
+ * finds the checkpoint still `pending` releases.
3557
+ */
3558
+ async releaseQueueSlot(cp) {
3559
+ const queue = cp.queue ?? this.stepQueue.get(cp.stepId);
3560
+ this.stepQueue.delete(cp.stepId);
2890
3561
  if (queue === undefined)
2891
3562
  return;
2892
- this.stepQueue.delete(id);
2893
- await this.admission.release(queue, id);
3563
+ await this.admission.release(queue, cp.stepId);
2894
3564
  }
2895
3565
  /** In-memory await path for a remote step with a liveness `timeoutMs` (re-dispatch on timeout). */
2896
- async callRemoteInMemory(runId, seq, step, input, transport, runNamespace) {
3566
+ async callRemoteInMemory(runId, seq, step, input, transport, runNamespace, queue, admission) {
2897
3567
  if (this.pool.size === 0)
2898
3568
  throw new Error('remote steps require a Transport');
2899
3569
  const validInput = step.input ? step.input.parse(input) : input;
@@ -2902,6 +3572,39 @@ export class WorkflowEngine {
2902
3572
  // emit + dispatch, so a step nobody can run parks `blocked` instead of awaiting a phantom worker.
2903
3573
  await this.ensureRoutable(token, step.requires);
2904
3574
  const id = stepId(runId, seq);
3575
+ // Flow control — the same admission gate the durable path applies. A `{ queue }` on a
3576
+ // `timeoutMs` step used to be SILENTLY ignored (this path branched off before the gate), so the
3577
+ // documented cap simply didn't hold for liveness-timeout steps. The slot spans every retry
3578
+ // attempt of this call and is released in the `finally` below on THIS pod (the await lives
3579
+ // here); it is deliberately NOT persisted on the checkpoint — a cross-pod result consumer
3580
+ // releasing from the row would double-free against this finally.
3581
+ let admittedQueue;
3582
+ if (queue && this.admission.handles(queue)) {
3583
+ const decision = await this.admission.tryAdmit(queue, {
3584
+ priority: admission?.priority,
3585
+ key: admission?.fairnessKey ?? runId,
3586
+ waiterId: id,
3587
+ });
3588
+ if (!decision.ok) {
3589
+ const waiters = this.queueWaiters.get(queue) ?? new Set();
3590
+ waiters.add(runId);
3591
+ this.queueWaiters.set(queue, waiters);
3592
+ throw new WorkflowSuspended(decision.retryAt);
3593
+ }
3594
+ this.queueWaiters.get(queue)?.delete(runId);
3595
+ admittedQueue = queue;
3596
+ }
3597
+ try {
3598
+ return await this.callRemoteInMemoryAdmitted(runId, seq, step, validInput, token, id, transport);
3599
+ }
3600
+ finally {
3601
+ if (admittedQueue !== undefined) {
3602
+ await this.admission.release(admittedQueue, id).catch(() => undefined);
3603
+ }
3604
+ }
3605
+ }
3606
+ /** The dispatch/await/retry body of {@link callRemoteInMemory}, once routable and admitted. */
3607
+ async callRemoteInMemoryAdmitted(runId, seq, step, validInput, token, id, transport) {
2905
3608
  const enqueuedAt = new Date();
2906
3609
  this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
2907
3610
  // Retry policy differs from a LOCAL step on purpose: a local `ctx.step` retries any non-fatal
@@ -3014,7 +3717,9 @@ export class WorkflowEngine {
3014
3717
  // while RUNNING". Without the split, a dispatch-anchored window false-failed a healthy
3015
3718
  // single-concurrency fleet whose next batch queued ~15min behind the current one.
3016
3719
  let seenBeat = false;
3017
- const window = () => (seenBeat ? timeoutMs : (pickupTimeoutMs ?? timeoutMs));
3720
+ // Clamp to Node's setTimeout max (2³¹−1 ms): past it Node fires the timer ~immediately, which
3721
+ // would turn a generous liveness window into an instant RemoteStepTimeout.
3722
+ const window = () => Math.min(seenBeat ? timeoutMs : (pickupTimeoutMs ?? timeoutMs), MAX_BACKOFF_MS);
3018
3723
  const cleanup = () => {
3019
3724
  clearTimeout(timer);
3020
3725
  this.heartbeatResets.delete(id);