@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.cjs CHANGED
@@ -847,6 +847,17 @@ async function runStepHandler(task, handler) {
847
847
  __name(runStepHandler, "runStepHandler");
848
848
 
849
849
  // src/checkpoints.ts
850
+ var REDISPATCHED_STEP_EVENT = "step.redispatched";
851
+ var LOST_STEP_EVENT = "step.lost";
852
+ function lostStepEvent(at, attempts, outcome) {
853
+ return {
854
+ at,
855
+ level: "warn",
856
+ name: outcome === "lost" ? LOST_STEP_EVENT : REDISPATCHED_STEP_EVENT,
857
+ 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`
858
+ };
859
+ }
860
+ __name(lostStepEvent, "lostStepEvent");
850
861
  function nonEmptyEvents(events) {
851
862
  return events && events.length > 0 ? events : void 0;
852
863
  }
@@ -2987,16 +2998,21 @@ var WorkflowEngine = class {
2987
2998
  },
2988
2999
  // A heartbeat resets the liveness window for whatever it targets: an in-flight long STEP (keyed
2989
3000
  // by stepId, see callRemote) or — when stepId is absent — the in-flight workflow TURN (keyed by
2990
- // runId). For an INLINE-advance turn the reset is an in-memory callback; for a DISPATCH-suspend
2991
- // turn there is no in-memory waiter, so a run-scoped beat rearms the suspended turn's durable
2992
- // re-drive deadline (`wakeAt`) instead, keeping a live worker's turn from being re-dispatched.
3001
+ // runId). For an INLINE-advance turn the reset is an in-memory callback; without one (the step
3002
+ // or turn suspended durably, possibly on another instance) the beat rearms the DURABLE deadline
3003
+ // instead: the suspended turn's `wakeAt` for a run-scoped beat, the step's lease for a
3004
+ // step-scoped one — either way keeping a live worker's work from being presumed lost.
2993
3005
  async (beat) => {
2994
3006
  const reset = this.heartbeatResets.get(beat.stepId ?? beat.runId);
2995
3007
  if (reset) {
2996
3008
  reset();
2997
3009
  return;
2998
3010
  }
2999
- if (beat.stepId === void 0) await this.rearmDecisionDeadline(beat.runId);
3011
+ if (beat.stepId === void 0) {
3012
+ await this.rearmDecisionDeadline(beat.runId);
3013
+ return;
3014
+ }
3015
+ await this.rearmStepLease(beat.runId, beat.seq);
3000
3016
  },
3001
3017
  // A remote workflow worker streams each local step's lifecycle (running → completed/failed) so
3002
3018
  // it's checkpointed live, not all-at-once when the long turn ends.
@@ -3641,9 +3657,14 @@ var WorkflowEngine = class {
3641
3657
  async recoverIncomplete(nowMs = this.clock()) {
3642
3658
  if (this.draining) return [];
3643
3659
  const results = [];
3644
- for (const run of await this.store.listIncompleteRuns(this.namespace)) {
3645
- const acquired = await this.store.tryLockRun(run.id, this.instanceId, nowMs + this.leaseMs, nowMs);
3660
+ for (const listed of await this.store.listIncompleteRuns(this.namespace)) {
3661
+ const acquired = await this.store.tryLockRun(listed.id, this.instanceId, nowMs + this.leaseMs, nowMs);
3646
3662
  if (!acquired) continue;
3663
+ const run = await this.store.getRun(listed.id);
3664
+ if (!run || run.status !== "running" && run.status !== "cancelling") {
3665
+ await this.store.releaseRunLock(listed.id);
3666
+ continue;
3667
+ }
3647
3668
  if (run.status === "running") {
3648
3669
  const dispatched = await this.inFlightRemoteStep(run.id);
3649
3670
  if (dispatched) {
@@ -4976,7 +4997,7 @@ var WorkflowEngine = class {
4976
4997
  error: fresh.error
4977
4998
  };
4978
4999
  }
4979
- const wakeAt = await this.applyCommands(run, decision.commands);
5000
+ const wakeAt = await this.applyCommands(run, decision.commands, true);
4980
5001
  const suspended = await this.settleRun(run, {
4981
5002
  kind: "suspended",
4982
5003
  wakeAt
@@ -5014,6 +5035,182 @@ var WorkflowEngine = class {
5014
5035
  }
5015
5036
  }
5016
5037
  }
5038
+ /**
5039
+ * A polyglot workflow's re-emitted `call` whose checkpoint already exists: decide whether the step
5040
+ * is still out for delivery, or whether its dispatch was LOST and has to be re-driven. Returns the
5041
+ * lease deadline to park the run on (so the next decision lands exactly when it's due), or
5042
+ * `undefined` when there is nothing to wait for.
5043
+ *
5044
+ * WHY THIS EXISTS. A worker process killed mid-step (OOM, exit 137 — a pod's memory ceiling, not a
5045
+ * bug in anyone's code) takes its in-flight job with it: BullMQ's stalled-check eventually fails the
5046
+ * job, and for a consumer that can't bridge a terminal job failure back into a `StepResult` (any
5047
+ * non-TS SDK) NOTHING settles the checkpoint. It stays `pending` — "out for delivery" — and the
5048
+ * `applyCommands` idempotency guard, which exists so a `ctx.gather_calls` fan-out doesn't
5049
+ * double-dispatch its still-in-flight siblings on every partial resume, skips it on every later turn.
5050
+ * The run then wakes on the reconcile sweep, dispatches a turn, has the call re-emitted, skips it
5051
+ * again, and sleeps. Forever. Observed in the wild on a six-run `gather_calls` fan-out: `pending`,
5052
+ * `attempts = 1`, `wakeAt` NULL, over an hour, no retry.
5053
+ *
5054
+ * THE POLICY (mirrors {@link callRemote}'s `ctx.step` self-heal, which this path silently did not
5055
+ * share — so `remoteRedispatchMs` simply did not apply to a fan-out):
5056
+ * - a SETTLED checkpoint always wins: a step that completed just before the crash is never re-run;
5057
+ * - `remoteRedispatchMs` unset (the default) keeps the by-design "re-suspend, never re-dispatch";
5058
+ * - otherwise the pending checkpoint carries a LEASE in `wakeAt` — a result or a step heartbeat
5059
+ * ({@link rearmStepLease}) renews it, so a live worker still holding the step is never
5060
+ * double-dispatched — and only a LAPSED lease re-dispatches, bounded by `remoteRedispatchMax`;
5061
+ * - past the bound the step is failed `remote_step_lost` and the failure enters the run's history,
5062
+ * so the workflow's own error path surfaces it instead of the engine looping.
5063
+ */
5064
+ async redriveLostCall(run, cp) {
5065
+ if (cp.kind !== "remote" || cp.status !== "pending") return void 0;
5066
+ if (this.remoteRedispatchMs == null) return void 0;
5067
+ if (cp.wakeAt == null) {
5068
+ const lease2 = this.clock() + this.remoteRedispatchMs;
5069
+ await this.store.saveCheckpoint({
5070
+ ...cp,
5071
+ wakeAt: lease2
5072
+ });
5073
+ return this.stepLeaseWakeAt(lease2);
5074
+ }
5075
+ if (this.clock() < cp.wakeAt) return this.stepLeaseWakeAt(cp.wakeAt);
5076
+ if (cp.attempts >= this.remoteRedispatchMax) {
5077
+ const error2 = {
5078
+ message: `remote step "${cp.name}" lost \u2014 no result after ${cp.attempts} dispatch(es)`,
5079
+ code: "remote_step_lost",
5080
+ retryable: false
5081
+ };
5082
+ const now = /* @__PURE__ */ new Date();
5083
+ await this.store.saveCheckpoint({
5084
+ ...cp,
5085
+ status: "failed",
5086
+ error: error2,
5087
+ wakeAt: void 0,
5088
+ finishedAt: now,
5089
+ events: [
5090
+ ...cp.events ?? [],
5091
+ lostStepEvent(this.clock(), cp.attempts, "lost")
5092
+ ]
5093
+ });
5094
+ this.emit({
5095
+ type: "step.failed",
5096
+ runId: run.id,
5097
+ seq: cp.seq,
5098
+ name: cp.name,
5099
+ kind: "remote",
5100
+ error: error2
5101
+ });
5102
+ return void 0;
5103
+ }
5104
+ const attempts = cp.attempts + 1;
5105
+ const lease = this.clock() + this.remoteRedispatchMs;
5106
+ const at = /* @__PURE__ */ new Date();
5107
+ await this.store.saveCheckpoint({
5108
+ ...cp,
5109
+ attempts,
5110
+ wakeAt: lease,
5111
+ // The re-dispatch restarts the step's wall-clock window; the old one measured a worker that died.
5112
+ enqueuedAt: at,
5113
+ startedAt: at,
5114
+ finishedAt: at,
5115
+ // Observability: the step's own trail says WHY it ran again — a lost worker, not a failure retry.
5116
+ events: [
5117
+ ...cp.events ?? [],
5118
+ lostStepEvent(this.clock(), attempts, "redispatched")
5119
+ ]
5120
+ });
5121
+ await this.dispatchRemoteTask({
5122
+ runId: run.id,
5123
+ seq: cp.seq,
5124
+ name: cp.name,
5125
+ stepId: cp.stepId ?? stepId(run.id, cp.seq),
5126
+ group: cp.workerGroup ?? sanitizeQueueToken(cp.name),
5127
+ input: cp.input,
5128
+ attempt: attempts
5129
+ });
5130
+ this.emit({
5131
+ type: "step.started",
5132
+ runId: run.id,
5133
+ seq: cp.seq,
5134
+ name: cp.name,
5135
+ kind: "remote",
5136
+ redispatched: true
5137
+ });
5138
+ return this.stepLeaseWakeAt(lease);
5139
+ }
5140
+ /**
5141
+ * The run `wakeAt` a step lease should park on: the lease itself, but never LATER than the
5142
+ * `reconcileMs` safety net would have woken the run anyway. A long lease (a fan-out step that
5143
+ * legitimately runs for half an hour) must not stretch the orphan sweep's cadence for this run —
5144
+ * an early wake just re-parks on the un-lapsed lease, which is cheap and convergent.
5145
+ */
5146
+ stepLeaseWakeAt(lease) {
5147
+ const reconcile = this.reconcileMs;
5148
+ return reconcile == null ? lease : Math.min(lease, this.clock() + reconcile);
5149
+ }
5150
+ /** Earliest clock time a step's lease is worth LOOKING at again (`stepId` → clock ms) — the read
5151
+ * throttle for {@link rearmStepLease}, the step-scoped sibling of {@link decisionDeadlineRearms}. */
5152
+ stepLeaseRearms = /* @__PURE__ */ new Map();
5153
+ /**
5154
+ * A step-scoped heartbeat for a durably-suspended remote step: push its LEASE forward so a worker
5155
+ * that is still working on it is never presumed dead and double-dispatched. The durable counterpart
5156
+ * of {@link callRemoteInMemory}'s in-memory `heartbeatResets` rearm (which only exists on the
5157
+ * instance that dispatched a `timeoutMs` step), and the step-scoped sibling of
5158
+ * {@link rearmDecisionDeadline}. No-op unless the lost-dispatch self-heal is on — without
5159
+ * `remoteRedispatchMs` nothing ever re-dispatches, so there is no lease to defend.
5160
+ *
5161
+ * THROTTLED BY THE LEASE ITSELF, not a fixed interval: a renewal buys at most ONE window, so it is
5162
+ * only WORTH a write once the window is half spent. Beats arrive every couple of seconds from a hot
5163
+ * worker loop — and a `gather_calls` fan-out multiplies that by its in-flight steps — so an
5164
+ * unthrottled rearm is a per-beat SELECT + UPDATE storm (plus a `getRun` + `updateRun` whenever the
5165
+ * run is parked exactly on this step's lease). The `cp.wakeAt >= renewed` guard it replaces never
5166
+ * short-circuited anything, because `renewed` advances with the clock. So:
5167
+ * - a beat that finds more than half the window still on the clock writes nothing, and records the
5168
+ * half-spent mark so subsequent beats skip even the `getCheckpoint` READ until then;
5169
+ * - a beat at or past the mark renews, leaving a full half-window of headroom — the lease can never
5170
+ * lapse under a worker whose beats are anywhere near their normal cadence.
5171
+ *
5172
+ * That bounds the cost at ONE read + ONE write per half `remoteRedispatchMs` per in-flight step,
5173
+ * independent of beat frequency (with a 30-minute window: two writes an hour). The in-memory mark is
5174
+ * a pure optimisation — cold on a fresh instance, or on the one that didn't dispatch the step — and
5175
+ * the durable half-spent check below re-derives the same answer from the checkpoint, so nothing
5176
+ * depends on it surviving. Cleared wholesale past 1024 entries: a rare extra read, never a leak.
5177
+ */
5178
+ async rearmStepLease(runId, seq) {
5179
+ if (this.remoteRedispatchMs == null) return;
5180
+ const id = stepId(runId, seq);
5181
+ const now = this.clock();
5182
+ const lookAgainAt = this.stepLeaseRearms.get(id);
5183
+ if (lookAgainAt !== void 0 && now < lookAgainAt) return;
5184
+ try {
5185
+ const cp = await this.store.getCheckpoint(runId, seq);
5186
+ if (!cp || cp.kind !== "remote" || cp.status !== "pending") {
5187
+ this.stepLeaseRearms.delete(id);
5188
+ return;
5189
+ }
5190
+ const halfWindow = Math.ceil(this.remoteRedispatchMs / 2);
5191
+ if (cp.wakeAt != null && cp.wakeAt > now + halfWindow) {
5192
+ this.stepLeaseRearms.set(id, cp.wakeAt - halfWindow);
5193
+ return;
5194
+ }
5195
+ const renewed = now + this.remoteRedispatchMs;
5196
+ const previous = cp.wakeAt;
5197
+ await this.store.saveCheckpoint({
5198
+ ...cp,
5199
+ wakeAt: renewed
5200
+ });
5201
+ if (this.stepLeaseRearms.size > 1024) this.stepLeaseRearms.clear();
5202
+ this.stepLeaseRearms.set(id, renewed - halfWindow);
5203
+ if (previous == null) return;
5204
+ const run = await this.store.getRun(runId);
5205
+ if (run?.status === "suspended" && run.wakeAt === previous) {
5206
+ await this.store.updateRun(runId, {
5207
+ wakeAt: renewed,
5208
+ updatedAt: /* @__PURE__ */ new Date()
5209
+ });
5210
+ }
5211
+ } catch {
5212
+ }
5213
+ }
5017
5214
  /** The run's resolved durable ops as replay inputs: completed/failed steps + elapsed timers. */
5018
5215
  async remoteHistory(runId) {
5019
5216
  const checkpoints = await this.store.listCheckpoints(runId);
@@ -5058,8 +5255,13 @@ var WorkflowEngine = class {
5058
5255
  return events.sort((a, b) => a.seq - b.seq);
5059
5256
  }
5060
5257
  /** Apply a turn's commands: persist recorded local steps, dispatch remote calls, schedule timers.
5061
- * Returns the earliest timer deadline to suspend on (or undefined — suspended on a result). */
5062
- async applyCommands(run, commands) {
5258
+ * Returns the earliest timer deadline to suspend on (or undefined — suspended on a result).
5259
+ *
5260
+ * `redriveLost` is set only by the `continue` path: a turn that PARKS on its blocking ops is the
5261
+ * one that may find a re-emitted `call` whose dispatch was lost and needs re-driving (see
5262
+ * {@link redriveLostCall}). A terminal turn (completed/failed/cancelled) applies its commands only
5263
+ * to record what already ran, and must never dispatch anything new. */
5264
+ async applyCommands(run, commands, redriveLost = false) {
5063
5265
  let wakeAt;
5064
5266
  for (const cmd of commands) {
5065
5267
  const at = /* @__PURE__ */ new Date();
@@ -5095,7 +5297,13 @@ var WorkflowEngine = class {
5095
5297
  error: cmd.error
5096
5298
  });
5097
5299
  } else if (cmd.kind === "call") {
5098
- if (await this.store.getCheckpoint(run.id, cmd.seq)) continue;
5300
+ const existing = await this.store.getCheckpoint(run.id, cmd.seq);
5301
+ if (existing) {
5302
+ const leaseAt = redriveLost ? await this.redriveLostCall(run, existing) : void 0;
5303
+ if (leaseAt != null) wakeAt = wakeAt == null ? leaseAt : Math.min(wakeAt, leaseAt);
5304
+ continue;
5305
+ }
5306
+ const lease = this.remoteRedispatchMs == null ? void 0 : this.clock() + this.remoteRedispatchMs;
5099
5307
  await this.store.saveCheckpoint(stepCheckpoint({
5100
5308
  runId: run.id,
5101
5309
  seq: cmd.seq,
@@ -5104,6 +5312,7 @@ var WorkflowEngine = class {
5104
5312
  status: "pending",
5105
5313
  input: cmd.input,
5106
5314
  attempts: 1,
5315
+ wakeAt: lease,
5107
5316
  // `cmd.group` is the routing declaration the worker's replay emitted (cross-SDK, or a
5108
5317
  // group-served TS body) — treated as an isolation partition on top of the step's own
5109
5318
  // name, mirroring the StepDef formula below (identical expression everywhere).
@@ -5134,6 +5343,10 @@ var WorkflowEngine = class {
5134
5343
  name: cmd.name,
5135
5344
  kind: "remote"
5136
5345
  });
5346
+ if (lease != null) {
5347
+ const leaseWake = this.stepLeaseWakeAt(lease);
5348
+ wakeAt = wakeAt == null ? leaseWake : Math.min(wakeAt, leaseWake);
5349
+ }
5137
5350
  } else if (cmd.kind === "sleep") {
5138
5351
  const deadline = this.clock() + cmd.ms;
5139
5352
  await this.store.saveCheckpoint(stepCheckpoint({
@@ -5311,16 +5524,16 @@ var WorkflowEngine = class {
5311
5524
  try {
5312
5525
  const output = await runInWorkflowCtx(ctx, () => fn(ctx, run.input));
5313
5526
  if (this.cancelRequested.has(run.id)) {
5314
- return this.compensateAndCancel(run, compensations, replay);
5527
+ return await this.compensateAndCancel(run, compensations, replay);
5315
5528
  }
5316
- return this.settleRun(run, {
5529
+ return await this.settleRun(run, {
5317
5530
  kind: "completed",
5318
5531
  output
5319
5532
  });
5320
5533
  } catch (err) {
5321
5534
  if (err instanceof ContinueAsNew) {
5322
5535
  if (this.cancelRequested.has(run.id)) {
5323
- return this.compensateAndCancel(run, compensations, replay);
5536
+ return await this.compensateAndCancel(run, compensations, replay);
5324
5537
  }
5325
5538
  await this.store.updateRun(run.id, {
5326
5539
  status: "completed",
@@ -5347,35 +5560,35 @@ var WorkflowEngine = class {
5347
5560
  }
5348
5561
  if (err instanceof WorkflowBlocked) {
5349
5562
  if (this.cancelRequested.has(run.id)) {
5350
- return this.compensateAndCancel(run, compensations, replay);
5563
+ return await this.compensateAndCancel(run, compensations, replay);
5351
5564
  }
5352
- return this.settleRun(run, {
5565
+ return await this.settleRun(run, {
5353
5566
  kind: "blocked",
5354
5567
  blocked: err.plan
5355
5568
  });
5356
5569
  }
5357
5570
  if (err instanceof WorkflowSuspended) {
5358
5571
  if (this.cancelRequested.has(run.id)) {
5359
- return this.compensateAndCancel(run, compensations, replay);
5572
+ return await this.compensateAndCancel(run, compensations, replay);
5360
5573
  }
5361
- return this.settleRun(run, {
5574
+ return await this.settleRun(run, {
5362
5575
  kind: "suspended",
5363
5576
  wakeAt: err.wakeAt
5364
5577
  });
5365
5578
  }
5366
5579
  if (this.cancelRequested.has(run.id)) {
5367
- return this.compensateAndCancel(run, compensations, replay);
5580
+ return await this.compensateAndCancel(run, compensations, replay);
5368
5581
  }
5369
5582
  const error2 = {
5370
5583
  message: err instanceof Error ? err.message : String(err),
5371
5584
  stack: err instanceof Error ? err.stack : void 0
5372
5585
  };
5373
5586
  const pending = await this.unwindCompensations(run, compensations, replay);
5374
- if (pending) return this.settleRun(run, {
5587
+ if (pending) return await this.settleRun(run, {
5375
5588
  kind: "suspended",
5376
5589
  wakeAt: pending.wakeAt
5377
5590
  });
5378
- return this.settleRun(run, {
5591
+ return await this.settleRun(run, {
5379
5592
  kind: "failed",
5380
5593
  error: error2
5381
5594
  });
@@ -5789,7 +6002,10 @@ var WorkflowEngine = class {
5789
6002
  }
5790
6003
  async callRemote(runId, seq, step, input, queue, transport, replay, admission, routing) {
5791
6004
  const group = stepGroup(step, routing?.namespace);
5792
- const existing = replay?.get(seq) ?? await this.store.getCheckpoint(runId, seq);
6005
+ let existing = replay?.get(seq) ?? await this.store.getCheckpoint(runId, seq);
6006
+ if (existing?.status === "pending" && replay?.has(seq)) {
6007
+ existing = await this.store.getCheckpoint(runId, seq) ?? existing;
6008
+ }
5793
6009
  if (existing && existing.name !== step.name) {
5794
6010
  throw new NonDeterminismError(runId, seq, step.name, existing.name);
5795
6011
  }
@@ -5822,7 +6038,13 @@ var WorkflowEngine = class {
5822
6038
  wakeAt: nextDeadline,
5823
6039
  enqueuedAt: reEnqueuedAt,
5824
6040
  startedAt: reEnqueuedAt,
5825
- finishedAt: reEnqueuedAt
6041
+ finishedAt: reEnqueuedAt,
6042
+ // Same observable marker the polyglot `call` path stamps (see redriveLostCall): the step's own
6043
+ // trail distinguishes a lost-dispatch re-drive from a failure retry.
6044
+ events: [
6045
+ ...existing.events ?? [],
6046
+ lostStepEvent(this.clock(), reAttempt, "redispatched")
6047
+ ]
5826
6048
  });
5827
6049
  await this.dispatchRemoteTask({
5828
6050
  runId,
@@ -5835,6 +6057,14 @@ var WorkflowEngine = class {
5835
6057
  attempt: reAttempt,
5836
6058
  transport
5837
6059
  });
6060
+ this.emit({
6061
+ type: "step.started",
6062
+ runId,
6063
+ seq,
6064
+ name: step.name,
6065
+ kind: "remote",
6066
+ redispatched: true
6067
+ });
5838
6068
  throw new WorkflowSuspended(nextDeadline);
5839
6069
  }
5840
6070
  let attempt = 1;
@@ -6001,20 +6231,48 @@ var WorkflowEngine = class {
6001
6231
  await this.store.releaseRunLock(decision.runId);
6002
6232
  }
6003
6233
  }
6234
+ /** Earliest clock time a suspended turn's deadline is worth LOOKING at again (`runId` → clock ms) —
6235
+ * the read throttle for {@link rearmDecisionDeadline}, the run-scoped sibling of
6236
+ * {@link stepLeaseRearms}. */
6237
+ decisionDeadlineRearms = /* @__PURE__ */ new Map();
6004
6238
  /**
6005
6239
  * A run-scoped heartbeat for a run suspended awaiting a workflow-turn decision (the dispatch path):
6006
6240
  * push its durable re-drive deadline (`wakeAt`) forward so a still-working worker's turn is not
6007
6241
  * re-dispatched. The durable counterpart of an inline turn's in-memory `heartbeatResets` rearm.
6242
+ *
6243
+ * THROTTLED exactly as {@link rearmStepLease} is, and for the same reason: a long workflow turn's
6244
+ * worker beats every couple of seconds, and each beat otherwise cost a `getRun` + `updateRun`.
6245
+ * The window here is `remoteAdvanceSilenceMs`, and a renewal buys at most one of them — so it is
6246
+ * only worth a write once the window is half spent, which leaves a full half-window of headroom
6247
+ * before the timer poller would re-drive the turn. The in-memory mark is a pure optimisation: a
6248
+ * cold (or different) instance re-derives the same answer from the run's own `wakeAt`.
6008
6249
  */
6009
6250
  async rearmDecisionDeadline(runId) {
6010
6251
  const silenceMs = this.remoteAdvanceSilenceMs;
6011
6252
  if (silenceMs == null) return;
6012
- const run = await this.store.getRun(runId);
6013
- if (!run || run.status !== "suspended" || run.awaitingDecisionTaskId === void 0) return;
6014
- await this.store.updateRun(runId, {
6015
- wakeAt: this.clock() + silenceMs,
6016
- updatedAt: /* @__PURE__ */ new Date()
6017
- });
6253
+ const now = this.clock();
6254
+ const lookAgainAt = this.decisionDeadlineRearms.get(runId);
6255
+ if (lookAgainAt !== void 0 && now < lookAgainAt) return;
6256
+ try {
6257
+ const run = await this.store.getRun(runId);
6258
+ if (!run || run.status !== "suspended" || run.awaitingDecisionTaskId === void 0) {
6259
+ this.decisionDeadlineRearms.delete(runId);
6260
+ return;
6261
+ }
6262
+ const halfWindow = Math.ceil(silenceMs / 2);
6263
+ if (run.wakeAt != null && run.wakeAt > now + halfWindow) {
6264
+ this.decisionDeadlineRearms.set(runId, run.wakeAt - halfWindow);
6265
+ return;
6266
+ }
6267
+ const renewed = now + silenceMs;
6268
+ await this.store.updateRun(runId, {
6269
+ wakeAt: renewed,
6270
+ updatedAt: /* @__PURE__ */ new Date()
6271
+ });
6272
+ if (this.decisionDeadlineRearms.size > 1024) this.decisionDeadlineRearms.clear();
6273
+ this.decisionDeadlineRearms.set(runId, renewed - halfWindow);
6274
+ } catch {
6275
+ }
6018
6276
  }
6019
6277
  /**
6020
6278
  * A slot freed on `queue` (a fleet-wide signal): resume this instance's runs blocked on it so they