@adonis-agora/durable 1.0.0-rc.0 → 1.0.0-rc.10

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 (152) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/dist/assets/spa/assets/index-B2kIfzsc.css +1 -0
  3. package/dist/assets/spa/assets/index-Bgf_8IJ0.js +16 -0
  4. package/dist/assets/spa/assets/index-Bgf_8IJ0.js.map +1 -0
  5. package/dist/assets/spa/index.html +2 -2
  6. package/dist/commands/runs.d.ts.map +1 -1
  7. package/dist/commands/runs.js +4 -2
  8. package/dist/commands/runs.js.map +1 -1
  9. package/dist/providers/dashboard_provider.d.ts +15 -49
  10. package/dist/providers/dashboard_provider.d.ts.map +1 -1
  11. package/dist/providers/dashboard_provider.js +69 -244
  12. package/dist/providers/dashboard_provider.js.map +1 -1
  13. package/dist/providers/durable_provider.d.ts.map +1 -1
  14. package/dist/providers/durable_provider.js +11 -3
  15. package/dist/providers/durable_provider.js.map +1 -1
  16. package/dist/src/commands/runs.js +1 -1
  17. package/dist/src/commands/runs.js.map +1 -1
  18. package/dist/src/concurrency.d.ts.map +1 -1
  19. package/dist/src/concurrency.js +1 -0
  20. package/dist/src/concurrency.js.map +1 -1
  21. package/dist/src/control-flow-signal.d.ts +5 -4
  22. package/dist/src/control-flow-signal.d.ts.map +1 -1
  23. package/dist/src/control-flow-signal.js +5 -4
  24. package/dist/src/control-flow-signal.js.map +1 -1
  25. package/dist/src/dashboard/access_denied_page.d.ts +5 -7
  26. package/dist/src/dashboard/access_denied_page.d.ts.map +1 -1
  27. package/dist/src/dashboard/access_denied_page.js +2 -6
  28. package/dist/src/dashboard/access_denied_page.js.map +1 -1
  29. package/dist/src/dashboard/console_authorize.d.ts +76 -0
  30. package/dist/src/dashboard/console_authorize.d.ts.map +1 -0
  31. package/dist/src/dashboard/console_authorize.js +101 -0
  32. package/dist/src/dashboard/console_authorize.js.map +1 -0
  33. package/dist/src/dashboard/define_config.d.ts +44 -81
  34. package/dist/src/dashboard/define_config.d.ts.map +1 -1
  35. package/dist/src/dashboard/define_config.js +16 -112
  36. package/dist/src/dashboard/define_config.js.map +1 -1
  37. package/dist/src/dashboard/handlers.d.ts.map +1 -1
  38. package/dist/src/dashboard/handlers.js +6 -3
  39. package/dist/src/dashboard/handlers.js.map +1 -1
  40. package/dist/src/dashboard/index.d.ts +5 -8
  41. package/dist/src/dashboard/index.d.ts.map +1 -1
  42. package/dist/src/dashboard/index.js +4 -6
  43. package/dist/src/dashboard/index.js.map +1 -1
  44. package/dist/src/dashboard/openapi.d.ts.map +1 -1
  45. package/dist/src/dashboard/openapi.js +11 -1
  46. package/dist/src/dashboard/openapi.js.map +1 -1
  47. package/dist/src/dashboard/run-filter.d.ts.map +1 -1
  48. package/dist/src/dashboard/run-filter.js +1 -0
  49. package/dist/src/dashboard/run-filter.js.map +1 -1
  50. package/dist/src/define_config.d.ts +6 -0
  51. package/dist/src/define_config.d.ts.map +1 -1
  52. package/dist/src/define_config.js.map +1 -1
  53. package/dist/src/diagnostics/index.d.ts +11 -0
  54. package/dist/src/diagnostics/index.d.ts.map +1 -0
  55. package/dist/src/diagnostics/index.js +6 -0
  56. package/dist/src/diagnostics/index.js.map +1 -0
  57. package/dist/src/diagnostics/transport.d.ts +71 -0
  58. package/dist/src/diagnostics/transport.d.ts.map +1 -0
  59. package/dist/src/diagnostics/transport.js +188 -0
  60. package/dist/src/diagnostics/transport.js.map +1 -0
  61. package/dist/src/diagnostics-bridge.d.ts +2 -1
  62. package/dist/src/diagnostics-bridge.d.ts.map +1 -1
  63. package/dist/src/diagnostics-bridge.js +7 -1
  64. package/dist/src/diagnostics-bridge.js.map +1 -1
  65. package/dist/src/engine.d.ts +30 -8
  66. package/dist/src/engine.d.ts.map +1 -1
  67. package/dist/src/engine.js +198 -72
  68. package/dist/src/engine.js.map +1 -1
  69. package/dist/src/errors.d.ts +9 -0
  70. package/dist/src/errors.d.ts.map +1 -1
  71. package/dist/src/errors.js +13 -0
  72. package/dist/src/errors.js.map +1 -1
  73. package/dist/src/event-trigger-bridge.d.ts +5 -14
  74. package/dist/src/event-trigger-bridge.d.ts.map +1 -1
  75. package/dist/src/event-trigger-bridge.js +11 -99
  76. package/dist/src/event-trigger-bridge.js.map +1 -1
  77. package/dist/src/index.d.ts +1 -1
  78. package/dist/src/index.d.ts.map +1 -1
  79. package/dist/src/index.js +1 -1
  80. package/dist/src/index.js.map +1 -1
  81. package/dist/src/interfaces.d.ts +22 -6
  82. package/dist/src/interfaces.d.ts.map +1 -1
  83. package/dist/src/interfaces.js +8 -0
  84. package/dist/src/interfaces.js.map +1 -1
  85. package/dist/src/origin.js +3 -3
  86. package/dist/src/scheduler.d.ts.map +1 -1
  87. package/dist/src/scheduler.js +2 -1
  88. package/dist/src/scheduler.js.map +1 -1
  89. package/dist/src/singleton-admission.d.ts.map +1 -1
  90. package/dist/src/singleton-admission.js +2 -0
  91. package/dist/src/singleton-admission.js.map +1 -1
  92. package/dist/src/singleton-gate.d.ts +1 -1
  93. package/dist/src/singleton-gate.d.ts.map +1 -1
  94. package/dist/src/singleton-gate.js +4 -3
  95. package/dist/src/singleton-gate.js.map +1 -1
  96. package/dist/src/stores/lucid-pg-hot-path.d.ts +6 -2
  97. package/dist/src/stores/lucid-pg-hot-path.d.ts.map +1 -1
  98. package/dist/src/stores/lucid-pg-hot-path.js +7 -3
  99. package/dist/src/stores/lucid-pg-hot-path.js.map +1 -1
  100. package/dist/src/stores/lucid.d.ts +15 -2
  101. package/dist/src/stores/lucid.d.ts.map +1 -1
  102. package/dist/src/stores/lucid.js +191 -49
  103. package/dist/src/stores/lucid.js.map +1 -1
  104. package/dist/src/testing/in-memory-state-store.js +3 -3
  105. package/dist/src/testing/in-memory-state-store.js.map +1 -1
  106. package/dist/src/testing-kit/state-store-conformance.d.ts.map +1 -1
  107. package/dist/src/testing-kit/state-store-conformance.js +13 -0
  108. package/dist/src/testing-kit/state-store-conformance.js.map +1 -1
  109. package/dist/src/testing-kit/transport-conformance.d.ts.map +1 -1
  110. package/dist/src/testing-kit/transport-conformance.js +5 -1
  111. package/dist/src/testing-kit/transport-conformance.js.map +1 -1
  112. package/dist/src/transports/bullmq/bullmq-transport.d.ts.map +1 -1
  113. package/dist/src/transports/bullmq/bullmq-transport.js +13 -2
  114. package/dist/src/transports/bullmq/bullmq-transport.js.map +1 -1
  115. package/dist/src/transports/db.d.ts.map +1 -1
  116. package/dist/src/transports/db.js +42 -3
  117. package/dist/src/transports/db.js.map +1 -1
  118. package/dist/src/transports/queue.d.ts.map +1 -1
  119. package/dist/src/transports/queue.js +44 -3
  120. package/dist/src/transports/queue.js.map +1 -1
  121. package/dist/src/workflow-discovery.d.ts +1 -1
  122. package/dist/src/workflow-discovery.d.ts.map +1 -1
  123. package/dist/src/workflow-discovery.js +4 -7
  124. package/dist/src/workflow-discovery.js.map +1 -1
  125. package/dist/src/workflow-events.d.ts +20 -51
  126. package/dist/src/workflow-events.d.ts.map +1 -1
  127. package/dist/src/workflow-events.js +12 -30
  128. package/dist/src/workflow-events.js.map +1 -1
  129. package/dist/src/workflow-ref.d.ts +1 -1
  130. package/dist/src/workflow-turn.d.ts.map +1 -1
  131. package/dist/src/workflow-turn.js +18 -1
  132. package/dist/src/workflow-turn.js.map +1 -1
  133. package/dist/stubs/config/durable_dashboard.stub +9 -29
  134. package/package.json +12 -2
  135. package/skills/durable-cluster/SKILL.md +8 -4
  136. package/skills/durable-reliability/SKILL.md +2 -2
  137. package/skills/durable-setup/SKILL.md +3 -1
  138. package/dist/assets/spa/assets/index-70hGzOnT.css +0 -1
  139. package/dist/assets/spa/assets/index-CJjMAUau.js +0 -16
  140. package/dist/assets/spa/assets/index-CJjMAUau.js.map +0 -1
  141. package/dist/src/dashboard/auth.d.ts +0 -134
  142. package/dist/src/dashboard/auth.d.ts.map +0 -1
  143. package/dist/src/dashboard/auth.js +0 -156
  144. package/dist/src/dashboard/auth.js.map +0 -1
  145. package/dist/src/dashboard/login_page.d.ts +0 -36
  146. package/dist/src/dashboard/login_page.d.ts.map +0 -1
  147. package/dist/src/dashboard/login_page.js +0 -158
  148. package/dist/src/dashboard/login_page.js.map +0 -1
  149. package/dist/src/dashboard/session_cookie.d.ts +0 -45
  150. package/dist/src/dashboard/session_cookie.d.ts.map +0 -1
  151. package/dist/src/dashboard/session_cookie.js +0 -97
  152. package/dist/src/dashboard/session_cookie.js.map +0 -1
