@dudousxd/nestjs-durable-core 0.52.0 → 0.53.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
@@ -628,6 +628,16 @@ interface StateStore {
628
628
  takeSignalWaiter(token: string): Promise<SignalWaiter | null>;
629
629
  /** List waiters whose `token` starts with `prefix` — used to fan out an event to its subscribers. */
630
630
  listSignalWaiters(prefix: string): Promise<SignalWaiter[]>;
631
+ /**
632
+ * Delete the EXACT waiter row — `token` AND `runId` AND `seq` must all match — no-op if absent.
633
+ * Unlike {@link takeSignalWaiter}, which deletes ANY row for `token` (fine when the caller just won
634
+ * the race to consume it), this is for a caller removing its OWN registration after resolving the
635
+ * wait some other way (a buffered hit, a timeout): blind `takeSignalWaiter(token)` there could steal
636
+ * a DIFFERENT run's waiter that has since claimed the same token (`token` is the store's primary key,
637
+ * so a later `putSignalWaiter` for the same token replaces the row). The exact-match variant only
638
+ * ever removes the row this caller itself put there.
639
+ */
640
+ removeSignalWaiter(waiter: SignalWaiter): Promise<void>;
631
641
  /**
632
642
  * Buffer a signal whose waiter hasn't arrived yet, so the next `waitForSignal(token)` consumes it
633
643
  * instead of it being lost (FIFO per token). Makes signals reliable regardless of timing and
@@ -2373,8 +2383,32 @@ declare class WorkflowEngine {
2373
2383
  /**
2374
2384
  * Resume every suspended run whose durable timer is due. Call periodically (a poller) and on
2375
2385
  * boot. A run still not due re-suspends cheaply without running new work.
2386
+ *
2387
+ * Piggy-backs the signal-waiter reconcile safety net (see {@link reconcileSignalWaiter}): a crash
2388
+ * between the waiter/buffer pairing ops on EITHER side (this run's own `putSignalWaiter` →
2389
+ * `takeBufferedSignal`, or a signaler's `takeSignalWaiter` → `bufferSignal` → re-check — see
2390
+ * `signal`'s interleaving proof) can leave both rows stranded with nothing left to pair them: the
2391
+ * waiter's turn already suspended, the signaler that buffered it already returned. Event-wait
2392
+ * suspends already carry a `reconcileMs` fallback `wakeAt` (see {@link reconcileWakeAt}) precisely so
2393
+ * they show up HERE — that's what makes this due-timer pass the natural place for the sweep. One
2394
+ * bulk waiter scan, filtered to just this tick's batch, keeps it cheap regardless of store size.
2376
2395
  */
2377
2396
  resumeDueTimers(nowMs?: number): Promise<RunResult[]>;
2397
+ /**
2398
+ * The reconcile safety net's per-run check (see {@link resumeDueTimers}): if this suspended run has
2399
+ * a registered signal waiter AND its token already has a buffered payload, pair and deliver them
2400
+ * directly instead of falling through to a full replay. Event/child/breakpoint waiter tokens are
2401
+ * never buffered (`publishEvent` doesn't buffer, and a child/breakpoint notifies via `signal` only
2402
+ * once its counterpart is already registered) — so `takeBufferedSignal` on those is a guaranteed
2403
+ * miss, and this is naturally signal-only without needing to classify the token.
2404
+ *
2405
+ * Returns `undefined` (not settled — `resumeLeased` proceeds with its normal `resume`) when there's
2406
+ * no waiter or no buffered hit. Deliberately never returns `null`: a delivered signal always resumes
2407
+ * via {@link deliverSignal}, and even a `NamespaceMismatch` there is reported as a real (non-nullish)
2408
+ * result — returning `null` would read as "not settled" to `resumeLeased`'s `??` fallback and
2409
+ * trigger a second, redundant `resume` call on the same run.
2410
+ */
2411
+ private reconcileSignalWaiter;
2378
2412
  /**
2379
2413
  * Lease and execute one run by id — the worker side of dispatch. Acquires the recovery lease (so
2380
2414
  * exactly one instance runs it), then resumes the body. Returns the result, or null if another
@@ -2447,6 +2481,37 @@ declare class WorkflowEngine {
2447
2481
  publishEvent(name: string, payload: unknown, opts?: {
2448
2482
  id?: string;
2449
2483
  }): Promise<number>;
2484
+ /**
2485
+ * Deliver a signal to an already-known waiter: write its resolving `signal:<token>` checkpoint and
2486
+ * resume the run. Shared by {@link signal}'s direct hit, its post-buffer re-check reclaim, and the
2487
+ * due-timer reconcile sweep ({@link reconcileSignalWaiter}) — all three resolve a waiter the exact
2488
+ * same way.
2489
+ */
2490
+ private deliverSignal;
2491
+ /**
2492
+ * Deliver an external signal to the run waiting on `token`, resume it with `payload`, and return
2493
+ * the run result — or null if no run is (or ends up) waiting for it.
2494
+ *
2495
+ * Race with the waiter side (`waitForSignal`'s check → `putSignalWaiter` → re-check, same shape in
2496
+ * the remote `waitSignal` command): a waiter can register in the sliver between this method's
2497
+ * initial `takeSignalWaiter` miss and its `bufferSignal` write, and the naive old flow (buffer and
2498
+ * return) would then leave BOTH a buffered payload and a registered waiter sitting in the store with
2499
+ * nothing left to pair them — a lost wake, forever suspended.
2500
+ *
2501
+ * The interleaving proof: the waiter side does check → put → re-check, this side does take → buffer
2502
+ * → re-check. Whichever side's SECOND look runs last is guaranteed to observe the other side's
2503
+ * write, because it always follows that write in real time:
2504
+ * - If the waiter's `put` lands before this method's first `take`, the take just finds it directly
2505
+ * (the pre-existing, non-racy path).
2506
+ * - If the waiter's `put` lands after this method's `bufferSignal`, our re-check below either sees
2507
+ * it (if `put` also lands before the re-check) or the waiter's OWN post-put re-check sees our
2508
+ * buffer (if `put` lands after our re-check but the waiter hasn't re-checked yet either — its
2509
+ * re-check strictly follows its own put).
2510
+ * - If the waiter's `put` lands between this method's `take` and its re-`take`, the re-check below
2511
+ * catches it.
2512
+ * Either way, the destructive `take*` ops (on the waiter row, on the buffer) arbitrate: whichever
2513
+ * side actually removes the row is the one that delivers, so a signal is never delivered twice.
2514
+ */
2450
2515
  signal(token: string, payload: unknown): Promise<RunResult | null>;
2451
2516
  /**
2452
2517
  * Ensure a run exists for `runId`, then deliver a signal to it — atomically race-free thanks to
@@ -3155,6 +3220,7 @@ declare class CodecStateStore implements StateStore {
3155
3220
  putSignalWaiter(waiter: SignalWaiter): Promise<void>;
3156
3221
  takeSignalWaiter(token: string): Promise<SignalWaiter | null>;
3157
3222
  listSignalWaiters(prefix: string): Promise<SignalWaiter[]>;
3223
+ removeSignalWaiter(waiter: SignalWaiter): Promise<void>;
3158
3224
  bufferSignal(token: string, payload: unknown): Promise<void>;
3159
3225
  takeBufferedSignal(token: string): Promise<{
3160
3226
  payload: unknown;
@@ -3522,6 +3588,7 @@ declare class InMemoryStateStore implements StateStore {
3522
3588
  putSignalWaiter(waiter: SignalWaiter): Promise<void>;
3523
3589
  takeSignalWaiter(token: string): Promise<SignalWaiter | null>;
3524
3590
  listSignalWaiters(prefix: string): Promise<SignalWaiter[]>;
3591
+ removeSignalWaiter(waiter: SignalWaiter): Promise<void>;
3525
3592
  transaction<T>(work: (tx: {
3526
3593
  raw: unknown;
3527
3594
  saveCheckpoint: (cp: StepCheckpoint) => Promise<void>;
package/dist/index.d.ts CHANGED
@@ -628,6 +628,16 @@ interface StateStore {
628
628
  takeSignalWaiter(token: string): Promise<SignalWaiter | null>;
629
629
  /** List waiters whose `token` starts with `prefix` — used to fan out an event to its subscribers. */
630
630
  listSignalWaiters(prefix: string): Promise<SignalWaiter[]>;
631
+ /**
632
+ * Delete the EXACT waiter row — `token` AND `runId` AND `seq` must all match — no-op if absent.
633
+ * Unlike {@link takeSignalWaiter}, which deletes ANY row for `token` (fine when the caller just won
634
+ * the race to consume it), this is for a caller removing its OWN registration after resolving the
635
+ * wait some other way (a buffered hit, a timeout): blind `takeSignalWaiter(token)` there could steal
636
+ * a DIFFERENT run's waiter that has since claimed the same token (`token` is the store's primary key,
637
+ * so a later `putSignalWaiter` for the same token replaces the row). The exact-match variant only
638
+ * ever removes the row this caller itself put there.
639
+ */
640
+ removeSignalWaiter(waiter: SignalWaiter): Promise<void>;
631
641
  /**
632
642
  * Buffer a signal whose waiter hasn't arrived yet, so the next `waitForSignal(token)` consumes it
633
643
  * instead of it being lost (FIFO per token). Makes signals reliable regardless of timing and
@@ -2373,8 +2383,32 @@ declare class WorkflowEngine {
2373
2383
  /**
2374
2384
  * Resume every suspended run whose durable timer is due. Call periodically (a poller) and on
2375
2385
  * boot. A run still not due re-suspends cheaply without running new work.
2386
+ *
2387
+ * Piggy-backs the signal-waiter reconcile safety net (see {@link reconcileSignalWaiter}): a crash
2388
+ * between the waiter/buffer pairing ops on EITHER side (this run's own `putSignalWaiter` →
2389
+ * `takeBufferedSignal`, or a signaler's `takeSignalWaiter` → `bufferSignal` → re-check — see
2390
+ * `signal`'s interleaving proof) can leave both rows stranded with nothing left to pair them: the
2391
+ * waiter's turn already suspended, the signaler that buffered it already returned. Event-wait
2392
+ * suspends already carry a `reconcileMs` fallback `wakeAt` (see {@link reconcileWakeAt}) precisely so
2393
+ * they show up HERE — that's what makes this due-timer pass the natural place for the sweep. One
2394
+ * bulk waiter scan, filtered to just this tick's batch, keeps it cheap regardless of store size.
2376
2395
  */
2377
2396
  resumeDueTimers(nowMs?: number): Promise<RunResult[]>;
2397
+ /**
2398
+ * The reconcile safety net's per-run check (see {@link resumeDueTimers}): if this suspended run has
2399
+ * a registered signal waiter AND its token already has a buffered payload, pair and deliver them
2400
+ * directly instead of falling through to a full replay. Event/child/breakpoint waiter tokens are
2401
+ * never buffered (`publishEvent` doesn't buffer, and a child/breakpoint notifies via `signal` only
2402
+ * once its counterpart is already registered) — so `takeBufferedSignal` on those is a guaranteed
2403
+ * miss, and this is naturally signal-only without needing to classify the token.
2404
+ *
2405
+ * Returns `undefined` (not settled — `resumeLeased` proceeds with its normal `resume`) when there's
2406
+ * no waiter or no buffered hit. Deliberately never returns `null`: a delivered signal always resumes
2407
+ * via {@link deliverSignal}, and even a `NamespaceMismatch` there is reported as a real (non-nullish)
2408
+ * result — returning `null` would read as "not settled" to `resumeLeased`'s `??` fallback and
2409
+ * trigger a second, redundant `resume` call on the same run.
2410
+ */
2411
+ private reconcileSignalWaiter;
2378
2412
  /**
2379
2413
  * Lease and execute one run by id — the worker side of dispatch. Acquires the recovery lease (so
2380
2414
  * exactly one instance runs it), then resumes the body. Returns the result, or null if another
@@ -2447,6 +2481,37 @@ declare class WorkflowEngine {
2447
2481
  publishEvent(name: string, payload: unknown, opts?: {
2448
2482
  id?: string;
2449
2483
  }): Promise<number>;
2484
+ /**
2485
+ * Deliver a signal to an already-known waiter: write its resolving `signal:<token>` checkpoint and
2486
+ * resume the run. Shared by {@link signal}'s direct hit, its post-buffer re-check reclaim, and the
2487
+ * due-timer reconcile sweep ({@link reconcileSignalWaiter}) — all three resolve a waiter the exact
2488
+ * same way.
2489
+ */
2490
+ private deliverSignal;
2491
+ /**
2492
+ * Deliver an external signal to the run waiting on `token`, resume it with `payload`, and return
2493
+ * the run result — or null if no run is (or ends up) waiting for it.
2494
+ *
2495
+ * Race with the waiter side (`waitForSignal`'s check → `putSignalWaiter` → re-check, same shape in
2496
+ * the remote `waitSignal` command): a waiter can register in the sliver between this method's
2497
+ * initial `takeSignalWaiter` miss and its `bufferSignal` write, and the naive old flow (buffer and
2498
+ * return) would then leave BOTH a buffered payload and a registered waiter sitting in the store with
2499
+ * nothing left to pair them — a lost wake, forever suspended.
2500
+ *
2501
+ * The interleaving proof: the waiter side does check → put → re-check, this side does take → buffer
2502
+ * → re-check. Whichever side's SECOND look runs last is guaranteed to observe the other side's
2503
+ * write, because it always follows that write in real time:
2504
+ * - If the waiter's `put` lands before this method's first `take`, the take just finds it directly
2505
+ * (the pre-existing, non-racy path).
2506
+ * - If the waiter's `put` lands after this method's `bufferSignal`, our re-check below either sees
2507
+ * it (if `put` also lands before the re-check) or the waiter's OWN post-put re-check sees our
2508
+ * buffer (if `put` lands after our re-check but the waiter hasn't re-checked yet either — its
2509
+ * re-check strictly follows its own put).
2510
+ * - If the waiter's `put` lands between this method's `take` and its re-`take`, the re-check below
2511
+ * catches it.
2512
+ * Either way, the destructive `take*` ops (on the waiter row, on the buffer) arbitrate: whichever
2513
+ * side actually removes the row is the one that delivers, so a signal is never delivered twice.
2514
+ */
2450
2515
  signal(token: string, payload: unknown): Promise<RunResult | null>;
2451
2516
  /**
2452
2517
  * Ensure a run exists for `runId`, then deliver a signal to it — atomically race-free thanks to
@@ -3155,6 +3220,7 @@ declare class CodecStateStore implements StateStore {
3155
3220
  putSignalWaiter(waiter: SignalWaiter): Promise<void>;
3156
3221
  takeSignalWaiter(token: string): Promise<SignalWaiter | null>;
3157
3222
  listSignalWaiters(prefix: string): Promise<SignalWaiter[]>;
3223
+ removeSignalWaiter(waiter: SignalWaiter): Promise<void>;
3158
3224
  bufferSignal(token: string, payload: unknown): Promise<void>;
3159
3225
  takeBufferedSignal(token: string): Promise<{
3160
3226
  payload: unknown;
@@ -3522,6 +3588,7 @@ declare class InMemoryStateStore implements StateStore {
3522
3588
  putSignalWaiter(waiter: SignalWaiter): Promise<void>;
3523
3589
  takeSignalWaiter(token: string): Promise<SignalWaiter | null>;
3524
3590
  listSignalWaiters(prefix: string): Promise<SignalWaiter[]>;
3591
+ removeSignalWaiter(waiter: SignalWaiter): Promise<void>;
3525
3592
  transaction<T>(work: (tx: {
3526
3593
  raw: unknown;
3527
3594
  saveCheckpoint: (cp: StepCheckpoint) => Promise<void>;
package/dist/index.js CHANGED
@@ -865,6 +865,47 @@ var RemoteWorkflowExecutor = class {
865
865
  }
866
866
  };
867
867
 
868
+ // src/run-waiting.ts
869
+ var WEBHOOK_PREFIX = "wh:";
870
+ var CHILD_PREFIX = "child:";
871
+ var BREAKPOINT_PREFIX = "bp:";
872
+ var EVENT_PREFIX = "event:";
873
+ function classifyWaiterToken(token) {
874
+ if (token.startsWith(WEBHOOK_PREFIX)) return {
875
+ on: "webhook",
876
+ name: token
877
+ };
878
+ if (token.startsWith(BREAKPOINT_PREFIX)) return {
879
+ on: "breakpoint",
880
+ name: "breakpoint"
881
+ };
882
+ if (token.startsWith(CHILD_PREFIX)) return {
883
+ on: "child",
884
+ name: token.slice(CHILD_PREFIX.length)
885
+ };
886
+ if (token.startsWith(EVENT_PREFIX)) return {
887
+ on: "signal",
888
+ name: eventNameOf(token)
889
+ };
890
+ return {
891
+ on: "signal",
892
+ name: token
893
+ };
894
+ }
895
+ __name(classifyWaiterToken, "classifyWaiterToken");
896
+ function resolveRunWaiting(run, waiterByRun) {
897
+ if (run.status !== "suspended") return void 0;
898
+ const waiter = waiterByRun.get(run.id);
899
+ return waiter ? classifyWaiterToken(waiter.token) : void 0;
900
+ }
901
+ __name(resolveRunWaiting, "resolveRunWaiting");
902
+ function indexWaitersByRun(waiters) {
903
+ const byRun = /* @__PURE__ */ new Map();
904
+ for (const w of waiters) if (!byRun.has(w.runId)) byRun.set(w.runId, w);
905
+ return byRun;
906
+ }
907
+ __name(indexWaitersByRun, "indexWaitersByRun");
908
+
868
909
  // src/singleton-gate.ts
