@effect-agent/platform-cloudflare 0.1.0-beta.112 → 0.1.0-beta.113

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/src/Alarm.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  Fiber,
10
10
  Layer,
11
11
  Option,
12
+ PubSub,
12
13
  Random,
13
14
  Ref,
14
15
  Result,
@@ -285,6 +286,65 @@ export class ThreadPublication extends Context.Service<
285
286
  });
286
287
  }
287
288
 
289
+ /** Event-local accounting shared by the existing maintenance pumps and native scheduler. */
290
+ export class ThreadMaintenanceActivity extends Context.Service<
291
+ ThreadMaintenanceActivity,
292
+ {
293
+ /**
294
+ * Account for one finite selection/dispatch, through joined completion or interruption.
295
+ * Register before reading/claiming work. After dispatch closes the body is not started.
296
+ * Host waves retain their declared allowance from this call; message Claims keep the driver's
297
+ * own deadline. Waiting for notifications belongs outside this bracket.
298
+ */
299
+ readonly run: <R>(
300
+ wave: Effect.Effect<void, DurableAlarmError, R>,
301
+ ) => Effect.Effect<void, DurableAlarmError, R>;
302
+ /** Acknowledge all initial subscriptions/scans. Composite hosts use `all` below. */
303
+ readonly ready: Effect.Effect<void>;
304
+ /**
305
+ * Acquire an independent scoped subscription before the initial scan and `ready`.
306
+ * The returned wait observes coalesced native scan hints, including those published before
307
+ * its first execution. Each hint requests a current local due-work check.
308
+ */
309
+ readonly subscribeChanges: Effect.Effect<Effect.Effect<void>, never, Scope.Scope>;
310
+ }
311
+ >()("@effect-agent/platform-cloudflare/ThreadMaintenanceActivity") {
312
+ /** Join child pumps; acknowledge the parent only after every child is ready or has exited. */
313
+ static readonly all = Effect.fnUntraced(function* <A, E, R>(
314
+ lanes: ReadonlyArray<Effect.Effect<A, E, R>>,
315
+ ): Effect.fn.Return<ReadonlyArray<A>, E, R | ThreadMaintenanceActivity> {
316
+ const activity = yield* ThreadMaintenanceActivity;
317
+ let remaining = lanes.length;
318
+
319
+ if (remaining === 0) return yield* activity.ready.pipe(Effect.as([]));
320
+
321
+ return yield* Effect.forEach(
322
+ lanes,
323
+ (lane) => {
324
+ let ready = false;
325
+
326
+ const child: ThreadMaintenanceActivity["Service"] = {
327
+ ...activity,
328
+ ready: Effect.suspend(() => {
329
+ if (ready) return Effect.void;
330
+ ready = true;
331
+
332
+ return --remaining === 0 ? activity.ready : Effect.void;
333
+ }).pipe(Effect.uninterruptible),
334
+ };
335
+
336
+ // A failed initial setup is accounted for without swallowing its Cause. Callers
337
+ // may collect Exits when sibling pumps must keep their independent opportunities.
338
+ return lane.pipe(
339
+ Effect.provideService(ThreadMaintenanceActivity, child),
340
+ Effect.onExit(() => child.ready),
341
+ );
342
+ },
343
+ { concurrency: "unbounded" },
344
+ );
345
+ });
346
+ }
347
+
288
348
  /**
289
349
  * Host-assembled native message recovery. The driver bounds each actual Claim and persists its
290
350
  * timeout/retry before this pump returns. Do not add a second timer starting at batch selection:
@@ -294,7 +354,7 @@ export const ThreadMessageDelivery = Context.Reference<{
294
354
  readonly drainUntil: (
295
355
  dispatchClosed: Effect.Effect<void>,
296
356
  dispatchUntil: DateTime.Utc,
297
- ) => Effect.Effect<void, DurableAlarmError, Scope.Scope>;
357
+ ) => Effect.Effect<void, DurableAlarmError, Scope.Scope | ThreadMaintenanceActivity>;
298
358
  readonly pendingDeadline: Effect.Effect<Option.Option<number>, DurableAlarmError>;
299
359
  }>("@effect-agent/platform-cloudflare/ThreadMessageDelivery", {
300
360
  defaultValue: () => ({
@@ -305,14 +365,16 @@ export const ThreadMessageDelivery = Context.Reference<{
305
365
 
306
366
  /**
307
367
  * Application obligations sharing this Object's alarm. Admit one initial external wave even on
308
- * a caught-up pass, then respond to wakes until dispatchClosed. This closes only admission
309
- * of NEW external waves; native work can continue while admitted waves finish. Keep local
310
- * admission/hub subscriptions in the event Scope until maintenance tears it down.
368
+ * a caught-up pass, then respond to wakes and ThreadMaintenanceActivity.subscribeChanges until
369
+ * dispatchClosed. Yield the event's ThreadMaintenanceActivity to bracket each finite lane with
370
+ * run and acknowledge initial setup with ready.
371
+ * Passive subscriptions do not count as active work. Closure stops both new waves and native
372
+ * Attempts; local admission/control subscriptions belong to the event Scope until teardown.
311
373
  * No deadline sleeps or automatic retry loops. Return after already-admitted waves finish.
312
374
  *
313
375
  * Declare a finite whole-wave allowance (1..300000ms): maximum for parallel lanes, sum for
314
376
  * sequential operations. Admit a wave only if its allowance fits before dispatchUntil. Later
315
- * arrivals cannot renew the retirement window. Maintenance bounds the join after dispatchClosed,
377
+ * arrivals cannot renew an admitted wave's allowance. Maintenance bounds each registered wave,
316
378
  * interrupts and joins event Scope, then reads local deadlines under the mutation gate.
317
379
  *
318
380
  * Setup and pendingDeadline are bounded local operations. Persist claims/envelopes before
@@ -326,7 +388,7 @@ export const ThreadHostMaintenance = Context.Reference<{
326
388
  readonly drainUntil: (
327
389
  dispatchClosed: Effect.Effect<void>,
328
390
  dispatchUntil: DateTime.Utc,
329
- ) => Effect.Effect<void, DurableAlarmError, Scope.Scope>;
391
+ ) => Effect.Effect<void, DurableAlarmError, Scope.Scope | ThreadMaintenanceActivity>;
330
392
  readonly pendingDeadline: Effect.Effect<Option.Option<number>, DurableAlarmError>;
331
393
  }>("@effect-agent/platform-cloudflare/ThreadHostMaintenance", {
332
394
  defaultValue: () => ({
@@ -622,10 +684,10 @@ export type MaintenancePassFailure =
622
684
  * recovery, ledger scans or canonical-history reads.
623
685
  * 2. Reconcile before each head Attempt, then checkpoint only the observed generation. A racing
624
686
  * producer keeps its newer generation dirty. Native retries retain their durable backoff.
625
- * 3. Keep delivery admission open while old recovery is pending, so fresh native replies can
626
- * publish in this event. Close once, at latest at the original ten-minute yield deadline;
627
- * native wakes can still advance heads while admitted deliveries retire.
628
- * 4. Native message delivery retains its driver-owned Claim deadline. Host/backfill joins are
687
+ * 3. Keep native and delivery admission open together while finite waves remain active, so
688
+ * fresh replies and abort controls can progress during unrelated cleanup. Close atomically
689
+ * at quiescence, or at the original ten-minute yield deadline, before retiring listeners.
690
+ * 4. Native message delivery retains its driver-owned Claim deadline. Host/backfill waves are
629
691
  * bounded independently; incoming native work never restarts or cancels their attempts.
630
692
  * Auxiliary failures are reported after the current native opportunity.
631
693
  * 5. Close every event resource before the final gated deadline snapshot and alarm decision.
@@ -1392,6 +1454,83 @@ export class ThreadMaintenance extends Context.Service<
1392
1454
  Scope.close(scope, exit),
1393
1455
  );
1394
1456
 
1457
+ const dispatchClosed = yield* Deferred.make<void>();
1458
+ const stopDispatch = Deferred.await(dispatchClosed);
1459
+ // Coalesce local scan hints while a lane is busy; one hint requests a current read.
1460
+ const checks = yield* PubSub.sliding<void>(1);
1461
+
1462
+ yield* Effect.addFinalizer(() => PubSub.shutdown(checks));
1463
+ let dispatchOpen = true;
1464
+ let activityChanged = yield* Deferred.make<void>();
1465
+ const signalActivity = Effect.suspend(() => Deferred.succeed(activityChanged, undefined));
1466
+
1467
+ const closeDispatch = Effect.sync(() => {
1468
+ dispatchOpen = false;
1469
+ }).pipe(Effect.andThen(Deferred.succeed(dispatchClosed, undefined)));
1470
+
1471
+ const makeActivity = Effect.fnUntraced(function* (allowance?: number) {
1472
+ const initialized = yield* Deferred.make<void>();
1473
+ let active = 0;
1474
+ let failed = false;
1475
+
1476
+ const ready = Deferred.succeed(initialized, undefined).pipe(
1477
+ Effect.andThen(signalActivity),
1478
+ Effect.asVoid,
1479
+ Effect.uninterruptible,
1480
+ );
1481
+
1482
+ const activity: ThreadMaintenanceActivity["Service"] = {
1483
+ ready,
1484
+ subscribeChanges: PubSub.subscribe(checks).pipe(Effect.map(PubSub.take)),
1485
+ run: (wave) =>
1486
+ Effect.acquireUseRelease(
1487
+ Effect.sync(() => {
1488
+ if (!dispatchOpen) return false;
1489
+ active++;
1490
+
1491
+ return true;
1492
+ }),
1493
+ (admitted) =>
1494
+ !admitted
1495
+ ? Effect.void
1496
+ : (allowance === undefined
1497
+ ? wave
1498
+ : wave.pipe(
1499
+ Effect.timeoutOrElse({
1500
+ duration: allowance,
1501
+ orElse: () =>
1502
+ DurableAlarmError.make({
1503
+ operation: "host dispatch allowance",
1504
+ message:
1505
+ "The admitted host wave exceeded its allowance; durable work remains pending",
1506
+ }),
1507
+ }),
1508
+ )
1509
+ ).pipe(
1510
+ Effect.onExit((exit) =>
1511
+ Effect.sync(() => {
1512
+ if (Exit.isFailure(exit)) failed = true;
1513
+ }),
1514
+ ),
1515
+ ),
1516
+ (admitted) =>
1517
+ Effect.sync(() => {
1518
+ if (admitted) active--;
1519
+ }).pipe(Effect.andThen(signalActivity)),
1520
+ ),
1521
+ };
1522
+
1523
+ return {
1524
+ activity,
1525
+ initialized: Deferred.await(initialized),
1526
+ quiet: () => active === 0 && Deferred.isDoneUnsafe(initialized),
1527
+ failed: () => failed,
1528
+ };
1529
+ });
1530
+
1531
+ const messageActivity = yield* makeActivity();
1532
+ const hostActivity = yield* makeActivity(host.dispatchTimeoutMillis);
1533
+
1395
1534
  const recovery: NativeRecovery = {
1396
1535
  queue: yield* Deferred.make<ReadonlyArray<ThreadId>>(),
1397
1536
  pending: new Set(),
@@ -1429,13 +1568,15 @@ export class ThreadMaintenance extends Context.Service<
1429
1568
  auxiliaryScope,
1430
1569
  );
1431
1570
 
1432
- const dispatchClosed = yield* Deferred.make<void>();
1433
- const stopDispatch = Deferred.await(dispatchClosed);
1434
-
1435
1571
  // Fork setup too: an ordinary auxiliary setup failure is reported after native work,
1436
1572
  // rather than gating its opportunity. Event interruption still closes every fiber.
1437
1573
  const deliveryFiber = yield* Effect.forkIn(
1438
- Scope.provide(auxiliaryScope)(messages.drainUntil(stopDispatch, dispatchUntil)),
1574
+ Scope.provide(auxiliaryScope)(
1575
+ messages.drainUntil(stopDispatch, dispatchUntil).pipe(
1576
+ Effect.provideService(ThreadMaintenanceActivity, messageActivity.activity),
1577
+ Effect.onExit(() => messageActivity.activity.ready),
1578
+ ),
1579
+ ),
1439
1580
  auxiliaryScope,
1440
1581
  );
1441
1582
 
@@ -1452,8 +1593,29 @@ export class ThreadMaintenance extends Context.Service<
1452
1593
  }),
1453
1594
  ),
1454
1595
  );
1455
- yield* host.drainUntil(stopDispatch, dispatchUntil);
1456
- }),
1596
+
1597
+ // Initial setup must also be finite. Once it is accounted for, each actual
1598
+ // wave has its own unchanged allowance; passive listeners have no timer.
1599
+ const initialization = hostActivity.initialized.pipe(
1600
+ Effect.timeoutOrElse({
1601
+ duration: host.dispatchTimeoutMillis,
1602
+ orElse: () =>
1603
+ DurableAlarmError.make({
1604
+ operation: "host initialization",
1605
+ message:
1606
+ "The host did not account for initial maintenance before its allowance; durable work remains pending",
1607
+ }),
1608
+ }),
1609
+ Effect.andThen(Effect.never),
1610
+ );
1611
+
1612
+ yield* Effect.raceFirst(
1613
+ host
1614
+ .drainUntil(stopDispatch, dispatchUntil)
1615
+ .pipe(Effect.provideService(ThreadMaintenanceActivity, hostActivity.activity)),
1616
+ initialization,
1617
+ );
1618
+ }).pipe(Effect.onExit(() => hostActivity.activity.ready)),
1457
1619
  ),
1458
1620
  auxiliaryScope,
1459
1621
  );
@@ -1463,6 +1625,7 @@ export class ThreadMaintenance extends Context.Service<
1463
1625
  drainDue.pipe(
1464
1626
  Effect.provideService(ThreadProjectionMaintenance, projection),
1465
1627
  Effect.timeoutOption(config.projectionDispatchTimeoutMillis),
1628
+ Effect.onExit(() => signalActivity),
1466
1629
  ),
1467
1630
  auxiliaryScope,
1468
1631
  );
@@ -1477,9 +1640,8 @@ export class ThreadMaintenance extends Context.Service<
1477
1640
 
1478
1641
  observed.nativeOnly = false;
1479
1642
 
1480
- // Old recovery may still admit fresh native dispatch. Keep its replies/deliveries
1481
- // eligible in this same window. Retirement starts only after that window closes;
1482
- // waiting for host retirement to close it would create a circular join.
1643
+ // Pump termination and quiescence are distinct: listeners await closure, while their
1644
+ // finite waves report activity. A held sibling never retires another lane's controls.
1483
1645
  const hostJoin = yield* Effect.forkIn(
1484
1646
  Effect.gen(function* () {
1485
1647
  yield* stopDispatch;
@@ -1502,28 +1664,60 @@ export class ThreadMaintenance extends Context.Service<
1502
1664
  Fiber.joinAll([deliveryFiber, hostJoin, backfill, recoveryFiber]),
1503
1665
  );
1504
1666
 
1505
- const auxiliaryPending = () =>
1506
- deliveryFiber.pollUnsafe() === undefined ||
1507
- hostFiber.pollUnsafe() === undefined ||
1508
- backfill.pollUnsafe() === undefined ||
1509
- recoveryFiber.pollUnsafe() === undefined;
1510
-
1511
1667
  while (true) {
1512
1668
  const recoveryFinished = recoveryFiber.pollUnsafe() !== undefined;
1669
+ const retiredExit = retired.pollUnsafe();
1513
1670
 
1514
1671
  if (recoveryFinished) {
1515
1672
  if (recovery.needsCheckpoint) {
1516
1673
  yield* Fiber.join(recoveryFiber);
1517
- // A head released by old recovery gets its native opportunity before the
1518
- // delivery window closes, never after all reply listeners have retired.
1674
+ // Fold the original recovery observation into its checkpoint before closing.
1519
1675
  result = yield* advance(started, yieldAfter, observed, recovery, settled === 0);
1520
1676
  if (result.phase === "actionable") phase = "actionable";
1521
1677
  settled += result.settled;
1522
1678
  recovery.needsCheckpoint = false;
1679
+ yield* PubSub.publish(checks, undefined);
1523
1680
  }
1524
- yield* Deferred.succeed(dispatchClosed, undefined);
1525
1681
  }
1526
- if (retired.pollUnsafe() !== undefined || !auxiliaryPending()) break;
1682
+ if (retiredExit !== undefined && Exit.isFailure(retiredExit)) break;
1683
+
1684
+ if (
1685
+ recoveryFinished &&
1686
+ backfill.pollUnsafe() !== undefined &&
1687
+ messageActivity.quiet() &&
1688
+ hostActivity.quiet()
1689
+ ) {
1690
+ const now = yield* Clock.currentTimeMillis;
1691
+ const messageDeadline = yield* messages.pendingDeadline;
1692
+ const hostDeadline = yield* host.pendingDeadline;
1693
+
1694
+ const messageDue =
1695
+ deliveryFiber.pollUnsafe() === undefined &&
1696
+ !messageActivity.failed() &&
1697
+ Option.isSome(messageDeadline) &&
1698
+ messageDeadline.value <= now;
1699
+
1700
+ const hostDue =
1701
+ hostFiber.pollUnsafe() === undefined &&
1702
+ !hostActivity.failed() &&
1703
+ now + host.dispatchTimeoutMillis <= DateTime.toEpochMillis(dispatchUntil) &&
1704
+ Option.isSome(hostDeadline) &&
1705
+ hostDeadline.value <= now;
1706
+
1707
+ // No asynchronous operation separates the activity recheck from closure. A
1708
+ // racing registration either keeps this window open or cannot start its body.
1709
+ const closed = yield* Effect.sync(() => {
1710
+ if (messageDue || hostDue || !messageActivity.quiet() || !hostActivity.quiet())
1711
+ return false;
1712
+
1713
+ dispatchOpen = false;
1714
+
1715
+ return true;
1716
+ });
1717
+
1718
+ if (closed) break;
1719
+ yield* PubSub.publish(checks, undefined);
1720
+ }
1527
1721
  const now = yield* Clock.currentTimeMillis;
1528
1722
  const until = DateTime.toEpochMillis(yieldAfter);
1529
1723
 
@@ -1537,6 +1731,8 @@ export class ThreadMaintenance extends Context.Service<
1537
1731
 
1538
1732
  // A completion racing this iteration stays armed until the loop handles it.
1539
1733
  const recoveryDone = recoveryFinished ? Effect.never : Fiber.await(recoveryFiber);
1734
+ const retirementDone = retiredExit === undefined ? Fiber.await(retired) : Effect.never;
1735
+ const changed = activityChanged;
1540
1736
 
1541
1737
  const ready = yield* Effect.raceFirst(
1542
1738
  Effect.raceFirst(notified, Effect.sleep(Math.max(0, next - now))).pipe(
@@ -1544,16 +1740,17 @@ export class ThreadMaintenance extends Context.Service<
1544
1740
  ),
1545
1741
  Effect.raceFirst(
1546
1742
  recoveryDone.pipe(Effect.as("recovery" as const)),
1547
- Fiber.await(retired).pipe(Effect.as("retired" as const)),
1743
+ Effect.raceFirst(
1744
+ retirementDone.pipe(Effect.as("retired" as const)),
1745
+ Deferred.await(changed).pipe(Effect.as("activity" as const)),
1746
+ ),
1548
1747
  ),
1549
1748
  );
1550
1749
 
1551
- // Recovery completion closes admission before joining host retirement. It must
1552
- // wake this loop directly: the public wake hint is allowed to be dropped.
1553
- if (ready === "recovery") continue;
1554
- if (ready === "retired" || retired.pollUnsafe() !== undefined) break;
1555
- if (!auxiliaryPending()) continue;
1750
+ if (ready === "recovery" || ready === "retired") continue;
1751
+ if (ready === "activity") activityChanged = yield* Deferred.make<void>();
1556
1752
  if ((yield* Clock.currentTimeMillis) >= until) break;
1753
+ if (ready === "native") yield* PubSub.publish(checks, undefined);
1557
1754
 
1558
1755
  const awakened = yield* beginNative(observed);
1559
1756
 
@@ -1561,10 +1758,11 @@ export class ThreadMaintenance extends Context.Service<
1561
1758
  if (result.phase === "actionable") phase = "actionable";
1562
1759
  settled += result.settled;
1563
1760
  observed.nativeOnly = false;
1761
+ if (result.settled > 0) yield* PubSub.publish(checks, undefined);
1564
1762
  }
1565
1763
  // The original native yield deadline closes all new waves even if old recovery
1566
1764
  // is still pending. No recovery or delivery receives a renewed event budget.
1567
- yield* Deferred.succeed(dispatchClosed, undefined);
1765
+ yield* closeDispatch;
1568
1766
  // Preserve driver-owned Claim deadlines and failures, then close every
1569
1767
  // listener before the one final alarm decision.
1570
1768
  yield* Fiber.join(retired);
@@ -18,7 +18,12 @@ import {
18
18
  } from "effect-agent/message-delivery";
19
19
  import { WakeScheduler } from "effect-agent/wake-scheduler";
20
20
 
21
- import { DurableAlarmError, ThreadMessageDelivery, ThreadMutationGate } from "../Alarm.ts";
21
+ import {
22
+ DurableAlarmError,
23
+ ThreadMaintenanceActivity,
24
+ ThreadMessageDelivery,
25
+ ThreadMutationGate,
26
+ } from "../Alarm.ts";
22
27
  import { ThreadObjectPlacement } from "../CloudflareBindings.ts";
23
28
 
24
29
  /** Every write prearms its owner; the delivery due index owns its recovery deadline. */