@@ -72,6 +72,13 @@ const isBreakpoint = (cp) => cp.status === 'pending' && cp.name.startsWith(BREAK
72
72
  * through some other path (an operator's cancel, a timeout sweep, a dead-letter) while this turn's
73
73
  * outcome was being computed from a possibly-stale snapshot.
74
74
  */
75
+ /** Statuses a wake refused by a held lease may stamp a request for another turn on. */
76
+ const WAKEABLE_RUN_STATUSES = ['running', 'suspended', 'blocked', 'cancelling'];
77
+ /**
78
+ * Statuses a wake that finds the lease free re-drives: parked runs, and a `cancelling` run whose
79
+ * undo is not executing (its turn unwinds the saga).
80
+ */
81
+ const PARKED_RUN_STATUSES = ['suspended', 'blocked', 'cancelling'];
75
82
  const SETTLE_ELIGIBLE_STATUSES = [
76
83
  'pending',
77
84
  'running',
@@ -139,6 +146,12 @@ const STEP_EVENTS_PER_BROADCAST = 500;
139
146
  const isStepEvent = (event) => event.type.startsWith('step.');
140
147
  /** The default {@link WorkflowEngineDeps.timerBatchSize}. */
141
148
  const DEFAULT_TIMER_BATCH = 500;
149
+ /** The default {@link WorkflowEngineDeps.waitForRunPollMs}. */
150
+ const DEFAULT_WAIT_FOR_RUN_POLL_MS = 250;
151
+ /** A `waitForRun` read interval: a finite value `>= 0` (`0` = no reads), else `undefined`. */
152
+ function validPollMs(ms) {
153
+ return ms !== undefined && Number.isFinite(ms) && ms >= 0 ? ms : undefined;
154
+ }
142
155
  /** {@link WorkflowEngine.sweepBufferedSignals}: rows read per page. */
143
156
  const BUFFERED_SWEEP_PAGE = 500;
144
157
  /** {@link WorkflowEngine.sweepBufferedSignals}: pages per call, so one tick stays short. */
@@ -241,7 +254,13 @@ function isNewerVersion(a, b) {
241
254
  return a.localeCompare(b, undefined, { numeric: true }) > 0;
242
255
  }
243
256
  /** The statuses a run can be skipped in for an unregistered workflow — every non-terminal one. */
244
- const UNREGISTERED_DEAD_LETTER_FROM = ['pending', 'running', 'suspended', 'blocked'];
257
+ const UNREGISTERED_DEAD_LETTER_FROM = [
258
+ 'pending',
259
+ 'running',
260
+ 'suspended',
261
+ 'blocked',
262
+ 'cancelling',
263
+ ];
245
264
  /** Thrown by {@link WorkflowEngine.resume} when the run belongs to a different namespace. */
246
265
  export class NamespaceMismatch extends Error {
247
266
  constructor() {
@@ -313,6 +332,7 @@ export class WorkflowEngine {
313
332
  remoteRedispatchMs;
314
333
  remoteRedispatchMax;
315
334
  remoteAdvanceSilenceMs;
335
+ waitForRunPollMs;
316
336
  webhookUrl;
317
337
  traceparent;
318
338
  context;
@@ -589,6 +609,7 @@ export class WorkflowEngine {
589
609
  this.remoteRedispatchMax = Math.max(1, deps.remoteRedispatchMax ?? 10);
590
610
  // Opt-in: unset (or 0) keeps the unbounded wait for a remote workflow turn's decision.
591
611
  this.remoteAdvanceSilenceMs = deps.remoteAdvanceSilenceMs || undefined;
612
+ this.waitForRunPollMs = validPollMs(deps.waitForRunPollMs) ?? DEFAULT_WAIT_FOR_RUN_POLL_MS;
592
613
  this.webhookUrl = deps.webhookUrl;
593
614
  this.traceparent = deps.traceparent;
594
615
  this.context = deps.context;
@@ -3217,7 +3238,7 @@ export class WorkflowEngine {
3217
3238
  return [];
3218
3239
  this.#lastStalledSweepAt = now;
3219
3240
  const candidates = await this.store.listRuns({
3220
- statuses: ['suspended', 'running'],
3241
+ statuses: ['suspended', 'running', 'cancelling'],
3221
3242
  namespace: this.namespace,
3222
3243
  updatedBefore: now - this.stalledAfterMs,
3223
3244
  size: 100,
@@ -3461,11 +3482,10 @@ export class WorkflowEngine {
3461
3482
  /**
3462
3483
  * Sweep a deleted run's token-keyed buffered signals — they are not run-scoped rows, so the store's
3463
3484
  * deleteRun can't cascade them. Without this every fire-and-forget child that was never joined leaks
3464
- * its buffered `child:<id>` completion FOREVER (one row per spawn), and a raced compensate-cancel
3465
- * can leave a `cancel:<id>` marker behind.
3485
+ * its buffered `child:<id>` completion FOREVER (one row per spawn).
3466
3486
  */
3467
3487
  async sweepRunBufferedSignals(runId) {
3468
- for (const token of [`child:${runId}`, `cancel:${runId}`]) {
3488
+ for (const token of [`child:${runId}`]) {
3469
3489
  for (;;) {
3470
3490
  const buffered = await this.store.takeBufferedSignal(token).catch(() => null);
3471
3491
  if (!buffered)
@@ -4061,6 +4081,27 @@ export class WorkflowEngine {
4061
4081
  // finished run) and re-enqueues it for a second execution. Only a run that still reads
4062
4082
  // `running` under the lease is an orphan; release the lease taken just to look at anything else.
4063
4083
  const run = await this.store.getRun(listed.id);
4084
+ // A compensating cancel whose undo died with its pod (or never started: the pod that took the
4085
+ // cancel crashed before its resume ran). Re-drive it rather than re-pend it, which would drop
4086
+ // the cancel: any turn of a `cancelling` run unwinds the saga and settles it `cancelled`.
4087
+ if (run?.status === 'cancelling') {
4088
+ // Only a pod that has the workflow can run its compensations; past the grace, one that is
4089
+ // registered nowhere is dead-lettered like any orphan.
4090
+ if (!this.isRegisteredHere(run) && !(await this.canResolveWorkflow(run))) {
4091
+ const settled = await this.settleUnregistered(run, nowMs);
4092
+ if (settled)
4093
+ results.push(settled);
4094
+ else if (settled === undefined)
4095
+ this.logUnregisteredSkip(run);
4096
+ continue;
4097
+ }
4098
+ await this.store.releaseRunLock(listed.id, this.instanceId);
4099
+ this.trackEffect(Promise.resolve(this.wake(run.id)).catch((error) => {
4100
+ console.error(`[adonis-durable] could not re-drive cancelling run ${run.id}; retrying on a later tick:`, error);
4101
+ }));
4102
+ results.push({ runId: run.id, status: 'cancelling' });
4103
+ continue;
4104
+ }
4064
4105
  if (run?.status !== 'running') {
4065
4106
  await this.store.releaseRunLock(listed.id, this.instanceId);
4066
4107
  continue;
@@ -4543,6 +4584,10 @@ export class WorkflowEngine {
4543
4584
  const run = await this.store.getRun(runId);
4544
4585
  if (!run)
4545
4586
  return null;
4587
+ // A run undoing its saga for a compensating cancel is not retried over: re-pending it would drop
4588
+ // the cancel. It ends `cancelled`, and can be requeued from there.
4589
+ if (run.status === 'cancelling')
4590
+ return { runId, status: 'cancelling' };
4546
4591
  // A FAILED/DEAD run's replay deterministically re-throws its recorded failures — an exhausted
4547
4592
  // failed checkpoint rethrows, and an awaited child's failure completion rethrows through
4548
4593
  // unwrapCompletion — so "retry" without resetting that state is a no-op that re-fails in
@@ -4798,15 +4843,25 @@ export class WorkflowEngine {
4798
4843
  * the wait continues until the run reaches an end state. This is what `BaseWorkflow.start` needs so
4799
4844
  * "I want the result" blocks through a suspension instead of returning early with no output. The
4800
4845
  * default (`terminal` unset/false) resolves on any settled state, suspended included.
4846
+ *
4847
+ * A `blocked` run (no live worker can take its next dispatch) is NOT settled in either mode: the
4848
+ * engine re-drives it as soon as a capable worker appears, so the wait continues. Bound it with
4849
+ * `timeoutMs`.
4850
+ *
4851
+ * The wait wakes on this run's lifecycle events, and also re-reads the run every `pollMs` (default
4852
+ * the engine's `waitForRunPollMs`, 250 ms), so it sees a run that settles on another instance even
4853
+ * without a control plane. `pollMs: 0` turns the re-read off.
4801
4854
  */
4802
4855
  waitForRun(runId, opts) {
4803
4856
  const isTerminal = (s) => s === 'completed' || s === 'failed' || s === 'cancelled' || s === 'dead';
4804
4857
  // A run `suspended` while it awaits a dispatched remote workflow turn's decision
4805
4858
  // (`awaitingDecisionTaskId` set) is mid-turn, not settled: that suspend is the engine's re-drive
4806
- // marker, not a sleep/signal the body asked for. Keep waiting until the decision lands.
4807
- const isSettled = (run) => run.status !== 'pending' &&
4808
- run.status !== 'running' &&
4809
- !(run.status === 'suspended' && run.awaitingDecisionTaskId !== undefined);
4859
+ // marker, not a sleep/signal the body asked for. Keep waiting until the decision lands. A `blocked`
4860
+ // run is parked on the fleet, not on anything the body asked for, and is re-driven on its own.
4861
+ // A `cancelling` run is still undoing its saga: it settles once it is `cancelled`.
4862
+ const isSettled = (run) => run.status !== 'cancelling' &&
4863
+ (isTerminal(run.status) ||
4864
+ (run.status === 'suspended' && run.awaitingDecisionTaskId === undefined));
4810
4865
  const shouldResolve = (run) => opts?.terminal ? isTerminal(run.status) : isSettled(run);
4811
4866
  const toResult = (run) => ({
4812
4867
  runId,
@@ -4814,37 +4869,56 @@ export class WorkflowEngine {
4814
4869
  output: run.output,
4815
4870
  error: run.error,
4816
4871
  });
4872
+ const pollMs = validPollMs(opts?.pollMs) ?? this.waitForRunPollMs;
4817
4873
  return new Promise((resolve, reject) => {
4818
4874
  let done = false;
4819
4875
  let off = () => { };
4820
- const timer = opts?.timeoutMs != null
4821
- ? setTimeout(() => {
4876
+ let poller;
4877
+ let reading = false;
4878
+ let timer;
4879
+ const stop = () => {
4880
+ done = true;
4881
+ if (timer)
4882
+ clearTimeout(timer);
4883
+ if (poller)
4884
+ clearInterval(poller);
4885
+ off();
4886
+ };
4887
+ if (opts?.timeoutMs != null) {
4888
+ timer = setTimeout(() => {
4822
4889
  if (done)
4823
4890
  return;
4824
- done = true;
4825
- off();
4891
+ stop();
4826
4892
  reject(new Error(`waitForRun(${runId}) timed out after ${opts.timeoutMs}ms`));
4827
- }, opts.timeoutMs)
4828
- : undefined;
4893
+ }, opts.timeoutMs);
4894
+ }
4829
4895
  const finish = (run) => {
4830
4896
  if (done)
4831
4897
  return;
4832
- done = true;
4833
- if (timer)
4834
- clearTimeout(timer);
4835
- off();
4898
+ stop();
4836
4899
  resolve(toResult(run));
4837
4900
  };
4901
+ // Swallow a transient store error: the next event or poll re-checks, and the timeout still
4902
+ // bounds the wait. Unhandled, one hiccup here was a process-fatal unhandled rejection.
4903
+ const read = () => this.store
4904
+ .getRun(runId)
4905
+ .then((run) => {
4906
+ if (run && shouldResolve(run))
4907
+ finish(run);
4908
+ })
4909
+ .catch(() => undefined);
4838
4910
  const check = () => {
4839
- // Swallow a transient store error: the next settling event re-checks, and the timeout still
4840
- // bounds the wait. Unhandled, one hiccup here was a process-fatal unhandled rejection.
4841
- void this.store
4842
- .getRun(runId)
4843
- .then((run) => {
4844
- if (run && shouldResolve(run))
4845
- finish(run);
4846
- })
4847
- .catch(() => undefined);
4911
+ if (!done)
4912
+ void read();
4913
+ };
4914
+ // The poll skips a tick while its previous read is still out, so a slow store never stacks reads.
4915
+ const poll = () => {
4916
+ if (done || reading)
4917
+ return;
4918
+ reading = true;
4919
+ void read().finally(() => {
4920
+ reading = false;
4921
+ });
4848
4922
  };
4849
4923
  // React only to this run's settling events (not its every step event), and subscribe BEFORE the
4850
4924
  // initial read so a run that settles in between isn't missed.
@@ -4854,6 +4928,12 @@ export class WorkflowEngine {
4854
4928
  check();
4855
4929
  }
4856
4930
  });
4931
+ // Events only reach this instance for runs it executes (or over a control plane, best-effort):
4932
+ // the poll is what sees a run that settles on another pod.
4933
+ if (pollMs > 0) {
4934
+ poller = setInterval(poll, pollMs);
4935
+ poller.unref?.();
4936
+ }
4857
4937
  check();
4858
4938
  });
4859
4939
  }
@@ -5270,6 +5350,9 @@ export class WorkflowEngine {
5270
5350
  * cancelled. Without it, cancellation is immediate (no undo).
5271
5351
  */
5272
5352
  async cancel(runId, opts) {
5353
+ return this.cancelOnce(runId, opts, true);
5354
+ }
5355
+ async cancelOnce(runId, opts, mayRetry) {
5273
5356
  const run = await this.store.getRun(runId);
5274
5357
  if (!run)
5275
5358
  return null;
@@ -5282,18 +5365,35 @@ export class WorkflowEngine {
5282
5365
  if (TERMINAL_RUN_STATUSES.includes(run.status)) {
5283
5366
  return { runId, status: run.status, output: run.output, error: run.error };
5284
5367
  }
5368
+ // A compensating cancel is already undoing the saga: a repeat cancel, with or without
5369
+ // `compensate`, echoes it. Taking the instant path below would mark it `cancelled` and skip the
5370
+ // undo still in flight. Recovery re-drives a `cancelling` run whose undo died.
5371
+ if (run.status === 'cancelling')
5372
+ return { runId, status: 'cancelling' };
5285
5373
  // Compensating cancel: resume the run with a cancellation pending — replay re-registers the saga,
5286
5374
  // and at the suspension point execute() runs the undo and marks it cancelled. Run that resume in
5287
5375
  // the BACKGROUND so the caller (e.g. an HTTP request) never blocks on replaying the workflow +
5288
5376
  // compensations. `execute` holds the run's lease, so this can't double-run one a live worker owns
5289
5377
  // (its lease acquire fails and it no-ops); the broadcast tells that worker to abort cooperatively.
5290
5378
  if (opts?.compensate && (run.status === 'suspended' || run.status === 'running')) {
5379
+ // DURABLE cancel intent: the persisted `cancelling` status. It survives a crash or a pod
5380
+ // handoff (any turn of a `cancelling` run unwinds it, a turn already executing on another pod
5381
+ // finds it when its settle loses the status race, and crash recovery re-drives one whose undo
5382
+ // died), and shows the dashboard/API that an undo is in progress. Conditional: a run that
5383
+ // moved meanwhile (settled, parked) is re-read and cancelled for what it is now.
5384
+ const marked = await this.store.updateRunIf(runId, ['suspended', 'running'], {
5385
+ status: 'cancelling',
5386
+ updatedAt: new Date(),
5387
+ });
5388
+ if (!marked) {
5389
+ if (mayRetry)
5390
+ return this.cancelOnce(runId, opts, false);
5391
+ const current = await this.store.getRun(runId);
5392
+ return current
5393
+ ? { runId, status: current.status, output: current.output, error: current.error }
5394
+ : null;
5395
+ }
5291
5396
  this.cancelRequested.add(runId);
5292
- // DURABLE cancel intent: the in-memory flag above only survives on this pod, this boot. The
5293
- // buffered `cancel:<id>` marker is what lets the compensating unwind survive a crash or be
5294
- // picked up by ANOTHER pod (its resume replays into the WorkflowSuspended catch, which checks
5295
- // the marker when the flag is absent). Consumed there once `cancelled` is durably recorded.
5296
- await this.store.bufferSignal(`cancel:${runId}`, true).catch(() => undefined);
5297
5397
  if (this.controlPlane) {
5298
5398
  void this.controlPlane
5299
5399
  .publishControl({ kind: 'cancel', runId, from: this.instanceId }, runTarget(run.namespace))
@@ -5311,7 +5411,7 @@ export class WorkflowEngine {
5311
5411
  // needed covering. The one-microtask defer is preserved; the promise is registered synchronously.
5312
5412
  this.trackEffect(Promise.resolve().then(() => this.resumeImplicit(runId).then(() => this.notifyCancelled(runId))));
5313
5413
  await this.cancelChildren(runId, opts);
5314
- return { runId, status: run.status };
5414
+ return { runId, status: 'cancelling' };
5315
5415
  }
5316
5416
  const error = { message: 'cancelled' };
5317
5417
  // Clears a remote turn's decision marker too: a cancelled run awaits no decision.
@@ -5648,7 +5748,7 @@ export class WorkflowEngine {
5648
5748
  publishedAt: this.clock(),
5649
5749
  })
5650
5750
  .catch(() => undefined);
5651
- const marked = await this.store.updateRunIf(runId, ['running', 'suspended', 'blocked'], {
5751
+ const marked = await this.store.updateRunIf(runId, WAKEABLE_RUN_STATUSES, {
5652
5752
  wakeRequestedAt: this.clock(),
5653
5753
  });
5654
5754
  return marked ? 'stamped' : 'gone';
@@ -5687,7 +5787,7 @@ export class WorkflowEngine {
5687
5787
  if (!(await this.store.tryLockRun(runId, this.instanceId, nowMs + this.leaseMs, nowMs)))
5688
5788
  return;
5689
5789
  const fresh = await this.store.getRun(runId);
5690
- if (fresh?.status !== 'suspended' && fresh?.status !== 'blocked') {
5790
+ if (!fresh || !PARKED_RUN_STATUSES.includes(fresh.status)) {
5691
5791
  await this.store.releaseRunLock(runId, this.instanceId);
5692
5792
  return;
5693
5793
  }
@@ -5787,7 +5887,7 @@ export class WorkflowEngine {
5787
5887
  }
5788
5888
  // Free, and `pending` (its dispatch or the pending-run poll runs it) or `running` with no lease
5789
5889
  // (a holder that died mid-turn: crash recovery takes it): nothing for a wake to do.
5790
- if (run.status !== 'suspended' && run.status !== 'blocked')
5890
+ if (!PARKED_RUN_STATUSES.includes(run.status))
5791
5891
  return;
5792
5892
  if (this.runQueue) {
5793
5893
  this.enqueueRun({
@@ -5800,7 +5900,7 @@ export class WorkflowEngine {
5800
5900
  if (!(await this.store.tryLockRun(runId, this.instanceId, nowMs + this.leaseMs, nowMs)))
5801
5901
  return;
5802
5902
  const fresh = await this.store.getRun(runId);
5803
- if (fresh?.status !== 'suspended' && fresh?.status !== 'blocked') {
5903
+ if (!fresh || !PARKED_RUN_STATUSES.includes(fresh.status)) {
5804
5904
  await this.store.releaseRunLock(runId, this.instanceId);
5805
5905
  return;
5806
5906
  }
@@ -5875,6 +5975,11 @@ export class WorkflowEngine {
5875
5975
  // A remote (e.g. Python) workflow is advanced by dispatching workflow tasks, not by running an
5876
5976
  // in-process body — but everything around it (lease, recovery, timers, the resume that lands us
5877
5977
  // here on a step result) is identical, so it branches here under the same lease.
5978
+ // A `cancelling` run is a compensating cancel in flight, maybe re-driven by crash recovery or
5979
+ // by another pod that never saw the in-memory flag: re-derive the intent from the status, so
5980
+ // this turn unwinds the saga and settles `cancelled` at whichever exit it reaches.
5981
+ if (run.status === 'cancelling')
5982
+ this.cancelRequested.add(run.id);
5878
5983
  const result = registered?.remote
5879
5984
  ? await this.runRemoteExecution(run, registered)
5880
5985
  : await this.runExecution(run, fn, registered);
@@ -6265,7 +6370,7 @@ export class WorkflowEngine {
6265
6370
  // settle — and it must happen even while a dispatched turn is still out (the hold below would
6266
6371
  // otherwise swallow the cancel's resume, and the in-flight turn's decision would carry the run on
6267
6372
  // as if it was never cancelled). The late decision then finds the run cancelled and is dropped.
6268
- if (await this.takeCancelIntent(run.id))
6373
+ if (this.takeCancelIntent(run.id))
6269
6374
  return this.compensateAndCancel(run, []);
6270
6375
  // A dispatched turn is still out and within its liveness window: whatever woke the run (a step
6271
6376
  // result, a signal, a wait recheck) must not dispatch a second turn over it — that would supersede
@@ -6358,13 +6463,17 @@ export class WorkflowEngine {
6358
6463
  // with its lease released, so crash recovery re-drives it (replaying completed steps; bounded by
6359
6464
  // `maxRecoveryAttempts`).
6360
6465
  if (err instanceof RemoteWorkflowTimeout) {
6361
- await this.store
6466
+ const reset = await this.store
6362
6467
  .updateRunIf(run.id, ['running', 'suspended'], {
6363
6468
  status: 'running',
6364
6469
  updatedAt: new Date(),
6365
6470
  })
6366
6471
  .catch(() => false);
6367
6472
  await this.releaseTurnLease(run.id);
6473
+ // Not reset: the run moved on meanwhile (a compensating cancel made it `cancelling`, or it
6474
+ // settled). Report what it is now, not a `running` it no longer reads.
6475
+ if (!reset)
6476
+ return this.echoCurrentStatus(run.id);
6368
6477
  return { runId: run.id, status: 'running' };
6369
6478
  }
6370
6479
  const error = {
@@ -7276,21 +7385,25 @@ export class WorkflowEngine {
7276
7385
  return wakeAt;
7277
7386
  }
7278
7387
  /**
7279
- * Whether a compensating cancel is pending for this run, consuming the in-memory flag. The intent
7280
- * lives in TWO places: the in-memory `cancelRequested` fast path (same pod, same boot), and the
7281
- * DURABLE buffered `cancel:<id>` marker `cancel()` persisted — so a crash or a cross-pod handoff
7282
- * mid-cancellation can't silently forget the cancel. The marker is peeked (take + re-buffer), not
7283
- * consumed: it must survive a crash mid-unwind; {@link compensateAndCancel} drains it once
7284
- * `cancelled` is durably recorded.
7388
+ * Whether a compensating cancel is pending for this run, consuming the in-memory flag. The flag is
7389
+ * set by `cancel()` on this pod, and at turn start for a run that reads `cancelling` (the durable
7390
+ * intent: a cancel from another pod, or one re-driven by recovery). A cancel another pod persists
7391
+ * while this turn runs is found at the settle instead: the run no longer reads a settle-eligible
7392
+ * status, and {@link settleOrUnwind} unwinds it.
7285
7393
  */
7286
- async takeCancelIntent(runId) {
7287
- if (this.cancelRequested.delete(runId))
7288
- return true;
7289
- const marker = await this.store.takeBufferedSignal(`cancel:${runId}`);
7290
- if (!marker)
7291
- return false;
7292
- await this.store.bufferSignal(`cancel:${runId}`, marker.payload).catch(() => undefined);
7293
- return true;
7394
+ takeCancelIntent(runId) {
7395
+ return this.cancelRequested.delete(runId);
7396
+ }
7397
+ /**
7398
+ * Settle this turn's `outcome` — unless a compensating cancel was persisted on another pod while the
7399
+ * turn ran: the settle's status check then reads `cancelling`, and this turn (which still holds the
7400
+ * lease and the saga it registered) undoes it and settles `cancelled` itself.
7401
+ */
7402
+ async settleOrUnwind(run, outcome, compensations) {
7403
+ const settled = await this.settleRun(run, outcome);
7404
+ if (settled.status !== 'cancelling' || this.leaseLostRuns.has(run.id))
7405
+ return settled;
7406
+ return this.compensateAndCancel(run, compensations);
7294
7407
  }
7295
7408
  /**
7296
7409
  * Undo the registered saga compensations in reverse and settle the run `cancelled`. Shared by EVERY
@@ -7300,8 +7413,8 @@ export class WorkflowEngine {
7300
7413
  */
7301
7414
  async compensateAndCancel(run, compensations) {
7302
7415
  this.cancelRequested.delete(run.id);
7303
- // Zombie fence (as in settleRun): another instance owns the run now; the durable marker is still
7304
- // there for it, so this stale turn must neither undo nor write.
7416
+ // Zombie fence (as in settleRun): another instance owns the run now; the persisted `cancelling`
7417
+ // status is still there for it, so this stale turn must neither undo nor write.
7305
7418
  if (this.leaseLostRuns.has(run.id))
7306
7419
  return this.echoCurrentStatus(run.id);
7307
7420
  for (let i = compensations.length - 1; i >= 0; i -= 1) {
@@ -7316,13 +7429,6 @@ export class WorkflowEngine {
7316
7429
  awaitingDecisionTaskId: undefined,
7317
7430
  updatedAt: new Date(),
7318
7431
  }));
7319
- // The cancellation is durably recorded — consume the marker(s) so a later retry of this run id
7320
- // doesn't get spuriously re-cancelled.
7321
- for (;;) {
7322
- const m = await this.store.takeBufferedSignal(`cancel:${run.id}`).catch(() => null);
7323
- if (!m)
7324
- break;
7325
- }
7326
7432
  this.emit({
7327
7433
  type: 'run.failed',
7328
7434
  runId: run.id,
@@ -7367,7 +7473,11 @@ export class WorkflowEngine {
7367
7473
  }
7368
7474
  // Singleton admission gate: if this run shares its key with `limit` older in-flight runs, wait
7369
7475
  // (suspend on a short timer) until a slot frees instead of running now. Re-checked on each resume.
7370
- if (registered?.singleton && !(await this.singletons.admit(run, registered.singleton))) {
7476
+ // A `cancelling` run skips it: it is tearing down, not competing for a slot, and parking it here
7477
+ // would overwrite `cancelling` with `suspended`.
7478
+ if (run.status !== 'cancelling' &&
7479
+ registered?.singleton &&
7480
+ !(await this.singletons.admit(run, registered.singleton))) {
7371
7481
  return this.singletonWaitResult(run);
7372
7482
  }
7373
7483
  // A body this instance parked on dispatched steps in an earlier turn continues from there (see
@@ -7446,10 +7556,10 @@ export class WorkflowEngine {
7446
7556
  }
7447
7557
  // A compensating cancel requested while this turn ran (or one whose resume replays straight to
7448
7558
  // `return`, with no suspension point left) still wins: undo + settle cancelled, never completed.
7449
- if (await this.takeCancelIntent(run.id)) {
7559
+ if (this.takeCancelIntent(run.id)) {
7450
7560
  return await this.compensateAndCancel(run, compensations);
7451
7561
  }
7452
- return await this.settleRun(run, { kind: 'completed', output });
7562
+ return await this.settleOrUnwind(run, { kind: 'completed', output }, compensations);
7453
7563
  }
7454
7564
  catch (err) {
7455
7565
  if (err instanceof ContinueAsNew) {
@@ -7458,7 +7568,11 @@ export class WorkflowEngine {
7458
7568
  if (this.leaseLostRuns.has(run.id))
7459
7569
  return await this.echoCurrentStatus(run.id);
7460
7570
  // A cancel in flight wins over continue-as-new: undo + cancel instead of forking the next run.
7461
- if (await this.takeCancelIntent(run.id)) {
7571
+ // The run is re-read here (continue-as-new is rare) so a cancel another pod persisted during
7572
+ // this turn usually stops the fork; one that lands after the read loses the conditional
7573
+ // completion below instead.
7574
+ if (this.takeCancelIntent(run.id) ||
7575
+ (await this.store.getRun(run.id))?.status === 'cancelling') {
7462
7576
  return await this.compensateAndCancel(run, compensations);
7463
7577
  }
7464
7578
  const nextId = nextContinuationId(run.id);
@@ -7473,12 +7587,23 @@ export class WorkflowEngine {
7473
7587
  // `dispatch: false`: the tracked handoff below owns the pickup. The continuation inherits
7474
7588
  // the parent RUN's namespace so a tenant's chain stays in its worker-pool partition.
7475
7589
  await this.startCore(run.workflow, err.input, nextId, { namespace: run.namespace }, false);
7476
- await this.store.updateRun(run.id, {
7590
+ // Conditional, like every settle: a cancel persisted after the re-read above (`cancelling`, or a
7591
+ // plain `cancelled`) must win over this completion. The continuation already exists then, and
7592
+ // nothing has handed it off: cancel it, and unwind this run if the cancel compensates.
7593
+ const completed = await this.store.updateRunIf(run.id, SETTLE_ELIGIBLE_STATUSES, {
7477
7594
  status: 'completed',
7478
7595
  output: undefined,
7479
7596
  error: undefined,
7480
7597
  updatedAt: new Date(),
7481
7598
  });
7599
+ if (!completed) {
7600
+ await this.cancel(nextId);
7601
+ const current = await this.store.getRun(run.id);
7602
+ if (current?.status === 'cancelling' && !this.leaseLostRuns.has(run.id)) {
7603
+ return await this.compensateAndCancel(run, compensations);
7604
+ }
7605
+ return await this.echoCurrentStatus(run.id);
7606
+ }
7482
7607
  this.emit({
7483
7608
  type: 'run.completed',
7484
7609
  runId: run.id,
@@ -7502,10 +7627,10 @@ export class WorkflowEngine {
7502
7627
  if (err instanceof WorkflowSuspended) {
7503
7628
  // A compensating cancel resumed this run to reach here: the replay re-registered the saga,
7504
7629
  // so undo the completed steps in reverse and mark it cancelled instead of re-suspending.
7505
- if (await this.takeCancelIntent(run.id)) {
7630
+ if (this.takeCancelIntent(run.id)) {
7506
7631
  return await this.compensateAndCancel(run, compensations);
7507
7632
  }
7508
- const settled = await this.settleRun(run, { kind: 'suspended', wakeAt: err.wakeAt });
7633
+ const settled = await this.settleOrUnwind(run, { kind: 'suspended', wakeAt: err.wakeAt }, compensations);
7509
7634
  const stamp = this.suspendStamps.get(run.id);
7510
7635
  this.suspendStamps.delete(run.id);
7511
7636
  if (parkedTurn && cont && settled.status === 'suspended' && stamp !== undefined) {
@@ -7530,7 +7655,7 @@ export class WorkflowEngine {
7530
7655
  }
7531
7656
  // A cancel in flight that surfaced as a thrown error settles `cancelled`, not `failed`: the saga
7532
7657
  // undo runs either way; this keeps the terminal status faithful to the cancel request.
7533
- if (await this.takeCancelIntent(run.id)) {
7658
+ if (this.takeCancelIntent(run.id)) {
7534
7659
  return await this.compensateAndCancel(run, compensations);
7535
7660
  }
7536
7661
  const error = {
@@ -7548,7 +7673,8 @@ export class WorkflowEngine {
7548
7673
  continue;
7549
7674
  await this.runCompensation(run, comp, i);
7550
7675
  }
7551
- return await this.settleRun(run, { kind: 'failed', error });
7676
+ // The saga is undone already: a cancel persisted meanwhile only needs the `cancelled` settle.
7677
+ return await this.settleOrUnwind(run, { kind: 'failed', error }, []);
7552
7678
  }
7553
7679
  finally {
7554
7680
  // Release the recovery lease once the run reaches a terminal/suspended state, so the