869
910
  var SINGLETON_RETRY_MS = 1e3;
870
911
  var SINGLETON_RETRY_JITTER_MS = 250;
@@ -1320,6 +1361,15 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
1320
1361
  runId,
1321
1362
  seq: current
1322
1363
  });
1364
+ const lateBuffered2 = await consumeBuffered(token, current);
1365
+ if (lateBuffered2) {
1366
+ await store.removeSignalWaiter({
1367
+ token,
1368
+ runId,
1369
+ seq: current
1370
+ });
1371
+ return lateBuffered2.value;
1372
+ }
1323
1373
  throw new WorkflowSuspended();
1324
1374
  }
1325
1375
  const timeoutMs = opts.timeoutMs;
@@ -1331,7 +1381,11 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
1331
1381
  const buffered = await consumeBuffered(token, waitSeq);
1332
1382
  if (buffered) return buffered.value;
1333
1383
  if (host.clock() >= deadline) {
1334
- await store.takeSignalWaiter(token).catch(() => void 0);
1384
+ await store.removeSignalWaiter({
1385
+ token,
1386
+ runId,
1387
+ seq: waitSeq
1388
+ }).catch(() => void 0);
1335
1389
  throw new SignalTimeoutError(token, timeoutMs);
1336
1390
  }
1337
1391
  await store.putSignalWaiter({
@@ -1339,6 +1393,15 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
1339
1393
  runId,
1340
1394
  seq: waitSeq
1341
1395
  });