@@ -137,38 +142,43 @@ export const threadMessageDeliveryLayer = Layer.effectContext(
137
142
  return Context.make(ThreadMessageDelivery, {
138
143
  drainUntil: (dispatchClosed, dispatchUntil) =>
139
144
  Effect.gen(function* () {
145
+ const activity = yield* ThreadMaintenanceActivity;
140
146
  // Subscribe before the initial scan. Only admission of new waves stops;
141
147
  // the enclosing event owns these resources until its actual teardown.
142
- const notified = (yield* Stream.toPull(wakes.wakes)).pipe(
143
- Effect.catch(() => Effect.never),
144
- );
148
+ const hinted = yield* Stream.toPull(wakes.wakes);
149
+ const checked = (yield* activity.subscribeChanges).pipe(Effect.as([undefined]));
150
+ const notified = Effect.raceFirst(hinted, checked).pipe(Effect.catch(() => Effect.never));
145
151
 
146
152
  const done = yield* Effect.forkScoped(dispatchClosed);
147
-
148
- const select = (initial = false) =>
149
- Effect.gen(function* () {
150
- const wave = yield* prepare;
151
- const now = yield* Clock.currentTimeMillis;
152
-
153
- // Recheck after local preparation: a late selection cannot start another wave once
154
- // dispatch closes. Always grant the initial opportunity to a caught-up alarm.
155
- if (
156
- (!initial && done.pollUnsafe() !== undefined) ||
157
- now + wave.timeoutMillis > DateTime.toEpochMillis(dispatchUntil)
158
- )
159
- return;
160
- yield* wave.run;
161
- });
162
-
163
- yield* select(true);
164
- while (done.pollUnsafe() === undefined) {
153
+ let exhausted = false;
154
+
155
+ const select = Effect.fnUntraced(function* (initial = false) {
156
+ if (exhausted) return;
157
+ const wave = yield* prepare;
158
+ const now = yield* Clock.currentTimeMillis;
159
+
160
+ // Recheck after local preparation: a late selection cannot start another wave once
161
+ // dispatch closes. Always grant the initial opportunity to a caught-up alarm.
162
+ if (!initial && done.pollUnsafe() !== undefined) return;
163
+ if (now + wave.timeoutMillis > DateTime.toEpochMillis(dispatchUntil)) {
164
+ // This pump has used its event opportunity. Keep the durable deadline for the
165
+ // next alarm without repeatedly selecting a wave that cannot fit this event.
166
+ exhausted = true;
167
+
168
+ return;
169
+ }
170
+ yield* wave.run;
171
+ });
172
+
173
+ yield* activity.run(activity.ready.pipe(Effect.andThen(select(true))));
174
+ while (!exhausted && done.pollUnsafe() === undefined) {
165
175
  const wake = yield* Effect.raceFirst(
166
176
  notified.pipe(Effect.map(Option.some)),
167
177
  Fiber.join(done).pipe(Effect.as(Option.none())),
168
178
  );
169
179
 
170
180
  if (Option.isNone(wake)) return;
171
- yield* Effect.forEach(wake.value, () => select(), { discard: true });
181
+ yield* Effect.forEach(wake.value, () => activity.run(select()), { discard: true });
172
182
  }
173
183
  // No deadline sleeps: the driver finishes its one active parallel wave, including
174
184
  // timeout/backoff commits; retained retries belong to a future physical alarm.