@adonis-agora/durable 0.40.0 → 0.41.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.
@@ -1,6 +1,6 @@
1
1
  import { InMemoryAdmissionBackend } from './admission.js';
2
2
  import { backoffDelay, MAX_BACKOFF_MS } from './backoff.js';
3
- import { instantCheckpoint, stepCheckpoint } from './checkpoints.js';
3
+ import { instantCheckpoint, lostStepEvent, stepCheckpoint } from './checkpoints.js';
4
4
  import { controlPlaneDescriptor, planDispatch, WorkflowBlocked, } from './dispatch-routing.js';
5
5
  import { parseDuration } from './duration.js';
6
6
  import { Entities } from './entities.js';
@@ -305,9 +305,16 @@ export class WorkflowEngine {
305
305
  await this.completeRemoteResult(result);
306
306
  },
307
307
  // A heartbeat for an in-flight long step resets its liveness window (see callRemote) and is
308
- // persisted (throttled) on the step's checkpoint so liveness is visible cross-process.
308
+ // persisted (throttled) on the step's checkpoint so liveness is visible cross-process. With no
309
+ // in-memory waiter (the step suspended DURABLY, possibly on another instance) the beat rearms
310
+ // the step's durable LEASE instead, so a worker still holding a long step is never presumed
311
+ // lost and double-dispatched (see rearmStepLease).
309
312
  async (beat) => {
310
- this.heartbeatResets.get(beat.stepId)?.();
313
+ const reset = this.heartbeatResets.get(beat.stepId);
314
+ if (reset)
315
+ reset();
316
+ else
317
+ await this.rearmStepLease(beat.runId, beat.seq);
311
318
  this.persistHeartbeat(beat);
312
319
  },
313
320
  // A remote workflow worker streams each local step's lifecycle (running → completed/failed) so
@@ -1270,14 +1277,30 @@ export class WorkflowEngine {
1270
1277
  }
1271
1278
  return (await this.store.listIncompleteRuns(this.namespace)).filter((r) => r.lockedUntil === undefined || r.lockedUntil <= nowMs);
1272
1279
  });
