@dudousxd/nestjs-durable-core 0.70.3 → 0.71.1

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.js CHANGED
@@ -706,6 +706,17 @@ async function runStepHandler(task, handler) {
706
706
  __name(runStepHandler, "runStepHandler");
707
707
 
708
708
  // src/checkpoints.ts
709
+ var REDISPATCHED_STEP_EVENT = "step.redispatched";
710
+ var LOST_STEP_EVENT = "step.lost";
711
+ function lostStepEvent(at, attempts, outcome) {
712
+ return {
713
+ at,
714
+ level: "warn",
715
+ name: outcome === "lost" ? LOST_STEP_EVENT : REDISPATCHED_STEP_EVENT,
716
+ message: outcome === "lost" ? `dispatch lost and the re-drive bound is spent after ${attempts} dispatch(es) \u2014 failing the step` : `re-dispatched after a lost dispatch (attempt ${attempts}): the step's lease lapsed with no result and no heartbeat`
717
+ };
718
+ }
719
+ __name(lostStepEvent, "lostStepEvent");
709
720
  function nonEmptyEvents(events) {
710
721
  return events && events.length > 0 ? events : void 0;
711
722
  }
@@ -2846,16 +2857,21 @@ var WorkflowEngine = class {
2846
2857
  },
2847
2858
  // A heartbeat resets the liveness window for whatever it targets: an in-flight long STEP (keyed
2848
2859
  // by stepId, see callRemote) or — when stepId is absent — the in-flight workflow TURN (keyed by
2849
- // runId). For an INLINE-advance turn the reset is an in-memory callback; for a DISPATCH-suspend
2850
- // turn there is no in-memory waiter, so a run-scoped beat rearms the suspended turn's durable
2851
- // re-drive deadline (`wakeAt`) instead, keeping a live worker's turn from being re-dispatched.
2860
+ // runId). For an INLINE-advance turn the reset is an in-memory callback; without one (the step
2861
+ // or turn suspended durably, possibly on another instance) the beat rearms the DURABLE deadline
2862
+ // instead: the suspended turn's `wakeAt` for a run-scoped beat, the step's lease for a
2863
+ // step-scoped one — either way keeping a live worker's work from being presumed lost.
2852
2864
  async (beat) => {
2853
2865
  const reset = this.heartbeatResets.get(beat.stepId ?? beat.runId);
2854
2866
  if (reset) {
2855
2867
  reset();
2856
2868
  return;
2857
2869
  }
2858
- if (beat.stepId === void 0) await this.rearmDecisionDeadline(beat.runId);
2870
+ if (beat.stepId === void 0) {
2871
+ await this.rearmDecisionDeadline(beat.runId);
2872
+ return;
2873
+ }
2874
+ await this.rearmStepLease(beat.runId, beat.seq);
2859
2875
  },
2860
2876
  // A remote workflow worker streams each local step's lifecycle (running → completed/failed) so
2861
2877
  // it's checkpointed live, not all-at-once when the long turn ends.
@@ -3500,9 +3516,14 @@ var WorkflowEngine = class {
3500
3516
  async recoverIncomplete(nowMs = this.clock()) {
3501
3517
  if (this.draining) return [];
3502
3518
  const results = [];
3503
- for (const run of await this.store.listIncompleteRuns(this.namespace)) {
3504
- const acquired = await this.store.tryLockRun(run.id, this.instanceId, nowMs + this.leaseMs, nowMs);
3519
+ for (const listed of await this.store.listIncompleteRuns(this.namespace)) {
3520
+ const acquired = await this.store.tryLockRun(listed.id, this.instanceId, nowMs + this.leaseMs, nowMs);
3505
3521
  if (!acquired) continue;
3522
+ const run = await this.store.getRun(listed.id);
3523
+ if (!run || run.status !== "running" && run.status !== "cancelling") {
3524
+ await this.store.releaseRunLock(listed.id);
3525
+ continue;
3526
+ }
3506
3527
  if (run.status === "running") {
3507
3528
  const dispatched = await this.inFlightRemoteStep(run.id);
3508
3529
  if (dispatched) {
@@ -4835,7 +4856,7 @@ var WorkflowEngine = class {
4835
4856
  error: fresh.error
4836
4857
  };
4837
4858
  }
4838
- const wakeAt = await this.applyCommands(run, decision.commands);
4859
+ const wakeAt = await this.applyCommands(run, decision.commands, true);
4839
4860
  const suspended = await this.settleRun(run, {
4840
4861
  kind: "suspended",
4841
4862
  wakeAt
@@ -4873,6 +4894,182 @@ var WorkflowEngine = class {
4873
4894
  }
4874
4895
  }
4875
4896
  }
4897
+ /**
4898
+ * A polyglot workflow's re-emitted `call` whose checkpoint already exists: decide whether the step
4899
+ * is still out for delivery, or whether its dispatch was LOST and has to be re-driven. Returns the
4900
+ * lease deadline to park the run on (so the next decision lands exactly when it's due), or
4901
+ * `undefined` when there is nothing to wait for.
4902
+ *
4903
+ * WHY THIS EXISTS. A worker process killed mid-step (OOM, exit 137 — a pod's memory ceiling, not a
4904
+ * bug in anyone's code) takes its in-flight job with it: BullMQ's stalled-check eventually fails the
4905
+ * job, and for a consumer that can't bridge a terminal job failure back into a `StepResult` (any
4906
+ * non-TS SDK) NOTHING settles the checkpoint. It stays `pending` — "out for delivery" — and the
4907
+ * `applyCommands` idempotency guard, which exists so a `ctx.gather_calls` fan-out doesn't
4908
+ * double-dispatch its still-in-flight siblings on every partial resume, skips it on every later turn.
4909
+ * The run then wakes on the reconcile sweep, dispatches a turn, has the call re-emitted, skips it
4910
+ * again, and sleeps. Forever. Observed in the wild on a six-run `gather_calls` fan-out: `pending`,
4911
+ * `attempts = 1`, `wakeAt` NULL, over an hour, no retry.
4912
+ *
4913
+ * THE POLICY (mirrors {@link callRemote}'s `ctx.step` self-heal, which this path silently did not
4914
+ * share — so `remoteRedispatchMs` simply did not apply to a fan-out):
4915
+ * - a SETTLED checkpoint always wins: a step that completed just before the crash is never re-run;
4916
+ * - `remoteRedispatchMs` unset (the default) keeps the by-design "re-suspend, never re-dispatch";
4917
+ * - otherwise the pending checkpoint carries a LEASE in `wakeAt` — a result or a step heartbeat
4918
+ * ({@link rearmStepLease}) renews it, so a live worker still holding the step is never
4919
+ * double-dispatched — and only a LAPSED lease re-dispatches, bounded by `remoteRedispatchMax`;
4920
+ * - past the bound the step is failed `remote_step_lost` and the failure enters the run's history,
4921
+ * so the workflow's own error path surfaces it instead of the engine looping.
4922
+ */
4923
+ async redriveLostCall(run, cp) {
4924
+ if (cp.kind !== "remote" || cp.status !== "pending") return void 0;
4925
+ if (this.remoteRedispatchMs == null) return void 0;
4926
+ if (cp.wakeAt == null) {
4927
+ const lease2 = this.clock() + this.remoteRedispatchMs;
4928
+ await this.store.saveCheckpoint({
4929
+ ...cp,
4930
+ wakeAt: lease2
4931
+ });
4932
+ return this.stepLeaseWakeAt(lease2);
4933
+ }
4934
+ if (this.clock() < cp.wakeAt) return this.stepLeaseWakeAt(cp.wakeAt);
4935
+ if (cp.attempts >= this.remoteRedispatchMax) {
4936
+ const error2 = {
4937
+ message: `remote step "${cp.name}" lost \u2014 no result after ${cp.attempts} dispatch(es)`,
4938
+ code: "remote_step_lost",
4939
+ retryable: false
4940
+ };
4941
+ const now = /* @__PURE__ */ new Date();
4942
+ await this.store.saveCheckpoint({
4943
+ ...cp,
4944
+ status: "failed",
4945
+ error: error2,
4946
+ wakeAt: void 0,
4947
+ finishedAt: now,
4948
+ events: [
4949
+ ...cp.events ?? [],
4950
+ lostStepEvent(this.clock(), cp.attempts, "lost")
4951
+ ]
4952
+ });
4953
+ this.emit({
4954
+ type: "step.failed",
4955
+ runId: run.id,
4956
+ seq: cp.seq,
4957
+ name: cp.name,
4958
+ kind: "remote",
4959
+ error: error2
4960
+ });
4961
+ return void 0;
4962
+ }
4963
+ const attempts = cp.attempts + 1;
4964
+ const lease = this.clock() + this.remoteRedispatchMs;
4965
+ const at = /* @__PURE__ */ new Date();
4966
+ await this.store.saveCheckpoint({
4967
+ ...cp,
4968
+ attempts,
4969
+ wakeAt: lease,
4970
+ // The re-dispatch restarts the step's wall-clock window; the old one measured a worker that died.
4971
+ enqueuedAt: at,
4972
+ startedAt: at,
4973
+ finishedAt: at,
4974
+ // Observability: the step's own trail says WHY it ran again — a lost worker, not a failure retry.
4975
+ events: [
4976
+ ...cp.events ?? [],
4977
+ lostStepEvent(this.clock(), attempts, "redispatched")
4978
+ ]
4979
+ });
4980
+ await this.dispatchRemoteTask({
4981
+ runId: run.id,
4982
+ seq: cp.seq,
4983
+ name: cp.name,
4984
+ stepId: cp.stepId ?? stepId(run.id, cp.seq),
4985
+ group: cp.workerGroup ?? sanitizeQueueToken(cp.name),
4986
+ input: cp.input,
4987
+ attempt: attempts
4988
+ });
4989
+ this.emit({
4990
+ type: "step.started",
4991
+ runId: run.id,
4992
+ seq: cp.seq,
4993
+ name: cp.name,
4994
+ kind: "remote",
4995
+ redispatched: true
4996
+ });
4997
+ return this.stepLeaseWakeAt(lease);
4998
+ }
4999
+ /**
5000
+ * The run `wakeAt` a step lease should park on: the lease itself, but never LATER than the
5001
+ * `reconcileMs` safety net would have woken the run anyway. A long lease (a fan-out step that
5002
+ * legitimately runs for half an hour) must not stretch the orphan sweep's cadence for this run —
5003
+ * an early wake just re-parks on the un-lapsed lease, which is cheap and convergent.
5004
+ */
5005
+ stepLeaseWakeAt(lease) {
5006
+ const reconcile = this.reconcileMs;
5007
+ return reconcile == null ? lease : Math.min(lease, this.clock() + reconcile);
5008
+ }
5009
+ /** Earliest clock time a step's lease is worth LOOKING at again (`stepId` → clock ms) — the read
5010
+ * throttle for {@link rearmStepLease}, the step-scoped sibling of {@link decisionDeadlineRearms}. */
5011
+ stepLeaseRearms = /* @__PURE__ */ new Map();
5012
+ /**
5013
+ * A step-scoped heartbeat for a durably-suspended remote step: push its LEASE forward so a worker
5014
+ * that is still working on it is never presumed dead and double-dispatched. The durable counterpart
5015
+ * of {@link callRemoteInMemory}'s in-memory `heartbeatResets` rearm (which only exists on the
5016
+ * instance that dispatched a `timeoutMs` step), and the step-scoped sibling of
5017
+ * {@link rearmDecisionDeadline}. No-op unless the lost-dispatch self-heal is on — without
5018
+ * `remoteRedispatchMs` nothing ever re-dispatches, so there is no lease to defend.
5019
+ *
5020
+ * THROTTLED BY THE LEASE ITSELF, not a fixed interval: a renewal buys at most ONE window, so it is
5021
+ * only WORTH a write once the window is half spent. Beats arrive every couple of seconds from a hot
5022
+ * worker loop — and a `gather_calls` fan-out multiplies that by its in-flight steps — so an
5023
+ * unthrottled rearm is a per-beat SELECT + UPDATE storm (plus a `getRun` + `updateRun` whenever the
5024
+ * run is parked exactly on this step's lease). The `cp.wakeAt >= renewed` guard it replaces never
5025
+ * short-circuited anything, because `renewed` advances with the clock. So:
5026
+ * - a beat that finds more than half the window still on the clock writes nothing, and records the
5027
+ * half-spent mark so subsequent beats skip even the `getCheckpoint` READ until then;
5028
+ * - a beat at or past the mark renews, leaving a full half-window of headroom — the lease can never
5029
+ * lapse under a worker whose beats are anywhere near their normal cadence.
5030
+ *
5031
+ * That bounds the cost at ONE read + ONE write per half `remoteRedispatchMs` per in-flight step,
5032
+ * independent of beat frequency (with a 30-minute window: two writes an hour). The in-memory mark is
5033
+ * a pure optimisation — cold on a fresh instance, or on the one that didn't dispatch the step — and
5034
+ * the durable half-spent check below re-derives the same answer from the checkpoint, so nothing
5035
+ * depends on it surviving. Cleared wholesale past 1024 entries: a rare extra read, never a leak.
5036
+ */
5037
+ async rearmStepLease(runId, seq) {
5038
+ if (this.remoteRedispatchMs == null) return;
5039
+ const id = stepId(runId, seq);
5040
+ const now = this.clock();
5041
+ const lookAgainAt = this.stepLeaseRearms.get(id);
5042
+ if (lookAgainAt !== void 0 && now < lookAgainAt) return;
5043
+ try {
5044
+ const cp = await this.store.getCheckpoint(runId, seq);
5045
+ if (!cp || cp.kind !== "remote" || cp.status !== "pending") {
5046
+ this.stepLeaseRearms.delete(id);
5047
+ return;
5048
+ }
5049
+ const halfWindow = Math.ceil(this.remoteRedispatchMs / 2);
5050
+ if (cp.wakeAt != null && cp.wakeAt > now + halfWindow) {
5051
+ this.stepLeaseRearms.set(id, cp.wakeAt - halfWindow);
5052
+ return;
5053
+ }
5054
+ const renewed = now + this.remoteRedispatchMs;
5055
+ const previous = cp.wakeAt;
5056
+ await this.store.saveCheckpoint({
5057
+ ...cp,
5058
+ wakeAt: renewed
5059
+ });
5060
+ if (this.stepLeaseRearms.size > 1024) this.stepLeaseRearms.clear();
5061
+ this.stepLeaseRearms.set(id, renewed - halfWindow);
5062
+ if (previous == null) return;
5063
+ const run = await this.store.getRun(runId);
5064
+ if (run?.status === "suspended" && run.wakeAt === previous) {
5065
+ await this.store.updateRun(runId, {
5066
+ wakeAt: renewed,
5067
+ updatedAt: /* @__PURE__ */ new Date()
5068
+ });
5069
+ }
5070
+ } catch {
5071
+ }
5072
+ }
4876
5073
  /** The run's resolved durable ops as replay inputs: completed/failed steps + elapsed timers. */
4877
5074
  async remoteHistory(runId) {
4878
5075
  const checkpoints = await this.store.listCheckpoints(runId);
@@ -4917,8 +5114,13 @@ var WorkflowEngine = class {
4917
5114
  return events.sort((a, b) => a.seq - b.seq);
4918
5115
  }
4919
5116
  /** Apply a turn's commands: persist recorded local steps, dispatch remote calls, schedule timers.
4920
- * Returns the earliest timer deadline to suspend on (or undefined — suspended on a result). */
4921
- async applyCommands(run, commands) {
5117
+ * Returns the earliest timer deadline to suspend on (or undefined — suspended on a result).
5118
+ *
5119
+ * `redriveLost` is set only by the `continue` path: a turn that PARKS on its blocking ops is the
5120
+ * one that may find a re-emitted `call` whose dispatch was lost and needs re-driving (see
5121
+ * {@link redriveLostCall}). A terminal turn (completed/failed/cancelled) applies its commands only
5122
+ * to record what already ran, and must never dispatch anything new. */
5123
+ async applyCommands(run, commands, redriveLost = false) {
4922
5124
  let wakeAt;
4923
5125
  for (const cmd of commands) {
4924
5126
  const at = /* @__PURE__ */ new Date();
@@ -4954,7 +5156,13 @@ var WorkflowEngine = class {
4954
5156
  error: cmd.error
4955
5157
  });
4956
5158
  } else if (cmd.kind === "call") {
4957
- if (await this.store.getCheckpoint(run.id, cmd.seq)) continue;
5159
+ const existing = await this.store.getCheckpoint(run.id, cmd.seq);
5160
+ if (existing) {
5161
+ const leaseAt = redriveLost ? await this.redriveLostCall(run, existing) : void 0;
5162
+ if (leaseAt != null) wakeAt = wakeAt == null ? leaseAt : Math.min(wakeAt, leaseAt);
5163
+ continue;
5164
+ }
5165
+ const lease = this.remoteRedispatchMs == null ? void 0 : this.clock() + this.remoteRedispatchMs;
4958
5166
  await this.store.saveCheckpoint(stepCheckpoint({
4959
5167
  runId: run.id,
4960
5168
  seq: cmd.seq,
@@ -4963,6 +5171,7 @@ var WorkflowEngine = class {
4963
5171
  status: "pending",
4964
5172
  input: cmd.input,
4965
5173
  attempts: 1,
5174
+ wakeAt: lease,
4966
5175
  // `cmd.group` is the routing declaration the worker's replay emitted (cross-SDK, or a
4967
5176
  // group-served TS body) — treated as an isolation partition on top of the step's own
4968
5177
  // name, mirroring the StepDef formula below (identical expression everywhere).
@@ -4993,6 +5202,10 @@ var WorkflowEngine = class {
4993
5202
  name: cmd.name,
4994
5203
  kind: "remote"
4995
5204
  });
5205
+ if (lease != null) {
5206
+ const leaseWake = this.stepLeaseWakeAt(lease);
5207
+ wakeAt = wakeAt == null ? leaseWake : Math.min(wakeAt, leaseWake);
5208
+ }
4996
5209
  } else if (cmd.kind === "sleep") {
4997
5210
  const deadline = this.clock() + cmd.ms;
4998
5211
  await this.store.saveCheckpoint(stepCheckpoint({
@@ -5170,16 +5383,16 @@ var WorkflowEngine = class {
5170
5383
  try {
5171
5384
  const output = await runInWorkflowCtx(ctx, () => fn(ctx, run.input));
5172
5385
  if (this.cancelRequested.has(run.id)) {
5173
- return this.compensateAndCancel(run, compensations, replay);
5386
+ return await this.compensateAndCancel(run, compensations, replay);
5174
5387
  }
5175
- return this.settleRun(run, {
5388
+ return await this.settleRun(run, {
5176
5389
  kind: "completed",
5177
5390
  output
5178
5391
  });
5179
5392
  } catch (err) {
5180
5393
  if (err instanceof ContinueAsNew) {
5181
5394
  if (this.cancelRequested.has(run.id)) {
5182
- return this.compensateAndCancel(run, compensations, replay);
5395
+ return await this.compensateAndCancel(run, compensations, replay);
5183
5396
  }
5184
5397
  await this.store.updateRun(run.id, {
5185
5398
  status: "completed",
@@ -5206,35 +5419,35 @@ var WorkflowEngine = class {
5206
5419
  }
5207
5420
  if (err instanceof WorkflowBlocked) {
5208
5421
  if (this.cancelRequested.has(run.id)) {
5209
- return this.compensateAndCancel(run, compensations, replay);
5422
+ return await this.compensateAndCancel(run, compensations, replay);
5210
5423
  }
5211
- return this.settleRun(run, {
5424
+ return await this.settleRun(run, {
5212
5425
  kind: "blocked",
5213
5426
  blocked: err.plan
5214
5427
  });
5215
5428
  }
5216
5429
  if (err instanceof WorkflowSuspended) {
5217
5430
  if (this.cancelRequested.has(run.id)) {
5218
- return this.compensateAndCancel(run, compensations, replay);
5431
+ return await this.compensateAndCancel(run, compensations, replay);
5219
5432
  }
5220
- return this.settleRun(run, {
5433
+ return await this.settleRun(run, {
5221
5434
  kind: "suspended",
5222
5435
  wakeAt: err.wakeAt
5223
5436
  });
5224
5437
  }
5225
5438
  if (this.cancelRequested.has(run.id)) {
5226
- return this.compensateAndCancel(run, compensations, replay);
5439
+ return await this.compensateAndCancel(run, compensations, replay);
5227
5440
  }
5228
5441
  const error2 = {
5229
5442
  message: err instanceof Error ? err.message : String(err),
5230
5443
  stack: err instanceof Error ? err.stack : void 0
5231
5444
  };
5232
5445
  const pending = await this.unwindCompensations(run, compensations, replay);
5233
- if (pending) return this.settleRun(run, {
5446
+ if (pending) return await this.settleRun(run, {
5234
5447
  kind: "suspended",
5235
5448
  wakeAt: pending.wakeAt
5236
5449
  });
5237
- return this.settleRun(run, {
5450
+ return await this.settleRun(run, {
5238
5451
  kind: "failed",
5239
5452
  error: error2
5240
5453
  });
@@ -5648,7 +5861,10 @@ var WorkflowEngine = class {
5648
5861
  }
5649
5862
  async callRemote(runId, seq, step, input, queue, transport, replay, admission, routing) {
5650
5863
  const group = stepGroup(step, routing?.namespace);
5651
- const existing = replay?.get(seq) ?? await this.store.getCheckpoint(runId, seq);
5864
+ let existing = replay?.get(seq) ?? await this.store.getCheckpoint(runId, seq);
5865
+ if (existing?.status === "pending" && replay?.has(seq)) {
5866
+ existing = await this.store.getCheckpoint(runId, seq) ?? existing;
5867
+ }
5652
5868
  if (existing && existing.name !== step.name) {
5653
5869
  throw new NonDeterminismError(runId, seq, step.name, existing.name);
5654
5870
  }
@@ -5681,7 +5897,13 @@ var WorkflowEngine = class {
5681
5897
  wakeAt: nextDeadline,
5682
5898
  enqueuedAt: reEnqueuedAt,
5683
5899
  startedAt: reEnqueuedAt,
5684
- finishedAt: reEnqueuedAt
5900
+ finishedAt: reEnqueuedAt,
5901
+ // Same observable marker the polyglot `call` path stamps (see redriveLostCall): the step's own
5902
+ // trail distinguishes a lost-dispatch re-drive from a failure retry.
5903
+ events: [
5904
+ ...existing.events ?? [],
5905
+ lostStepEvent(this.clock(), reAttempt, "redispatched")
5906
+ ]
5685
5907
  });
5686
5908
  await this.dispatchRemoteTask({
5687
5909
  runId,
@@ -5694,6 +5916,14 @@ var WorkflowEngine = class {
5694
5916
  attempt: reAttempt,
5695
5917
  transport
5696
5918
  });
5919
+ this.emit({
5920
+ type: "step.started",
5921
+ runId,
5922
+ seq,
5923
+ name: step.name,
5924
+ kind: "remote",
5925
+ redispatched: true
5926
+ });
5697
5927
  throw new WorkflowSuspended(nextDeadline);
5698
5928
  }
5699
5929
  let attempt = 1;
@@ -5860,20 +6090,48 @@ var WorkflowEngine = class {
5860
6090
  await this.store.releaseRunLock(decision.runId);
5861
6091
  }
5862
6092
  }
6093
+ /** Earliest clock time a suspended turn's deadline is worth LOOKING at again (`runId` → clock ms) —
6094
+ * the read throttle for {@link rearmDecisionDeadline}, the run-scoped sibling of
6095
+ * {@link stepLeaseRearms}. */
6096
+ decisionDeadlineRearms = /* @__PURE__ */ new Map();
5863
6097
  /**
5864
6098
  * A run-scoped heartbeat for a run suspended awaiting a workflow-turn decision (the dispatch path):
5865
6099
  * push its durable re-drive deadline (`wakeAt`) forward so a still-working worker's turn is not
5866
6100
  * re-dispatched. The durable counterpart of an inline turn's in-memory `heartbeatResets` rearm.
6101
+ *
6102
+ * THROTTLED exactly as {@link rearmStepLease} is, and for the same reason: a long workflow turn's
6103
+ * worker beats every couple of seconds, and each beat otherwise cost a `getRun` + `updateRun`.
6104
+ * The window here is `remoteAdvanceSilenceMs`, and a renewal buys at most one of them — so it is
6105
+ * only worth a write once the window is half spent, which leaves a full half-window of headroom
6106
+ * before the timer poller would re-drive the turn. The in-memory mark is a pure optimisation: a
6107
+ * cold (or different) instance re-derives the same answer from the run's own `wakeAt`.
5867
6108
  */
5868
6109
  async rearmDecisionDeadline(runId) {
5869
6110
  const silenceMs = this.remoteAdvanceSilenceMs;
5870
6111
  if (silenceMs == null) return;
5871
- const run = await this.store.getRun(runId);
5872
- if (!run || run.status !== "suspended" || run.awaitingDecisionTaskId === void 0) return;
5873
- await this.store.updateRun(runId, {
5874
- wakeAt: this.clock() + silenceMs,
5875
- updatedAt: /* @__PURE__ */ new Date()
5876
- });
6112
+ const now = this.clock();
6113
+ const lookAgainAt = this.decisionDeadlineRearms.get(runId);
6114
+ if (lookAgainAt !== void 0 && now < lookAgainAt) return;
6115
+ try {
6116
+ const run = await this.store.getRun(runId);
6117
+ if (!run || run.status !== "suspended" || run.awaitingDecisionTaskId === void 0) {
6118
+ this.decisionDeadlineRearms.delete(runId);
6119
+ return;
6120
+ }
6121
+ const halfWindow = Math.ceil(silenceMs / 2);
6122
+ if (run.wakeAt != null && run.wakeAt > now + halfWindow) {
6123
+ this.decisionDeadlineRearms.set(runId, run.wakeAt - halfWindow);
6124
+ return;
6125
+ }
6126
+ const renewed = now + silenceMs;
6127
+ await this.store.updateRun(runId, {
6128
+ wakeAt: renewed,
6129
+ updatedAt: /* @__PURE__ */ new Date()
6130
+ });
6131
+ if (this.decisionDeadlineRearms.size > 1024) this.decisionDeadlineRearms.clear();
6132
+ this.decisionDeadlineRearms.set(runId, renewed - halfWindow);
6133
+ } catch {
6134
+ }
5877
6135
  }
5878
6136
  /**
5879
6137
  * A slot freed on `queue` (a fleet-wide signal): resume this instance's runs blocked on it so they