1396
+ const lateBuffered = await consumeBuffered(token, waitSeq);
1397
+ if (lateBuffered) {
1398
+ await store.removeSignalWaiter({
1399
+ token,
1400
+ runId,
1401
+ seq: waitSeq
1402
+ });
1403
+ return lateBuffered.value;
1404
+ }
1342
1405
  throw new WorkflowSuspended(deadline);
1343
1406
  }, "waitForSignal");
1344
1407
  const waitForEvent = /* @__PURE__ */ __name(async (name, opts) => {
@@ -1362,7 +1425,11 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
1362
1425
  const waited = await readCheckpoint(waitSeq);
1363
1426
  if (waited && waited.status === "completed") return waited.output;
1364
1427
  if (host.clock() >= deadline) {
1365
- await store.takeSignalWaiter(token).catch(() => void 0);
1428
+ await store.removeSignalWaiter({
1429
+ token,
1430
+ runId,
1431
+ seq: waitSeq
1432
+ }).catch(() => void 0);
1366
1433
  throw new SignalTimeoutError(`event:${name}`, timeoutMs);
1367
1434
  }
1368
1435
  await store.putSignalWaiter({
@@ -2508,9 +2575,46 @@ var WorkflowEngine = class {
2508
2575
  /**
2509
2576
  * Resume every suspended run whose durable timer is due. Call periodically (a poller) and on
2510
2577
  * boot. A run still not due re-suspends cheaply without running new work.
2578
+ *
2579
+ * Piggy-backs the signal-waiter reconcile safety net (see {@link reconcileSignalWaiter}): a crash
2580
+ * between the waiter/buffer pairing ops on EITHER side (this run's own `putSignalWaiter` →
2581
+ * `takeBufferedSignal`, or a signaler's `takeSignalWaiter` → `bufferSignal` → re-check — see
2582
+ * `signal`'s interleaving proof) can leave both rows stranded with nothing left to pair them: the
2583
+ * waiter's turn already suspended, the signaler that buffered it already returned. Event-wait
2584
+ * suspends already carry a `reconcileMs` fallback `wakeAt` (see {@link reconcileWakeAt}) precisely so
2585
+ * they show up HERE — that's what makes this due-timer pass the natural place for the sweep. One
2586
+ * bulk waiter scan, filtered to just this tick's batch, keeps it cheap regardless of store size.
2511
2587
  */
2512
2588
  async resumeDueTimers(nowMs = this.clock()) {
2513
- return this.resumeLeased(await this.store.listDueTimers(nowMs, this.namespace), nowMs);
2589
+ const due = await this.store.listDueTimers(nowMs, this.namespace);
2590
+ if (due.length === 0) return [];
2591
+ const waiterByRun = indexWaitersByRun(await this.store.listSignalWaiters(""));
2592
+ return this.resumeLeased(due, nowMs, (run) => this.reconcileSignalWaiter(run, waiterByRun.get(run.id)));
2593
+ }
2594
+ /**
2595
+ * The reconcile safety net's per-run check (see {@link resumeDueTimers}): if this suspended run has
2596
+ * a registered signal waiter AND its token already has a buffered payload, pair and deliver them
2597
+ * directly instead of falling through to a full replay. Event/child/breakpoint waiter tokens are
2598
+ * never buffered (`publishEvent` doesn't buffer, and a child/breakpoint notifies via `signal` only
2599
+ * once its counterpart is already registered) — so `takeBufferedSignal` on those is a guaranteed
2600
+ * miss, and this is naturally signal-only without needing to classify the token.
2601
+ *
2602
+ * Returns `undefined` (not settled — `resumeLeased` proceeds with its normal `resume`) when there's
2603
+ * no waiter or no buffered hit. Deliberately never returns `null`: a delivered signal always resumes
2604
+ * via {@link deliverSignal}, and even a `NamespaceMismatch` there is reported as a real (non-nullish)
2605
+ * result — returning `null` would read as "not settled" to `resumeLeased`'s `??` fallback and
2606
+ * trigger a second, redundant `resume` call on the same run.
2607
+ */
2608
+ async reconcileSignalWaiter(run, waiter) {
2609
+ if (!waiter) return void 0;
2610
+ const buffered = await this.store.takeBufferedSignal(waiter.token);
2611
+ if (!buffered) return void 0;
2612
+ await this.store.removeSignalWaiter(waiter);
2613
+ const settled = await this.deliverSignal(waiter, buffered.payload);
2614
+ return settled ?? {
2615
+ runId: run.id,
2616
+ status: run.status
2617
+ };
2514
2618
  }
2515
2619
  /**
2516
2620
  * Lease and execute one run by id — the worker side of dispatch. Acquires the recovery lease (so
@@ -2728,16 +2832,17 @@ var WorkflowEngine = class {
2728
2832
  }
2729
2833
  return touched;
2730
2834
  }
2731
- async signal(token, payload) {
2732
- const waiter = await this.store.takeSignalWaiter(token);
2733
- if (!waiter) {
2734
- await this.store.bufferSignal(token, payload);
2735
- return null;
2736
- }
2835
+ /**
2836
+ * Deliver a signal to an already-known waiter: write its resolving `signal:<token>` checkpoint and
2837
+ * resume the run. Shared by {@link signal}'s direct hit, its post-buffer re-check reclaim, and the
2838
+ * due-timer reconcile sweep ({@link reconcileSignalWaiter}) — all three resolve a waiter the exact
2839
+ * same way.
2840
+ */
2841
+ async deliverSignal(waiter, payload) {
2737
2842
  await this.store.saveCheckpoint(instantCheckpoint({
2738
2843
  runId: waiter.runId,
2739
2844
  seq: waiter.seq,
2740
- name: `signal:${token}`,
2845
+ name: `signal:${waiter.token}`,
2741
2846
  kind: "signal",
2742
2847
  output: payload,
2743
2848
  // Carry the awaiting command's fan group (set when a `ctx.gather_children`/`ctx.all` fan-out
@@ -2752,6 +2857,42 @@ var WorkflowEngine = class {
2752
2857
  });
2753
2858
  }
2754
2859
  /**
2860
+ * Deliver an external signal to the run waiting on `token`, resume it with `payload`, and return
2861
+ * the run result — or null if no run is (or ends up) waiting for it.
2862
+ *
2863
+ * Race with the waiter side (`waitForSignal`'s check → `putSignalWaiter` → re-check, same shape in
2864
+ * the remote `waitSignal` command): a waiter can register in the sliver between this method's
2865
+ * initial `takeSignalWaiter` miss and its `bufferSignal` write, and the naive old flow (buffer and
2866
+ * return) would then leave BOTH a buffered payload and a registered waiter sitting in the store with
2867
+ * nothing left to pair them — a lost wake, forever suspended.
2868
+ *
2869
+ * The interleaving proof: the waiter side does check → put → re-check, this side does take → buffer
2870
+ * → re-check. Whichever side's SECOND look runs last is guaranteed to observe the other side's
2871
+ * write, because it always follows that write in real time:
2872
+ * - If the waiter's `put` lands before this method's first `take`, the take just finds it directly
2873
+ * (the pre-existing, non-racy path).
2874
+ * - If the waiter's `put` lands after this method's `bufferSignal`, our re-check below either sees
2875
+ * it (if `put` also lands before the re-check) or the waiter's OWN post-put re-check sees our
2876
+ * buffer (if `put` lands after our re-check but the waiter hasn't re-checked yet either — its
2877
+ * re-check strictly follows its own put).
2878
+ * - If the waiter's `put` lands between this method's `take` and its re-`take`, the re-check below
2879
+ * catches it.
2880
+ * Either way, the destructive `take*` ops (on the waiter row, on the buffer) arbitrate: whichever
2881
+ * side actually removes the row is the one that delivers, so a signal is never delivered twice.
2882
+ */
2883
+ async signal(token, payload) {
2884
+ const waiter = await this.store.takeSignalWaiter(token);
2885
+ if (waiter) return this.deliverSignal(waiter, payload);
2886
+ await this.store.bufferSignal(token, payload);
2887
+ const lateWaiter = await this.store.takeSignalWaiter(token);
2888
+ if (!lateWaiter) return null;
2889
+ const reclaimed = await this.store.takeBufferedSignal(token);
2890
+ if (!reclaimed) {
2891
+ return null;
2892
+ }
2893
+ return this.deliverSignal(lateWaiter, reclaimed.payload);
2894
+ }
2895
+ /**
2755
2896
  * Ensure a run exists for `runId`, then deliver a signal to it — atomically race-free thanks to
2756
2897
  * signal buffering: if the run is new (or busy / not yet waiting), the signal is buffered and
2757
2898
  * consumed when it reaches `waitForSignal(token)`. The canonical **durable-entity / accumulator**
@@ -3224,6 +3365,14 @@ var WorkflowEngine = class {
3224
3365
  error: outcome.error
3225
3366
  };
3226
3367
  }
3368
+ const latest = await this.store.getRun(run.id);
3369
+ if (latest?.status === "cancelled") {
3370
+ return {
3371
+ runId: run.id,
3372
+ status: "cancelled",
3373
+ error: latest.error
3374
+ };
3375
+ }
3227
3376
  await this.store.updateRun(run.id, {
3228
3377
  status: "suspended",
3229
3378
  wakeAt: this.reconcileWakeAt(outcome.wakeAt),
@@ -3566,22 +3715,34 @@ var WorkflowEngine = class {
3566
3715
  }));
3567
3716
  wakeAt = wakeAt == null ? deadline : Math.min(wakeAt, deadline);
3568
3717
  } else if (cmd.kind === "waitSignal") {
3569
- const buffered = await this.store.takeBufferedSignal(cmd.signal);
3570
- if (buffered) {
3718
+ const deliverBuffered = /* @__PURE__ */ __name(async (payload) => {
3571
3719
  await this.store.saveCheckpoint(instantCheckpoint({
3572
3720
  runId: run.id,
3573
3721
  seq: cmd.seq,
3574
3722
  name: `signal:${cmd.signal}`,
3575
3723
  kind: "signal",
3576
- output: buffered.payload
3724
+ output: payload
3577
3725
  }));
3578
3726
  setTimeout(() => void this.resume(run.id).catch(() => void 0), 0);
3727
+ }, "deliverBuffered");
3728
+ const buffered = await this.store.takeBufferedSignal(cmd.signal);
3729
+ if (buffered) {
3730
+ await deliverBuffered(buffered.payload);
3579
3731
  } else {
3580
3732
  await this.store.putSignalWaiter({
3581
3733
  token: cmd.signal,
3582
3734
  runId: run.id,
3583
3735
  seq: cmd.seq
3584
3736
  });
3737
+ const lateBuffered = await this.store.takeBufferedSignal(cmd.signal);
3738
+ if (lateBuffered) {
3739
+ await this.store.removeSignalWaiter({
3740
+ token: cmd.signal,
3741
+ runId: run.id,
3742
+ seq: cmd.seq
3743
+ });
3744
+ await deliverBuffered(lateBuffered.payload);
3745
+ }
3585
3746
  }
3586
3747
  } else if (cmd.kind === "startChild") {
3587
3748
  const childId = `${run.id}.child.${cmd.seq}`;
@@ -4441,47 +4602,6 @@ var TERMINAL_RUN_STATUSES = [
4441
4602
  "dead"
4442
4603
  ];
4443
4604
 
4444
- // src/run-waiting.ts
4445
- var WEBHOOK_PREFIX = "wh:";
4446
- var CHILD_PREFIX = "child:";
4447
- var BREAKPOINT_PREFIX = "bp:";
4448
- var EVENT_PREFIX = "event:";
4449
- function classifyWaiterToken(token) {
4450
- if (token.startsWith(WEBHOOK_PREFIX)) return {
4451
- on: "webhook",
4452
- name: token
4453
- };
4454
- if (token.startsWith(BREAKPOINT_PREFIX)) return {
4455
- on: "breakpoint",
4456
- name: "breakpoint"
4457
- };
4458
- if (token.startsWith(CHILD_PREFIX)) return {
4459
- on: "child",
4460
- name: token.slice(CHILD_PREFIX.length)
4461
- };
4462
- if (token.startsWith(EVENT_PREFIX)) return {
4463
- on: "signal",
4464
- name: eventNameOf(token)
4465
- };
4466
- return {
4467
- on: "signal",
4468
- name: token
4469
- };
4470
- }
4471
- __name(classifyWaiterToken, "classifyWaiterToken");
4472
- function resolveRunWaiting(run, waiterByRun) {
4473
- if (run.status !== "suspended") return void 0;
4474
- const waiter = waiterByRun.get(run.id);
4475
- return waiter ? classifyWaiterToken(waiter.token) : void 0;
4476
- }
4477
- __name(resolveRunWaiting, "resolveRunWaiting");
4478
- function indexWaitersByRun(waiters) {
4479
- const byRun = /* @__PURE__ */ new Map();
4480
- for (const w of waiters) if (!byRun.has(w.runId)) byRun.set(w.runId, w);
4481
- return byRun;
4482
- }
4483
- __name(indexWaitersByRun, "indexWaitersByRun");
4484
-
4485
4605
  // src/codec-state-store.ts
4486
4606
  var CodecStateStore = class {
4487
4607
  static {
@@ -4589,6 +4709,9 @@ var CodecStateStore = class {
4589
4709
  listSignalWaiters(prefix) {
4590
4710
  return this.inner.listSignalWaiters(prefix);
4591
4711
  }
4712
+ removeSignalWaiter(waiter) {
4713
+ return this.inner.removeSignalWaiter(waiter);
4714
+ }
4592
4715
  bufferSignal(token, payload) {
4593
4716
  return this.inner.bufferSignal(token, this.enc(payload));
4594
4717
  }
@@ -5041,6 +5164,12 @@ var InMemoryStateStore = class {
5041
5164
  ...w
5042
5165
  }));
5043
5166
  }
5167
+ async removeSignalWaiter(waiter) {
5168
+ const current = this.signalWaiters.get(waiter.token);
5169
+ if (current && current.runId === waiter.runId && current.seq === waiter.seq) {
5170
+ this.signalWaiters.delete(waiter.token);
5171
+ }
5172
+ }
5044
5173
  // No real DB transaction (there's no DB) — run the work and save the checkpoint. Exactly-once
5045
5174
  // across a crash needs a SQL store; this keeps `ctx.transaction` usable in tests / local dev.
5046
5175
  async transaction(work) {