@dudousxd/nestjs-durable-core 0.43.0 → 0.44.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.
package/dist/index.d.cts CHANGED
@@ -260,6 +260,16 @@ interface WorkflowRun {
260
260
  * durable state required for correctness — a transport without priority support ignores it.
261
261
  */
262
262
  priority?: number | undefined;
263
+ /**
264
+ * REMOTE workflow turn awaiting a decision: the `taskId` of the turn the engine dispatched to a
265
+ * workflow worker and then SUSPENDED on. Set by the dispatch-and-suspend path; matched by
266
+ * {@link WorkflowEngine.completeRemoteDecision} so ONLY the decision for the currently-awaited turn
267
+ * is applied (a stale/duplicate/foreign decision is dropped), then cleared the moment it is applied.
268
+ * This is what makes a workflow-turn decision multi-instance safe: any engine instance that receives
269
+ * the decision looks the run up by `decision.runId` and applies it durably, instead of resolving an
270
+ * in-memory promise that only the dispatching instance held. Absent on non-remote / not-awaiting runs.
271
+ */
272
+ awaitingDecisionTaskId?: string | undefined;
263
273
  createdAt: Date;
264
274
  updatedAt: Date;
265
275
  }
@@ -784,7 +794,25 @@ interface WorkflowStepEvent {
784
794
  * concerns, identical for in-process and remote workflows.
785
795
  */
786
796
  interface WorkflowExecutor {
787
- advance(run: WorkflowRun, history: HistoryEvent[], pendingSignals?: WorkflowTask['pendingSignals']): Promise<WorkflowDecision>;
797
+ /**
798
+ * INLINE replay: advance the turn and RETURN its decision (the in-process executor runs the
799
+ * registered TS function synchronously). The engine awaits this under the run's lease. Provide this
800
+ * OR {@link dispatch} — exactly one. An executor with only `advance` is single-instance by nature
801
+ * (the decision is produced in-process, not delivered over a broker).
802
+ */
803
+ advance?(run: WorkflowRun, history: HistoryEvent[], pendingSignals?: WorkflowTask['pendingSignals']): Promise<WorkflowDecision>;
804
+ /**
805
+ * DISPATCH-AND-SUSPEND (broker-backed, multi-instance safe): dispatch the turn to a worker and
806
+ * return the dispatched `taskId` WITHOUT awaiting the decision. The engine then suspends the run,
807
+ * recording the `taskId` on it; the worker's {@link WorkflowDecision} comes back over the
808
+ * transport's {@link Transport.onDecision} and is applied DURABLY by whatever engine instance
809
+ * receives it ({@link WorkflowEngine.completeRemoteDecision} looks the run up by `decision.runId`).
810
+ * No in-memory await, so the decision is never lost to a point-to-point broker handing it to a
811
+ * non-dispatching instance. Provide this OR {@link advance} — exactly one.
812
+ */
813
+ dispatch?(run: WorkflowRun, history: HistoryEvent[], pendingSignals?: WorkflowTask['pendingSignals']): Promise<{
814
+ taskId: string;
815
+ }>;
788
816
  }
789
817
  /**
790
818
  * A transport in an ordered pool, identified by `id`. The engine dispatches on the first by default
@@ -1969,6 +1997,14 @@ declare class WorkflowEngine {
1969
1997
  */
1970
1998
  private settleRun;
1971
1999
  private runRemoteExecution;
2000
+ /**
2001
+ * Apply a remote workflow turn's {@link WorkflowDecision}: persist the local steps it ran, then
2002
+ * settle (completed/failed/cancelled) or dispatch its blocking ops and suspend (continue). Shared by
2003
+ * the INLINE-advance path ({@link runRemoteExecution}, under the held lease) and the durable
2004
+ * DISPATCH path ({@link completeRemoteDecision}, which holds the lease itself). Idempotent transitions
2005
+ * — the caller has already ensured this is the decision for the currently-awaited turn.
2006
+ */
2007
+ private applyDecision;
1972
2008
  /** The run's resolved durable ops as replay inputs: completed/failed steps + elapsed timers. */
1973
2009
  private remoteHistory;
1974
2010
  /** Apply a turn's commands: persist recorded local steps, dispatch remote calls, schedule timers.
@@ -2025,6 +2061,21 @@ declare class WorkflowEngine {
2025
2061
  * A no-op if the checkpoint isn't `pending` (a duplicate or late delivery).
2026
2062
  */
2027
2063
  private completeRemoteResult;
2064
+ /**
2065
+ * Apply a remote workflow-turn {@link WorkflowDecision} durably — runs on WHATEVER instance receives
2066
+ * it over the broker (the dispatching one may be gone, or a point-to-point broker handed it to a
2067
+ * non-dispatcher). The durable, multi-instance-safe twin of {@link completeRemoteResult} for the
2068
+ * workflow TURN: look the run up by `decision.runId`, verify the decision matches the run's
2069
+ * CURRENTLY-awaited turn (`awaitingDecisionTaskId`) — so a stale / duplicate / foreign / re-driven
2070
+ * decision is dropped (applied AT MOST ONCE) — lease it, clear the marker, and apply the decision.
2071
+ */
2072
+ private completeRemoteDecision;
2073
+ /**
2074
+ * A run-scoped heartbeat for a run suspended awaiting a workflow-turn decision (the dispatch path):
2075
+ * push its durable re-drive deadline (`wakeAt`) forward so a still-working worker's turn is not
2076
+ * re-dispatched. The durable counterpart of an inline turn's in-memory `heartbeatResets` rearm.
2077
+ */
2078
+ private rearmDecisionDeadline;
2028
2079
  /**
2029
2080
  * A slot freed on `queue` (a fleet-wide signal): resume this instance's runs blocked on it so they
2030
2081
  * re-contend now instead of at their retry tick. Snapshot-and-clear — a run still blocked after the
@@ -2193,25 +2244,36 @@ declare function remoteStep<TInput, TOutput>(config: RemoteStepConfig<TInput, TO
2193
2244
 
2194
2245
  /**
2195
2246
  * A {@link WorkflowExecutor} backed by a {@link Transport}: it advances a remote workflow by
2196
- * dispatching a {@link WorkflowTask} over the broker and awaiting the matching {@link WorkflowDecision}
2197
- * (correlated by `taskId`). Pass one to `engine.registerRemote(name, version, { group, executor })` so
2198
- * a workflow authored in another SDK (e.g. the Python `durable-worker`) is driven over Redis/BullMQ.
2247
+ * DISPATCHING a {@link WorkflowTask} over the broker and returning the dispatched `taskId` WITHOUT
2248
+ * awaiting the decision. Pass one to `engine.registerRemote(name, version, { group, executor })` so a
2249
+ * workflow authored in another SDK (e.g. the Python `durable-worker`) is driven over Redis/BullMQ.
2250
+ *
2251
+ * Multi-instance safe by construction: the executor holds NO in-memory state correlating a dispatched
2252
+ * turn to its decision. The engine suspends the run recording the dispatched `taskId`, and the
2253
+ * worker's {@link import('./interfaces').WorkflowDecision} is delivered over the transport's
2254
+ * `onDecision` and applied DURABLY (by run id) on whatever engine instance consumes it — even if that
2255
+ * is not the instance that dispatched the turn. A point-to-point broker can therefore hand the
2256
+ * decision to any instance without it being dropped (the bug this design fixes).
2199
2257
  *
2200
- * Recovery-safe: if the engine crashes awaiting a decision, the re-drive dispatches a fresh task with
2201
- * the same history — the worker's replay is deterministic, so it returns the same decision; a late
2202
- * decision for the old `taskId` simply finds no waiter and is dropped.
2258
+ * Recovery-safe: if the engine crashes (or a decision is genuinely lost because the worker died), the
2259
+ * suspended turn's heartbeat-rearmed window (`remoteAdvanceSilenceMs`) lapses and the timer poller
2260
+ * re-dispatches a fresh task with the same history — the worker's replay is deterministic, so it
2261
+ * returns the same decision; a late decision for the old `taskId` no longer matches the run's
2262
+ * currently-awaited turn and is dropped.
2203
2263
  */
2204
2264
  declare class RemoteWorkflowExecutor implements WorkflowExecutor {
2205
2265
  private readonly transport;
2206
2266
  private readonly group;
2207
- private readonly opts;
2208
- private readonly pending;
2209
- private subscribed;
2210
- constructor(transport: Transport, group: string, opts?: {
2267
+ constructor(transport: Transport, group: string, _opts?: {
2211
2268
  timeoutMs?: number;
2212
2269
  });
2213
- private ensureSubscribed;
2214
- advance(run: WorkflowRun, history: HistoryEvent[]): Promise<WorkflowDecision>;
2270
+ /**
2271
+ * Dispatch one workflow turn and return its `taskId`. Does NOT await the decision — the engine
2272
+ * suspends the run on the returned `taskId` and applies the decision durably via `onDecision`.
2273
+ */
2274
+ dispatch(run: WorkflowRun, history: HistoryEvent[], pendingSignals?: WorkflowTask['pendingSignals']): Promise<{
2275
+ taskId: string;
2276
+ }>;
2215
2277
  }
2216
2278
 
2217
2279
  /**
package/dist/index.d.ts CHANGED
@@ -260,6 +260,16 @@ interface WorkflowRun {
260
260
  * durable state required for correctness — a transport without priority support ignores it.
261
261
  */
262
262
  priority?: number | undefined;
263
+ /**
264
+ * REMOTE workflow turn awaiting a decision: the `taskId` of the turn the engine dispatched to a
265
+ * workflow worker and then SUSPENDED on. Set by the dispatch-and-suspend path; matched by
266
+ * {@link WorkflowEngine.completeRemoteDecision} so ONLY the decision for the currently-awaited turn
267
+ * is applied (a stale/duplicate/foreign decision is dropped), then cleared the moment it is applied.
268
+ * This is what makes a workflow-turn decision multi-instance safe: any engine instance that receives
269
+ * the decision looks the run up by `decision.runId` and applies it durably, instead of resolving an
270
+ * in-memory promise that only the dispatching instance held. Absent on non-remote / not-awaiting runs.
271
+ */
272
+ awaitingDecisionTaskId?: string | undefined;
263
273
  createdAt: Date;
264
274
  updatedAt: Date;
265
275
  }
@@ -784,7 +794,25 @@ interface WorkflowStepEvent {
784
794
  * concerns, identical for in-process and remote workflows.
785
795
  */
786
796
  interface WorkflowExecutor {
787
- advance(run: WorkflowRun, history: HistoryEvent[], pendingSignals?: WorkflowTask['pendingSignals']): Promise<WorkflowDecision>;
797
+ /**
798
+ * INLINE replay: advance the turn and RETURN its decision (the in-process executor runs the
799
+ * registered TS function synchronously). The engine awaits this under the run's lease. Provide this
800
+ * OR {@link dispatch} — exactly one. An executor with only `advance` is single-instance by nature
801
+ * (the decision is produced in-process, not delivered over a broker).
802
+ */
803
+ advance?(run: WorkflowRun, history: HistoryEvent[], pendingSignals?: WorkflowTask['pendingSignals']): Promise<WorkflowDecision>;
804
+ /**
805
+ * DISPATCH-AND-SUSPEND (broker-backed, multi-instance safe): dispatch the turn to a worker and
806
+ * return the dispatched `taskId` WITHOUT awaiting the decision. The engine then suspends the run,
807
+ * recording the `taskId` on it; the worker's {@link WorkflowDecision} comes back over the
808
+ * transport's {@link Transport.onDecision} and is applied DURABLY by whatever engine instance
809
+ * receives it ({@link WorkflowEngine.completeRemoteDecision} looks the run up by `decision.runId`).
810
+ * No in-memory await, so the decision is never lost to a point-to-point broker handing it to a
811
+ * non-dispatching instance. Provide this OR {@link advance} — exactly one.
812
+ */
813
+ dispatch?(run: WorkflowRun, history: HistoryEvent[], pendingSignals?: WorkflowTask['pendingSignals']): Promise<{
814
+ taskId: string;
815
+ }>;
788
816
  }
789
817
  /**
790
818
  * A transport in an ordered pool, identified by `id`. The engine dispatches on the first by default
@@ -1969,6 +1997,14 @@ declare class WorkflowEngine {
1969
1997
  */
1970
1998
  private settleRun;
1971
1999
  private runRemoteExecution;
2000
+ /**
2001
+ * Apply a remote workflow turn's {@link WorkflowDecision}: persist the local steps it ran, then
2002
+ * settle (completed/failed/cancelled) or dispatch its blocking ops and suspend (continue). Shared by
2003
+ * the INLINE-advance path ({@link runRemoteExecution}, under the held lease) and the durable
2004
+ * DISPATCH path ({@link completeRemoteDecision}, which holds the lease itself). Idempotent transitions
2005
+ * — the caller has already ensured this is the decision for the currently-awaited turn.
2006
+ */
2007
+ private applyDecision;
1972
2008
  /** The run's resolved durable ops as replay inputs: completed/failed steps + elapsed timers. */
1973
2009
  private remoteHistory;
1974
2010
  /** Apply a turn's commands: persist recorded local steps, dispatch remote calls, schedule timers.
@@ -2025,6 +2061,21 @@ declare class WorkflowEngine {
2025
2061
  * A no-op if the checkpoint isn't `pending` (a duplicate or late delivery).
2026
2062
  */
2027
2063
  private completeRemoteResult;
2064
+ /**
2065
+ * Apply a remote workflow-turn {@link WorkflowDecision} durably — runs on WHATEVER instance receives
2066
+ * it over the broker (the dispatching one may be gone, or a point-to-point broker handed it to a
2067
+ * non-dispatcher). The durable, multi-instance-safe twin of {@link completeRemoteResult} for the
2068
+ * workflow TURN: look the run up by `decision.runId`, verify the decision matches the run's
2069
+ * CURRENTLY-awaited turn (`awaitingDecisionTaskId`) — so a stale / duplicate / foreign / re-driven
2070
+ * decision is dropped (applied AT MOST ONCE) — lease it, clear the marker, and apply the decision.
2071
+ */
2072
+ private completeRemoteDecision;
2073
+ /**
2074
+ * A run-scoped heartbeat for a run suspended awaiting a workflow-turn decision (the dispatch path):
2075
+ * push its durable re-drive deadline (`wakeAt`) forward so a still-working worker's turn is not
2076
+ * re-dispatched. The durable counterpart of an inline turn's in-memory `heartbeatResets` rearm.
2077
+ */
2078
+ private rearmDecisionDeadline;
2028
2079
  /**
2029
2080
  * A slot freed on `queue` (a fleet-wide signal): resume this instance's runs blocked on it so they
2030
2081
  * re-contend now instead of at their retry tick. Snapshot-and-clear — a run still blocked after the
@@ -2193,25 +2244,36 @@ declare function remoteStep<TInput, TOutput>(config: RemoteStepConfig<TInput, TO
2193
2244
 
2194
2245
  /**
2195
2246
  * A {@link WorkflowExecutor} backed by a {@link Transport}: it advances a remote workflow by
2196
- * dispatching a {@link WorkflowTask} over the broker and awaiting the matching {@link WorkflowDecision}
2197
- * (correlated by `taskId`). Pass one to `engine.registerRemote(name, version, { group, executor })` so
2198
- * a workflow authored in another SDK (e.g. the Python `durable-worker`) is driven over Redis/BullMQ.
2247
+ * DISPATCHING a {@link WorkflowTask} over the broker and returning the dispatched `taskId` WITHOUT
2248
+ * awaiting the decision. Pass one to `engine.registerRemote(name, version, { group, executor })` so a
2249
+ * workflow authored in another SDK (e.g. the Python `durable-worker`) is driven over Redis/BullMQ.
2250
+ *
2251
+ * Multi-instance safe by construction: the executor holds NO in-memory state correlating a dispatched
2252
+ * turn to its decision. The engine suspends the run recording the dispatched `taskId`, and the
2253
+ * worker's {@link import('./interfaces').WorkflowDecision} is delivered over the transport's
2254
+ * `onDecision` and applied DURABLY (by run id) on whatever engine instance consumes it — even if that
2255
+ * is not the instance that dispatched the turn. A point-to-point broker can therefore hand the
2256
+ * decision to any instance without it being dropped (the bug this design fixes).
2199
2257
  *
2200
- * Recovery-safe: if the engine crashes awaiting a decision, the re-drive dispatches a fresh task with
2201
- * the same history — the worker's replay is deterministic, so it returns the same decision; a late
2202
- * decision for the old `taskId` simply finds no waiter and is dropped.
2258
+ * Recovery-safe: if the engine crashes (or a decision is genuinely lost because the worker died), the
2259
+ * suspended turn's heartbeat-rearmed window (`remoteAdvanceSilenceMs`) lapses and the timer poller
2260
+ * re-dispatches a fresh task with the same history — the worker's replay is deterministic, so it
2261
+ * returns the same decision; a late decision for the old `taskId` no longer matches the run's
2262
+ * currently-awaited turn and is dropped.
2203
2263
  */
2204
2264
  declare class RemoteWorkflowExecutor implements WorkflowExecutor {
2205
2265
  private readonly transport;
2206
2266
  private readonly group;
2207
- private readonly opts;
2208
- private readonly pending;
2209
- private subscribed;
2210
- constructor(transport: Transport, group: string, opts?: {
2267
+ constructor(transport: Transport, group: string, _opts?: {
2211
2268
  timeoutMs?: number;
2212
2269
  });
2213
- private ensureSubscribed;
2214
- advance(run: WorkflowRun, history: HistoryEvent[]): Promise<WorkflowDecision>;
2270
+ /**
2271
+ * Dispatch one workflow turn and return its `taskId`. Does NOT await the decision — the engine
2272
+ * suspends the run on the returned `taskId` and applies the decision durably via `onDecision`.
2273
+ */
2274
+ dispatch(run: WorkflowRun, history: HistoryEvent[], pendingSignals?: WorkflowTask['pendingSignals']): Promise<{
2275
+ taskId: string;
2276
+ }>;
2215
2277
  }
2216
2278
 
2217
2279
  /**
package/dist/index.js CHANGED
@@ -708,33 +708,18 @@ var RemoteWorkflowExecutor = class {
708
708
  }
709
709
  transport;
710
710
  group;
711
- opts;
712
- pending = /* @__PURE__ */ new Map();
713
- subscribed = false;
714
- constructor(transport, group, opts = {}) {
711
+ constructor(transport, group, _opts = {}) {
715
712
  this.transport = transport;
716
713
  this.group = group;
717
- this.opts = opts;
718
- }
719
- ensureSubscribed() {
720
- if (this.subscribed) return;
721
- if (!this.transport.onDecision) {
722
- throw new Error("transport does not support workflow decisions (onDecision)");
723
- }
724
- this.subscribed = true;
725
- this.transport.onDecision(async (decision) => {
726
- const resolve = this.pending.get(decision.taskId);
727
- if (resolve) {
728
- this.pending.delete(decision.taskId);
729
- resolve(decision);
730
- }
731
- });
732
714
  }
733
- async advance(run, history) {
715
+ /**
716
+ * Dispatch one workflow turn and return its `taskId`. Does NOT await the decision — the engine
717
+ * suspends the run on the returned `taskId` and applies the decision durably via `onDecision`.
718
+ */
719
+ async dispatch(run, history, pendingSignals) {
734
720
  if (!this.transport.dispatchWorkflowTask) {
735
721
  throw new Error("transport does not support workflow tasks (dispatchWorkflowTask)");
736
722
  }
737
- this.ensureSubscribed();
738
723
  taskCounter += 1;
739
724
  const taskId = `${run.id}:wf:${taskCounter}`;
740
725
  const task = {
@@ -744,23 +729,17 @@ var RemoteWorkflowExecutor = class {
744
729
  workflowVersion: run.workflowVersion,
745
730
  input: run.input,
746
731
  history,
732
+ ...pendingSignals ? {
733
+ pendingSignals
734
+ } : {},
747
735
  group: this.group,
748
736
  priority: run.priority,
749
737
  attempt: 1
750
738
  };
751
- const decision = new Promise((resolve, reject) => {
752
- this.pending.set(taskId, resolve);
753
- const { timeoutMs } = this.opts;
754
- if (timeoutMs) {
755
- const timer = setTimeout(() => {
756
- this.pending.delete(taskId);
757
- reject(new RemoteWorkflowTimeout(taskId, timeoutMs));
758
- }, timeoutMs);
759
- timer.unref?.();
760
- }
761
- });
762
739
  await this.transport.dispatchWorkflowTask(task);
763
- return decision;
740
+ return {
741
+ taskId
742
+ };
764
743
  }
765
744
  };
766
745
 
@@ -881,13 +860,16 @@ var TransportPool = class {
881
860
  /** Register the engine's result/heartbeat/step-event handlers on EVERY transport — a result can come
882
861
  * back on whichever one delivered the task, so failover stays symmetric. `onStepEvent` is wired only
883
862
  * on transports that carry streamed workflow step lifecycle (BullMQ); others simply skip it. */
884
- bind(onResult, onHeartbeat, onStepEvent) {
863
+ bind(onResult, onHeartbeat, onStepEvent, onDecision) {
885
864
  for (const { transport } of this.transports) {
886
865
  transport.onResult(onResult);
887
866
  transport.onHeartbeat(onHeartbeat);
888
867
  if (onStepEvent && transport.onStepEvent) {
889
868
  transport.onStepEvent(onStepEvent);
890
869
  }
870
+ if (onDecision && transport.onDecision) {
871
+ transport.onDecision(onDecision);
872
+ }
891
873
  }
892
874
  }
893
875
  /**
@@ -1645,14 +1627,27 @@ var WorkflowEngine = class {
1645
1627
  },
1646
1628
  // A heartbeat resets the liveness window for whatever it targets: an in-flight long STEP (keyed
1647
1629
  // by stepId, see callRemote) or — when stepId is absent — the in-flight workflow TURN (keyed by
1648
- // runId, see runRemoteExecution's heartbeat-rearmed advance).
1630
+ // runId). For an INLINE-advance turn the reset is an in-memory callback; for a DISPATCH-suspend
1631
+ // turn there is no in-memory waiter, so a run-scoped beat rearms the suspended turn's durable
1632
+ // re-drive deadline (`wakeAt`) instead, keeping a live worker's turn from being re-dispatched.
1649
1633
  async (beat) => {
1650
- this.heartbeatResets.get(beat.stepId ?? beat.runId)?.();
1634
+ const reset = this.heartbeatResets.get(beat.stepId ?? beat.runId);
1635
+ if (reset) {
1636
+ reset();
1637
+ return;
1638
+ }
1639
+ if (beat.stepId === void 0) await this.rearmDecisionDeadline(beat.runId);
1651
1640
  },
1652
1641
  // A remote workflow worker streams each local step's lifecycle (running → completed/failed) so
1653
1642
  // it's checkpointed live, not all-at-once when the long turn ends.
1654
1643
  async (event) => {
1655
1644
  await this.persistStepEvent(event);
1645
+ },
1646
+ // A workflow-turn DECISION delivered over the broker — applied durably (by run id) on whatever
1647
+ // instance consumes it, NOT correlated to an in-memory promise on the dispatcher. This is the
1648
+ // multi-instance fix: a point-to-point broker may hand the decision to a non-dispatching instance.
1649
+ async (decision) => {
1650
+ await this.completeRemoteDecision(decision);
1656
1651
  }
1657
1652
  );
1658
1653
  this.controlPlane?.onControl((msg) => {
@@ -2281,7 +2276,7 @@ var WorkflowEngine = class {
2281
2276
  * `start` when a call site needs the outcome — `await start(...); const r = await waitForRun(id)`.
2282
2277
  */
2283
2278
  waitForRun(runId, opts) {
2284
- const isSettled = /* @__PURE__ */ __name((s) => s !== "pending" && s !== "running" && s !== "cancelling", "isSettled");
2279
+ const isSettled = /* @__PURE__ */ __name((run) => run.status !== "pending" && run.status !== "running" && run.status !== "cancelling" && !(run.status === "suspended" && run.awaitingDecisionTaskId !== void 0), "isSettled");
2285
2280
  const toResult = /* @__PURE__ */ __name((run) => ({
2286
2281
  runId,
2287
2282
  status: run.status,
@@ -2307,7 +2302,7 @@ var WorkflowEngine = class {
2307
2302
  }, "finish");
2308
2303
  const check = /* @__PURE__ */ __name(() => {
2309
2304
  void this.store.getRun(runId).then((run) => {
2310
- if (run && isSettled(run.status)) finish(run);
2305
+ if (run && isSettled(run)) finish(run);
2311
2306
  });
2312
2307
  }, "check");
2313
2308
  off = this.subscribe((ev) => {
@@ -2914,10 +2909,10 @@ var WorkflowEngine = class {
2914
2909
  });
2915
2910
  }
2916
2911
  if (registered.singleton && !await this.singletons.admit(run, registered.singleton)) {
2917
- const wakeAt2 = this.singletons.retryWakeAt();
2912
+ const wakeAt = this.singletons.retryWakeAt();
2918
2913
  await this.store.updateRun(run.id, {
2919
2914
  status: "suspended",
2920
- wakeAt: wakeAt2,
2915
+ wakeAt,
2921
2916
  updatedAt: /* @__PURE__ */ new Date()
2922
2917
  });
2923
2918
  this.emit({
@@ -2932,10 +2927,45 @@ var WorkflowEngine = class {
2932
2927
  };
2933
2928
  }
2934
2929
  const history = await this.remoteHistory(run.id);
2930
+ if (remote.executor.dispatch) {
2931
+ const { taskId } = await remote.executor.dispatch(run, history);
2932
+ const silenceMs = this.remoteAdvanceSilenceMs;
2933
+ const wakeAt = silenceMs != null ? this.clock() + silenceMs : void 0;
2934
+ const fresh = await this.store.getRun(run.id);
2935
+ if (fresh && (fresh.status === "cancelled" || fresh.status === "completed" || fresh.status === "dead")) {
2936
+ await this.store.releaseRunLock(run.id);
2937
+ return {
2938
+ runId: run.id,
2939
+ status: fresh.status,
2940
+ output: fresh.output,
2941
+ error: fresh.error
2942
+ };
2943
+ }
2944
+ await this.store.updateRun(run.id, {
2945
+ status: "suspended",
2946
+ awaitingDecisionTaskId: taskId,
2947
+ wakeAt,
2948
+ updatedAt: /* @__PURE__ */ new Date()
2949
+ });
2950
+ this.emit({
2951
+ type: "run.suspended",
2952
+ runId: run.id,
2953
+ workflow: run.workflow
2954
+ });
2955
+ await this.store.releaseRunLock(run.id);
2956
+ return {
2957
+ runId: run.id,
2958
+ status: "suspended"
2959
+ };
2960
+ }
2961
+ const advanceExecutor = remote.executor.advance;
2962
+ if (!advanceExecutor) {
2963
+ throw new Error(`workflow ${run.workflow}@${run.workflowVersion}: executor implements neither advance nor dispatch`);
2964
+ }
2935
2965
  let decision;
2936
2966
  try {
2937
2967
  const silenceMs = this.remoteAdvanceSilenceMs;
2938
- const advance = remote.executor.advance(run, history);
2968
+ const advance = advanceExecutor.call(remote.executor, run, history);
2939
2969
  decision = silenceMs != null ? await this.awaitWithLivenessDeadline(run.id, advance, silenceMs, () => new RemoteWorkflowTimeout(run.id, silenceMs)) : await advance;
2940
2970
  } catch (err) {
2941
2971
  if (err instanceof RemoteWorkflowTimeout) {
@@ -2966,6 +2996,16 @@ var WorkflowEngine = class {
2966
2996
  error
2967
2997
  };
2968
2998
  }
2999
+ return this.applyDecision(run, decision);
3000
+ }
3001
+ /**
3002
+ * Apply a remote workflow turn's {@link WorkflowDecision}: persist the local steps it ran, then
3003
+ * settle (completed/failed/cancelled) or dispatch its blocking ops and suspend (continue). Shared by
3004
+ * the INLINE-advance path ({@link runRemoteExecution}, under the held lease) and the durable
3005
+ * DISPATCH path ({@link completeRemoteDecision}, which holds the lease itself). Idempotent transitions
3006
+ * — the caller has already ensured this is the decision for the currently-awaited turn.
3007
+ */
3008
+ async applyDecision(run, decision) {
2969
3009
  if (decision.status === "completed") {
2970
3010
  await this.applyCommands(run, decision.commands);
2971
3011
  return this.settleRun(run, {
@@ -3563,6 +3603,60 @@ var WorkflowEngine = class {
3563
3603
  });
3564
3604
  }
3565
3605
  /**
3606
+ * Apply a remote workflow-turn {@link WorkflowDecision} durably — runs on WHATEVER instance receives
3607
+ * it over the broker (the dispatching one may be gone, or a point-to-point broker handed it to a
3608
+ * non-dispatcher). The durable, multi-instance-safe twin of {@link completeRemoteResult} for the
3609
+ * workflow TURN: look the run up by `decision.runId`, verify the decision matches the run's
3610
+ * CURRENTLY-awaited turn (`awaitingDecisionTaskId`) — so a stale / duplicate / foreign / re-driven
3611
+ * decision is dropped (applied AT MOST ONCE) — lease it, clear the marker, and apply the decision.
3612
+ */
3613
+ async completeRemoteDecision(decision) {
3614
+ const run = await this.store.getRun(decision.runId);
3615
+ if (!run || run.awaitingDecisionTaskId !== decision.taskId) return;
3616
+ if (run.status === "cancelled" || run.status === "completed" || run.status === "dead") return;
3617
+ if (run.namespace !== void 0 && run.namespace !== this.namespace) return;
3618
+ const nowMs = this.clock();
3619
+ if (!await this.store.tryLockRun(decision.runId, this.instanceId, nowMs + this.leaseMs, nowMs)) {
3620
+ return;
3621
+ }
3622
+ const renew = setInterval(() => {
3623
+ void this.store.renewRunLock(decision.runId, this.instanceId, this.clock() + this.leaseMs).catch(() => void 0);
3624
+ }, Math.max(50, Math.floor(this.leaseMs / 2)));
3625
+ renew.unref?.();
3626
+ try {
3627
+ const fresh = await this.store.getRun(decision.runId);
3628
+ if (!fresh || fresh.awaitingDecisionTaskId !== decision.taskId) return;
3629
+ if (fresh.status === "cancelled" || fresh.status === "completed" || fresh.status === "dead") {
3630
+ return;
3631
+ }
3632
+ await this.store.updateRun(decision.runId, {
3633
+ awaitingDecisionTaskId: void 0,
3634
+ wakeAt: void 0,
3635
+ updatedAt: /* @__PURE__ */ new Date()
3636
+ });
3637
+ fresh.awaitingDecisionTaskId = void 0;
3638
+ await this.applyDecision(fresh, decision);
3639
+ } finally {
3640
+ clearInterval(renew);
3641
+ await this.store.releaseRunLock(decision.runId);
3642
+ }
3643
+ }
3644
+ /**
3645
+ * A run-scoped heartbeat for a run suspended awaiting a workflow-turn decision (the dispatch path):
3646
+ * push its durable re-drive deadline (`wakeAt`) forward so a still-working worker's turn is not
3647
+ * re-dispatched. The durable counterpart of an inline turn's in-memory `heartbeatResets` rearm.
3648
+ */
3649
+ async rearmDecisionDeadline(runId) {
3650
+ const silenceMs = this.remoteAdvanceSilenceMs;
3651
+ if (silenceMs == null) return;
3652
+ const run = await this.store.getRun(runId);
3653
+ if (!run || run.status !== "suspended" || run.awaitingDecisionTaskId === void 0) return;
3654
+ await this.store.updateRun(runId, {
3655
+ wakeAt: this.clock() + silenceMs,
3656
+ updatedAt: /* @__PURE__ */ new Date()
3657
+ });
3658
+ }
3659
+ /**
3566
3660
  * A slot freed on `queue` (a fleet-wide signal): resume this instance's runs blocked on it so they
3567
3661
  * re-contend now instead of at their retry tick. Snapshot-and-clear — a run still blocked after the
3568
3662
  * retry re-registers itself, and one that's gone (cancelled/admitted) is dropped. Best-effort.