1273
- for (const run of incomplete) {
1280
+ for (const listed of incomplete) {
1274
1281
  // A live worker renews its lease, so an acquirable lease means the run is genuinely orphaned
1275
1282
  // (its worker crashed). Skip the ones still owned.
1276
- const acquired = await this.store.tryLockRun(run.id, this.instanceId, nowMs + this.leaseMs, nowMs);
1283
+ const acquired = await this.store.tryLockRun(listed.id, this.instanceId, nowMs + this.leaseMs, nowMs);
1277
1284
  if (!acquired)
1278
1285
  continue;
1286
+ // Decide on the run as it is NOW that the lease is held, never on the listing. The listing is a
1287
+ // snapshot taken before this loop reached the row, and a live run moves on in between: the turn
1288
+ // that was executing it settles and releases its lease, a fast step result resumes it, it may
1289
+ // even complete. Trusting the listed `running` reads that as a crashed turn — and the code below
1290
+ // counts a recovery attempt (or, past `maxRecoveryAttempts`, writes `dead` over a live or
1291
+ // finished run) and re-enqueues it for a second execution. Only a run that still reads
1292
+ // `running` under the lease is an orphan; release the lease taken just to look at anything else.
1293
+ const run = await this.store.getRun(listed.id);
1294
+ if (run?.status !== 'running') {
1295
+ await this.store.releaseRunLock(listed.id, this.instanceId);
1296
+ continue;
1297
+ }
1279
1298
  // Count the attempt / dead-letter a poison pill past maxRecoveryAttempts.
1280
1299
  const settled = await this.countRecovery(run);
1300
+ // `null`: the run left `running` between the re-read above and the dead-letter write (a
1301
+ // lease-free `cancel()`), so it is not ours to touch — countRecovery already released the lease.
1302
+ if (settled === null)
1303
+ continue;
1281
1304
  if (settled) {
1282
1305
  results.push(settled);
1283
1306
  continue;
@@ -1306,7 +1329,8 @@ export class WorkflowEngine {
1306
1329
  /**
1307
1330
  * Per-recovery bookkeeping (called once the lease is held): count the attempt, or — past
1308
1331
  * `maxRecoveryAttempts` — move a poison pill to the `dead` dead-letter state. Returns a terminal
1309
- * result to skip the resume, or `undefined` to proceed.
1332
+ * result to skip the resume, `null` to skip it silently (the run is no longer `running`), or
1333
+ * `undefined` to proceed.
1310
1334
  */
1311
1335
  async countRecovery(run) {
1312
1336
  const attempts = (run.recoveryAttempts ?? 0) + 1;
@@ -1315,8 +1339,17 @@ export class WorkflowEngine {
1315
1339
  message: `run exceeded maxRecoveryAttempts (${this.maxRecoveryAttempts}) — moved to dead-letter`,
1316
1340
  code: 'max_recovery_attempts',
1317
1341
  };
1318
- await this.store.updateRun(run.id, { status: 'dead', error, updatedAt: new Date() });
1342
+ // Conditional on still-`running`, like the `pending` flip in recoverIncomplete: the lease does
1343
+ // not fence `cancel()`, which writes `cancelled` without it, so a cancel landing after the
1344
+ // caller's re-read must not be overwritten with `dead`.
1345
+ const applied = await this.store.updateRunIf(run.id, ['running'], {
1346
+ status: 'dead',
1347
+ error,
1348
+ updatedAt: new Date(),
1349
+ });
1319
1350
  await this.store.releaseRunLock(run.id, this.instanceId);
1351
+ if (!applied)
1352
+ return null;
1320
1353
  this.emit({
1321
1354
  type: 'run.failed',
1322
1355
  runId: run.id,
@@ -2429,9 +2462,205 @@ export class WorkflowEngine {
2429
2462
  }
2430
2463
  // continue: persist any local steps the replay ran, dispatch the blocking ops, then suspend. When
2431
2464
  // those resolve (a result lands, a timer fires) `resume` brings us back for the next turn.
2432
- const wakeAt = await this.applyCommands(run, decision.commands);
2465
+ const wakeAt = await this.applyCommands(run, decision.commands, true);
2433
2466
  return this.settleRun(run, { kind: 'suspended', wakeAt });
2434
2467
  }
2468
+ /**
2469
+ * A polyglot workflow's re-emitted `call` whose checkpoint already exists: decide whether the step is
2470
+ * still out for delivery, or whether its dispatch was LOST and has to be re-driven. Returns the
2471
+ * deadline to park the run on (so the next decision lands exactly when it's due), or `undefined` when
2472
+ * there is nothing to wait for.
2473
+ *
2474
+ * WHY THIS EXISTS. A worker process killed mid-step (an OOM kill, exit 137 — a pod's memory ceiling,
2475
+ * not a bug in anyone's code) takes its in-flight job with it, and the transport cannot always put it
2476
+ * back: the `bullmq` transport's terminal-failure bridge (`worker.on('failed')`) only exists inside a
2477
+ * JS consumer, so a first-class Python fleet (see `docs/python.mdx`) has none; the `queue` transport's
2478
+ * reclaim sweep gives up past `maxStalledCount`, and never runs at all on an adapter without
2479
+ * `recoverStalledJobs`. NOTHING then settles the checkpoint. It stays `pending` — "out for delivery" —
2480
+ * and the `applyCommands` idempotency guard, which exists so a `ctx.gather_calls` fan-out doesn't
2481
+ * double-dispatch its still-in-flight siblings on every partial resume, skips it on every later turn.
2482
+ * The run wakes on the reconcile sweep, dispatches a turn, has the call re-emitted, skips it again,
2483
+ * and sleeps. Forever — `pending`, `attempts = 1`, `wakeAt` NULL.
2484
+ *
2485
+ * THE POLICY (mirrors {@link callRemote}'s `ctx.step` self-heal, which this path silently did not
2486
+ * share — so `remoteRedispatchMs` simply did not apply to a fan-out):
2487
+ * - a SETTLED checkpoint always wins: a step that completed just before the crash is never re-run;
2488
+ * - `remoteRedispatchMs` unset (the default) keeps the by-design "re-suspend, never re-dispatch";
2489
+ * - otherwise the pending checkpoint carries a LEASE in `wakeAt` — a result or a step heartbeat
2490
+ * ({@link rearmStepLease}) renews it, so a live worker still holding the step is never
2491
+ * double-dispatched — and only a LAPSED lease re-dispatches, bounded by `remoteRedispatchMax`;
2492
+ * - past the bound the step is failed `remote_step_lost` and the failure enters the run's history
2493
+ * (see {@link remoteHistory}), so the workflow's own error path surfaces it on the next turn
2494
+ * instead of the engine looping. The run is parked to wake immediately for exactly that turn.
2495
+ */
2496
+ async redriveLostCall(run, cp) {
2497
+ // Settled (or a non-remote seq): the checkpoint is the truth, nothing to re-drive.
2498
+ if (cp.kind !== 'remote' || cp.status !== 'pending')
2499
+ return undefined;
2500
+ if (this.remoteRedispatchMs == null)
2501
+ return undefined;
2502
+ // First sight of this pending step under the self-heal: stamp its lease (clock-space, persisted,
2503
+ // stable across replays and crashes) and park the run on it.
2504
+ if (cp.wakeAt == null) {
2505
+ const lease = this.clock() + this.remoteRedispatchMs;
2506
+ await this.store.saveCheckpoint({ ...cp, wakeAt: lease });
2507
+ return this.stepLeaseWakeAt(lease);
2508
+ }
2509
+ if (this.clock() < cp.wakeAt)
2510
+ return this.stepLeaseWakeAt(cp.wakeAt);
2511
+ // The lease lapsed with no result and no heartbeat: the dispatch is presumed lost.
2512
+ if (cp.attempts >= this.remoteRedispatchMax) {
2513
+ const error = {
2514
+ message: `remote step "${cp.name}" lost — no result after ${cp.attempts} dispatch(es)`,
2515
+ code: 'remote_step_lost',
2516
+ retryable: false,
2517
+ };
2518
+ const now = new Date();
2519
+ await this.store.saveCheckpoint({
2520
+ ...cp,
2521
+ status: 'failed',
2522
+ error,
2523
+ wakeAt: undefined,
2524
+ finishedAt: now,
2525
+ events: [...(cp.events ?? []), lostStepEvent(this.clock(), cp.attempts, 'lost')],
2526
+ });
2527
+ this.emit({
2528
+ type: 'step.failed',
2529
+ runId: run.id,
2530
+ seq: cp.seq,
2531
+ name: cp.name,
2532
+ kind: 'remote',
2533
+ error,
2534
+ });
2535
+ // Wake now: the failure is in history, so the very next turn's replay raises it inside the
2536
+ // gather and the run settles on the workflow's own error path.
2537
+ return this.clock();
2538
+ }
2539
+ const attempts = cp.attempts + 1;
2540
+ const lease = this.clock() + this.remoteRedispatchMs;
2541
+ const at = new Date();
2542
+ await this.store.saveCheckpoint({
2543
+ ...cp,
2544
+ attempts,
2545
+ wakeAt: lease,
2546
+ // The re-dispatch restarts the step's wall-clock window; the old one measured a worker that died.
2547
+ enqueuedAt: at,
2548
+ startedAt: at,
2549
+ finishedAt: at,
2550
+ // Observability: the step's own trail says WHY it ran again — a lost worker, not a failure retry.
2551
+ events: [...(cp.events ?? []), lostStepEvent(this.clock(), attempts, 'redispatched')],
2552
+ });
2553
+ await this.dispatchRemoteTask({
2554
+ runId: run.id,
2555
+ seq: cp.seq,
2556
+ name: cp.name,
2557
+ stepId: cp.stepId ?? stepId(run.id, cp.seq),
2558
+ group: cp.workerGroup ?? tenantGroup(sanitizeQueueToken(cp.name), run.namespace),
2559
+ input: cp.input,
2560
+ attempt: attempts,
2561
+ });
2562
+ this.emit({
2563
+ type: 'step.started',
2564
+ runId: run.id,
2565
+ seq: cp.seq,
2566
+ name: cp.name,
2567
+ kind: 'remote',
2568
+ redispatched: true,
2569
+ });
2570
+ return this.stepLeaseWakeAt(lease);
2571
+ }
2572
+ /**
2573
+ * The run `wakeAt` a step lease should park on: the lease itself, but never LATER than the
2574
+ * {@link reconcileMs} safety net would have woken the run anyway. A long lease (a fan-out step that
2575
+ * legitimately runs for half an hour) must not stretch the orphan sweep's cadence for this run — an
2576
+ * early wake just re-parks on the un-lapsed lease, which is cheap and convergent.
2577
+ */
2578
+ stepLeaseWakeAt(lease) {
2579
+ const reconcile = this.reconcileMs;
2580
+ return reconcile == null ? lease : Math.min(lease, this.clock() + reconcile);
2581
+ }
2582
+ /** Earliest clock time a step's lease is worth LOOKING at again (`stepId` → clock ms) — the read
2583
+ * throttle for {@link rearmStepLease}, the sibling of {@link heartbeatPersists}. */
2584
+ stepLeaseRearms = new Map();
2585
+ /**
2586
+ * A step-scoped heartbeat for a durably-suspended remote step: push its LEASE forward so a worker
2587
+ * that is still working on it is never presumed dead and double-dispatched. The durable counterpart
2588
+ * of the in-memory `heartbeatResets` rearm (which only exists on the instance that dispatched a
2589
+ * `timeoutMs` step). No-op unless the lost-dispatch self-heal is on — without `remoteRedispatchMs`
2590
+ * nothing ever re-dispatches, so there is no lease to defend.
2591
+ *
2592
+ * THROTTLED, for the same reason {@link persistHeartbeat} is: beats arrive every few seconds from a
2593
+ * hot batch loop, and defending a lease must not turn into a per-beat SELECT + UPDATE storm. The
2594
+ * throttle is the lease itself rather than a fixed interval — a renewal is only WORTH a write once
2595
+ * the window is half spent, because it buys at most one window either way. So:
2596
+ * - a beat that finds more than half the window still on the clock writes nothing, and records the
2597
+ * half-spent mark so subsequent beats skip even the `getCheckpoint` READ until then;
2598
+ * - a beat at or past the half-spent mark renews, leaving a full half-window of headroom — the
2599
+ * lease can never lapse under a worker whose beats are anywhere near their normal cadence.
2600
+ *
2601
+ * That bounds the cost at ONE read + ONE write per half `remoteRedispatchMs` per in-flight step
2602
+ * (with a 30-minute window: two writes an hour), independent of beat frequency. The in-memory mark
2603
+ * is a pure optimisation — cold on a fresh instance, or on the one that didn't dispatch the step —
2604
+ * and the durable half-spent check re-derives the same answer from the checkpoint, so nothing
2605
+ * depends on it surviving. Cleared wholesale past 1024 entries: a rare extra read, never a leak.
2606
+ *
2607
+ * NEVER REJECTS. This runs on the transport's SERIAL heartbeat handler, so a store hiccup thrown out
2608
+ * of here would take the whole fleet's beat loop with it — and every lease would then lapse at once,
2609
+ * a far worse failure than the write storm the throttle removes. So the entire rearm is best-effort,
2610
+ * exactly like {@link persistHeartbeat}: a missed renewal costs at most one re-drive of a step that
2611
+ * is idempotent by contract, and because the in-memory mark is advanced only AFTER a successful
2612
+ * write, the very next beat retries the renewal rather than waiting out a window.
2613
+ */
2614
+ async rearmStepLease(runId, seq) {
2615
+ if (this.remoteRedispatchMs == null)
2616
+ return;
2617
+ try {
2618
+ await this.renewStepLease(runId, seq);
2619
+ }
2620
+ catch {
2621
+ /* best-effort: a failed renewal must not break the transport's serial heartbeat loop */
2622
+ }
2623
+ }
2624
+ /** {@link rearmStepLease}'s body — every durable read/write of the lease renewal, so the caller's one
2625
+ * guard covers all of them (the read, the checkpoint write, and the run's wake carry-forward). */
2626
+ async renewStepLease(runId, seq) {
2627
+ if (this.remoteRedispatchMs == null)
2628
+ return;
2629
+ const id = stepId(runId, seq);
2630
+ const now = this.clock();
2631
+ const lookAgainAt = this.stepLeaseRearms.get(id);
2632
+ if (lookAgainAt !== undefined && now < lookAgainAt)
2633
+ return;
2634
+ const cp = await this.store.getCheckpoint(runId, seq);
2635
+ if (cp?.kind !== 'remote' || cp.status !== 'pending') {
2636
+ // Settled (or gone): stop tracking it, so a long-lived worker's stale beats don't keep the entry.
2637
+ this.stepLeaseRearms.delete(id);
2638
+ return;
2639
+ }
2640
+ const halfWindow = Math.ceil(this.remoteRedispatchMs / 2);
2641
+ // More than half the window left: nothing to buy, and nothing to look at until it is half spent.
2642
+ if (cp.wakeAt != null && cp.wakeAt > now + halfWindow) {
2643
+ this.stepLeaseRearms.set(id, cp.wakeAt - halfWindow);
2644
+ return;
2645
+ }
2646
+ const renewed = now + this.remoteRedispatchMs;
2647
+ const previous = cp.wakeAt;
2648
+ await this.store.saveCheckpoint({ ...cp, wakeAt: renewed });
2649
+ if (this.stepLeaseRearms.size > 1024)
2650
+ this.stepLeaseRearms.clear();
2651
+ this.stepLeaseRearms.set(id, renewed - halfWindow);
2652
+ // If the run is parked EXACTLY on this step's old lease, carry its wake forward too — otherwise a
2653
+ // beating worker costs one wasted turn per renewal window. Only on an exact match: any other
2654
+ // `wakeAt` belongs to some other timer (a sleep, a sibling's shorter lease) and moving it would
2655
+ // delay that instead. Failing HERE (after the checkpoint write landed) is harmless: the run wakes
2656
+ // on the old lease, finds it un-lapsed, and re-parks on the renewed one.
2657
+ if (previous == null)
2658
+ return;
2659
+ const run = await this.store.getRun(runId);
2660
+ if (run?.status === 'suspended' && run.wakeAt === previous) {
2661
+ await this.store.updateRun(runId, { wakeAt: renewed, updatedAt: new Date() });
2662
+ }
2663
+ }
2435
2664
  /** The run's resolved durable ops as replay inputs: completed/failed steps + elapsed timers. */
2436
2665
  async remoteHistory(runId) {
2437
2666
  const checkpoints = await this.store.listCheckpoints(runId);
@@ -2475,8 +2704,13 @@ export class WorkflowEngine {
2475
2704
  return events.sort((a, b) => a.seq - b.seq);
2476
2705
  }
2477
2706
  /** Apply a turn's commands: persist recorded local steps, dispatch remote calls, schedule timers.
2478
- * Returns the earliest timer deadline to suspend on (or undefined — suspended on a result). */
2479
- async applyCommands(run, commands) {
2707
+ * Returns the earliest timer deadline to suspend on (or undefined — suspended on a result).
2708
+ *
2709
+ * `redriveLost` is set only by the `continue` path: a turn that PARKS on its blocking ops is the one
2710
+ * that may find a re-emitted `call` whose dispatch was lost and needs re-driving (see
2711
+ * {@link redriveLostCall}). A terminal turn (completed/failed/cancelled) applies its commands only to
2712
+ * record what already ran, and must never dispatch anything new. */
2713
+ async applyCommands(run, commands, redriveLost = false) {
2480
2714
  let wakeAt;
2481
2715
  for (const cmd of commands) {
2482
2716
  const at = new Date();
@@ -2519,13 +2753,29 @@ export class WorkflowEngine {
2519
2753
  // already exists — pending OR terminal — skip the save + dispatch entirely; its result lands
2520
2754
  // independently via the remote-result path (keyed by seq, so concurrent calls never clobber).
2521
2755
  // Mirrors the `startChild` `getRun(childId)` guard below.
2522
- if (await this.store.getCheckpoint(run.id, cmd.seq))
2756
+ //
2757
+ // What that guard must NOT do is treat "a checkpoint exists" as "somebody is still going to
2758
+ // deliver it". A dispatched job can be LOST — the worker was killed mid-step, so the job died
2759
+ // with the process and no result will ever come — and the checkpoint still reads `pending`.
2760
+ // Skipping such a command on every later turn is how a fan-out orphans its run (see
2761
+ // {@link redriveLostCall}, which owns that decision; a settled checkpoint always wins).
2762
+ const existing = await this.store.getCheckpoint(run.id, cmd.seq);
2763
+ if (existing) {
2764
+ const leaseAt = redriveLost ? await this.redriveLostCall(run, existing) : undefined;
2765
+ if (leaseAt != null)
2766
+ wakeAt = wakeAt == null ? leaseAt : Math.min(wakeAt, leaseAt);
2523
2767
  continue;
2768
+ }
2524
2769
  // Route this cross-SDK-worker `call` by the SAME name-based token a native `ctx.step`
2525
2770
  // dispatches with (and a worker subscribes to per handler name), so a decision-driven remote
2526
2771
  // step and an in-process one land on the identical queue. The command's own `group` is not a
2527
2772
  // routing input; the run's namespace is, so a tenant's fan-out stays on the tenant's pool.
2528
2773
  const callToken = tenantGroup(sanitizeQueueToken(cmd.name), run.namespace);
2774
+ // The dispatched step's LEASE: when the lost-dispatch self-heal is on (`remoteRedispatchMs`),
2775
+ // stamp the deadline by which a result — or a step heartbeat renewing it — must have landed,
2776
+ // and suspend the run ON it, so the re-drive lands when it's due instead of waiting for the
2777
+ // reconcile sweep. Unset (the default) leaves `wakeAt` NULL exactly as before.
2778
+ const lease = this.remoteRedispatchMs == null ? undefined : this.clock() + this.remoteRedispatchMs;
2529
2779
  await this.store.saveCheckpoint(stepCheckpoint({
2530
2780
  runId: run.id,
2531
2781
  seq: cmd.seq,
@@ -2534,6 +2784,7 @@ export class WorkflowEngine {
2534
2784
  status: 'pending',
2535
2785
  input: cmd.input,
2536
2786
  attempts: 1,
2787
+ wakeAt: lease,
2537
2788
  workerGroup: callToken,
2538
2789
  enqueuedAt: at,
2539
2790
  startedAt: at,
@@ -2561,6 +2812,10 @@ export class WorkflowEngine {
2561
2812
  name: cmd.name,
2562
2813
  kind: 'remote',
2563
2814
  });
2815
+ if (lease != null) {
2816
+ const leaseWake = this.stepLeaseWakeAt(lease);
2817
+ wakeAt = wakeAt == null ? leaseWake : Math.min(wakeAt, leaseWake);
2818
+ }
2564
2819
  }
2565
2820
  else if (cmd.kind === 'sleep') {
2566
2821
  const deadline = this.clock() + cmd.ms;
@@ -2721,19 +2976,26 @@ export class WorkflowEngine {
2721
2976
  for (const cp of snapshot)
2722
2977
  replay.set(cp.seq, cp);
2723
2978
  const ctx = createWorkflowCtx(this.ctxHostFor(replay, run.namespace), run.id, compensations, run.workflow);
2979
+ // Every settle below is `return await`ed, never a bare `return this.settleRun(...)`: inside
2980
+ // `try … finally` a bare return runs the `finally` — which RELEASES THE LEASE — as soon as the
2981
+ // return expression is evaluated, while the settle's own write is still in flight. A first turn
2982
+ // (the one that flipped `pending` -> `running`) then sits unlocked but still reading `running`,
2983
+ // which is exactly the shape `listOrphanedRuns` returns for a crashed turn: the recovery sweep
2984
+ // counted an attempt (or dead-lettered it), flipped it to `pending` and re-enqueued a second
2985
+ // execution. The lease must outlive the state it protects.
2724
2986
  try {
2725
2987
  // Establish the ambient ctx for the duration of this body turn so context-aware statics
2726
2988
  // (`BaseWorkflow.start`/`dispatch`) reachable from `fn` route through this run's `ctx.child`/
2727
2989
  // `ctx.startChild`. Re-set on every replay turn (each is its own async scope) — correct.
2728
2990
  const output = await workflowAls.run(ctx, () => fn(ctx, run.input));
2729
- return this.settleRun(run, { kind: 'completed', output });
2991
+ return await this.settleRun(run, { kind: 'completed', output });
2730
2992
  }
2731
2993
  catch (err) {
2732
2994
  if (err instanceof ContinueAsNew) {
2733
2995
  // Hand off to a fresh execution with a clean history: persist the next run (`<id>~N`), then
2734
2996
  // complete this one. Zombie fence first — a fenced turn must not fork a continuation either.
2735
2997
  if (this.leaseLostRuns.has(run.id))
2736
- return this.echoCurrentStatus(run.id);
2998
+ return await this.echoCurrentStatus(run.id);
2737
2999
  const nextId = nextContinuationId(run.id);
2738
3000
  // Persist the continuation BEFORE the parent's terminal write — the durable ordering that
2739
3001
  // makes the chain crash-safe. The old order (complete parent, then create the continuation
@@ -2770,7 +3032,7 @@ export class WorkflowEngine {
2770
3032
  // No live worker can run the next dispatch (capability/protocol) — park `blocked` with the
2771
3033
  // structured reason + diagnostics instead of suspending on a timer that would re-drive into the
2772
3034
  // same void. The blocked-recovery poll re-drives it when a capable+compatible worker appears.
2773
- return this.parkBlocked(run, err.plan);
3035
+ return await this.parkBlocked(run, err.plan);
2774
3036
  }
2775
3037
  if (err instanceof WorkflowSuspended) {
2776
3038
  // A compensating cancel resumed this run to reach here: the replay re-registered the saga,
@@ -2840,7 +3102,7 @@ export class WorkflowEngine {
2840
3102
  continue;
2841
3103
  await this.runCompensation(run, comp, i);
2842
3104
  }
2843
- return this.settleRun(run, { kind: 'failed', error });
3105
+ return await this.settleRun(run, { kind: 'failed', error });
2844
3106
  }
2845
3107
  finally {
2846
3108
  // Release the recovery lease once the run reaches a terminal/suspended state, so the
@@ -3299,7 +3561,16 @@ export class WorkflowEngine {
3299
3561
  async callRemote(runId, seq, step, input, queue, transport, replay, admission, runNamespace) {
3300
3562
  // Read the prefix from the per-execution snapshot (avoids the O(N²) replay SELECTs); a seq absent
3301
3563
  // from the snapshot — not yet dispatched, or written after the snapshot — falls back to the store.
3302
- const existing = replay?.get(seq) ?? (await this.store.getCheckpoint(runId, seq));
3564
+ let existing = replay?.get(seq) ?? (await this.store.getCheckpoint(runId, seq));
3565
+ // A `pending` step in the SNAPSHOT may already have its result: the snapshot is taken when this
3566
+ // execution starts, and the result can land any time after. Every branch below that sees
3567
+ // `pending` either suspends on it or WRITES the checkpoint back (`{ ...existing, wakeAt }` stamping
3568
+ // the lost-dispatch lease, or a re-dispatch) — written from a stale row, that overwrites the landed
3569
+ // `completed` result with `pending` and parks the run on a lease an hour out. So a pending step is
3570
+ // re-read from the store before anything acts on it (one read, only at a suspension point).
3571
+ if (existing?.status === 'pending' && replay?.has(seq)) {
3572
+ existing = (await this.store.getCheckpoint(runId, seq)) ?? existing;
3573
+ }
3303
3574
  if (existing && existing.name !== step.name) {
3304
3575
  throw new NonDeterminismError(runId, seq, step.name, existing.name);
3305
3576
  }
@@ -3350,6 +3621,12 @@ export class WorkflowEngine {
3350
3621
  enqueuedAt: reEnqueuedAt,
3351
3622
  startedAt: reEnqueuedAt,
3352
3623
  finishedAt: reEnqueuedAt,
3624
+ // Same observable marker the polyglot `call` path stamps (see redriveLostCall): the step's own
3625
+ // trail distinguishes a lost-dispatch re-drive from a failure retry.
3626
+ events: [
3627
+ ...(existing.events ?? []),
3628
+ lostStepEvent(this.clock(), reAttempt, 'redispatched'),
3629
+ ],
3353
3630
  });
3354
3631
  await this.dispatchRemoteTask({
3355
3632
  runId,
@@ -3362,6 +3639,14 @@ export class WorkflowEngine {
3362
3639
  attempt: reAttempt,
3363
3640
  transport,
3364
3641
  });
3642
+ this.emit({
3643
+ type: 'step.started',
3644
+ runId,
3645
+ seq,
3646
+ name: step.name,
3647
+ kind: 'remote',
3648
+ redispatched: true,
3649
+ });
3365
3650
  throw new WorkflowSuspended(nextDeadline, [seq]);
3366
3651
  }
3367
3652
  // Durable retry: a failed attempt re-dispatches up to `retries`, spacing attempts by `backoff` —