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

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: () => ({
@@ -621,11 +683,12 @@ export type MaintenancePassFailure =
621
683
  * 1. Prearm before any work. A caught-up native step uses the O(1) generation record without
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
- * 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
686
+ * producer keeps its generation dirty and immediately eligible. Quiescent native retries
687
+ * retain their durable backoff.
688
+ * 3. Keep native and delivery admission open together while finite waves remain active, so
689
+ * fresh replies and abort controls can progress during unrelated cleanup. Close atomically
690
+ * at quiescence, or at the original ten-minute yield deadline, before retiring listeners.
691
+ * 4. Native message delivery retains its driver-owned Claim deadline. Host/backfill waves are
629
692
  * bounded independently; incoming native work never restarts or cancels their attempts.
630
693
  * Auxiliary failures are reported after the current native opportunity.
631
694
  * 5. Close every event resource before the final gated deadline snapshot and alarm decision.
@@ -1317,16 +1380,20 @@ export class ThreadMaintenance extends Context.Service<
1317
1380
  ctx.storage.transaction(async (transaction) => {
1318
1381
  const { state } = await readMaintenanceState(transaction);
1319
1382
 
1383
+ const mutationOverlap =
1384
+ observation.activeAtStart > 0 || started.activeAtStart > 0 || active > 0;
1385
+
1320
1386
  const processed =
1321
- autonomous ||
1322
- observation.activeAtStart > 0 ||
1323
- started.activeAtStart > 0 ||
1324
- active > 0
1387
+ autonomous || mutationOverlap
1325
1388
  ? state.processed
1326
1389
  : state.processed > observation.generation
1327
1390
  ? state.processed
1328
1391
  : observation.generation;
1329
1392
 
1393
+ // Enrollment precedes the mutation body. A retry from an overlapping snapshot
1394
+ // must not defer work that becomes ready later in that same generation.
1395
+ const canBackoff = !mutationOverlap && state.dirty === started.generation;
1396
+
1330
1397
  const next = ThreadMaintenanceState.make({
1331
1398
  ...Struct.omit(state, ["retry"]),
1332
1399
  processed,
@@ -1334,7 +1401,7 @@ export class ThreadMaintenance extends Context.Service<
1334
1401
  bindingRetries: (state.bindingRetries ?? []).filter((retry) =>
1335
1402
  remaining.some((row) => row.submissionId === retry.submissionId),
1336
1403
  ),
1337
- ...(autonomous && !progressed
1404
+ ...(autonomous && !progressed && canBackoff
1338
1405
  ? {
1339
1406
  retry: MaintenanceRetry.make({
1340
1407
  generation: started.generation,
@@ -1348,12 +1415,10 @@ export class ThreadMaintenance extends Context.Service<
1348
1415
 
1349
1416
  await transaction.put(MAINTENANCE_STATE_KEY, encodeMaintenanceState(next));
1350
1417
  if (autonomous) {
1351
- return started.activeAtStart > 0 || active > 0 || state.dirty !== started.generation
1352
- ? now + minimumAlarmDelay
1353
- : now + delay;
1418
+ return now + (canBackoff ? delay : minimumAlarmDelay);
1354
1419
  }
1355
1420
 
1356
- return started.activeAtStart > 0 || active > 0 || next.dirty > next.processed
1421
+ return mutationOverlap || next.dirty > next.processed
1357
1422
  ? now + minimumAlarmDelay
1358
1423
  : undefined;
1359
1424
  }),
@@ -1392,6 +1457,83 @@ export class ThreadMaintenance extends Context.Service<
1392
1457
  Scope.close(scope, exit),
1393
1458
  );
1394
1459
 
1460
+ const dispatchClosed = yield* Deferred.make<void>();
1461
+ const stopDispatch = Deferred.await(dispatchClosed);
1462
+ // Coalesce local scan hints while a lane is busy; one hint requests a current read.
1463
+ const checks = yield* PubSub.sliding<void>(1);
1464
+
1465
+ yield* Effect.addFinalizer(() => PubSub.shutdown(checks));
1466
+ let dispatchOpen = true;
1467
+ let activityChanged = yield* Deferred.make<void>();
1468
+ const signalActivity = Effect.suspend(() => Deferred.succeed(activityChanged, undefined));
1469
+
1470
+ const closeDispatch = Effect.sync(() => {
1471
+ dispatchOpen = false;
1472
+ }).pipe(Effect.andThen(Deferred.succeed(dispatchClosed, undefined)));
1473
+
1474
+ const makeActivity = Effect.fnUntraced(function* (allowance?: number) {
1475
+ const initialized = yield* Deferred.make<void>();
1476
+ let active = 0;
1477
+ let failed = false;
1478
+
1479
+ const ready = Deferred.succeed(initialized, undefined).pipe(
1480
+ Effect.andThen(signalActivity),
1481
+ Effect.asVoid,
1482
+ Effect.uninterruptible,
1483
+ );
1484
+
1485
+ const activity: ThreadMaintenanceActivity["Service"] = {
1486
+ ready,
1487
+ subscribeChanges: PubSub.subscribe(checks).pipe(Effect.map(PubSub.take)),
1488
+ run: (wave) =>
1489
+ Effect.acquireUseRelease(
1490
+ Effect.sync(() => {
1491
+ if (!dispatchOpen) return false;
1492
+ active++;
1493
+
1494
+ return true;
1495
+ }),
1496
+ (admitted) =>
1497
+ !admitted
1498
+ ? Effect.void
1499
+ : (allowance === undefined
1500
+ ? wave
1501
+ : wave.pipe(
1502
+ Effect.timeoutOrElse({
1503
+ duration: allowance,
1504
+ orElse: () =>
1505
+ DurableAlarmError.make({
1506
+ operation: "host dispatch allowance",
1507
+ message:
1508
+ "The admitted host wave exceeded its allowance; durable work remains pending",
1509
+ }),
1510
+ }),
1511
+ )
1512
+ ).pipe(
1513
+ Effect.onExit((exit) =>
1514
+ Effect.sync(() => {
1515
+ if (Exit.isFailure(exit)) failed = true;
1516
+ }),
1517
+ ),
1518
+ ),
1519
+ (admitted) =>
1520
+ Effect.sync(() => {
1521
+ if (admitted) active--;
1522
+ }).pipe(Effect.andThen(signalActivity)),
1523
+ ),
1524
+ };
1525
+
1526
+ return {
1527
+ activity,
1528
+ initialized: Deferred.await(initialized),
1529
+ quiet: () => active === 0 && Deferred.isDoneUnsafe(initialized),
1530
+ failed: () => failed,
1531
+ };
1532
+ });
1533
+
1534
+ const messageActivity = yield* makeActivity();
1535
+ const hostActivity = yield* makeActivity(host.dispatchTimeoutMillis);
1536
+
1395
1537
  const recovery: NativeRecovery = {
1396
1538
  queue: yield* Deferred.make<ReadonlyArray<ThreadId>>(),
1397
1539
  pending: new Set(),
@@ -1429,13 +1571,15 @@ export class ThreadMaintenance extends Context.Service<
1429
1571
  auxiliaryScope,
1430
1572
  );
1431
1573
 
1432
- const dispatchClosed = yield* Deferred.make<void>();
1433
- const stopDispatch = Deferred.await(dispatchClosed);
1434
-
1435
1574
  // Fork setup too: an ordinary auxiliary setup failure is reported after native work,
1436
1575
  // rather than gating its opportunity. Event interruption still closes every fiber.
1437
1576
  const deliveryFiber = yield* Effect.forkIn(
1438
- Scope.provide(auxiliaryScope)(messages.drainUntil(stopDispatch, dispatchUntil)),
1577
+ Scope.provide(auxiliaryScope)(
1578
+ messages.drainUntil(stopDispatch, dispatchUntil).pipe(
1579
+ Effect.provideService(ThreadMaintenanceActivity, messageActivity.activity),
1580
+ Effect.onExit(() => messageActivity.activity.ready),
1581
+ ),
1582
+ ),
1439
1583
  auxiliaryScope,
1440
1584
  );
1441
1585
 
@@ -1452,8 +1596,29 @@ export class ThreadMaintenance extends Context.Service<
1452
1596
  }),
1453
1597
  ),
1454
1598
  );
1455
- yield* host.drainUntil(stopDispatch, dispatchUntil);
1456
- }),
1599
+
1600
+ // Initial setup must also be finite. Once it is accounted for, each actual
1601
+ // wave has its own unchanged allowance; passive listeners have no timer.
1602
+ const initialization = hostActivity.initialized.pipe(
1603
+ Effect.timeoutOrElse({
1604
+ duration: host.dispatchTimeoutMillis,
1605
+ orElse: () =>
1606
+ DurableAlarmError.make({
1607
+ operation: "host initialization",
1608
+ message:
1609
+ "The host did not account for initial maintenance before its allowance; durable work remains pending",
1610
+ }),
1611
+ }),
1612
+ Effect.andThen(Effect.never),
1613
+ );
1614
+
1615
+ yield* Effect.raceFirst(
1616
+ host
1617
+ .drainUntil(stopDispatch, dispatchUntil)
1618
+ .pipe(Effect.provideService(ThreadMaintenanceActivity, hostActivity.activity)),
1619
+ initialization,
1620
+ );
1621
+ }).pipe(Effect.onExit(() => hostActivity.activity.ready)),
1457
1622
  ),
1458
1623
  auxiliaryScope,
1459
1624
  );
@@ -1463,6 +1628,7 @@ export class ThreadMaintenance extends Context.Service<
1463
1628
  drainDue.pipe(
1464
1629
  Effect.provideService(ThreadProjectionMaintenance, projection),
1465
1630
  Effect.timeoutOption(config.projectionDispatchTimeoutMillis),
1631
+ Effect.onExit(() => signalActivity),
1466
1632
  ),
1467
1633
  auxiliaryScope,
1468
1634
  );
@@ -1477,9 +1643,8 @@ export class ThreadMaintenance extends Context.Service<
1477
1643
 
1478
1644
  observed.nativeOnly = false;
1479
1645
 
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.
1646
+ // Pump termination and quiescence are distinct: listeners await closure, while their
1647
+ // finite waves report activity. A held sibling never retires another lane's controls.
1483
1648
  const hostJoin = yield* Effect.forkIn(
1484
1649
  Effect.gen(function* () {
1485
1650
  yield* stopDispatch;
@@ -1502,28 +1667,60 @@ export class ThreadMaintenance extends Context.Service<
1502
1667
  Fiber.joinAll([deliveryFiber, hostJoin, backfill, recoveryFiber]),
1503
1668
  );
1504
1669
 
1505
- const auxiliaryPending = () =>
1506
- deliveryFiber.pollUnsafe() === undefined ||
1507
- hostFiber.pollUnsafe() === undefined ||
1508
- backfill.pollUnsafe() === undefined ||
1509
- recoveryFiber.pollUnsafe() === undefined;
1510
-
1511
1670
  while (true) {
1512
1671
  const recoveryFinished = recoveryFiber.pollUnsafe() !== undefined;
1672
+ const retiredExit = retired.pollUnsafe();
1513
1673
 
1514
1674
  if (recoveryFinished) {
1515
1675
  if (recovery.needsCheckpoint) {
1516
1676
  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.
1677
+ // Fold the original recovery observation into its checkpoint before closing.
1519
1678
  result = yield* advance(started, yieldAfter, observed, recovery, settled === 0);
1520
1679
  if (result.phase === "actionable") phase = "actionable";
1521
1680
  settled += result.settled;
1522
1681
  recovery.needsCheckpoint = false;
1682
+ yield* PubSub.publish(checks, undefined);
1523
1683
  }
1524
- yield* Deferred.succeed(dispatchClosed, undefined);
1525
1684
  }
1526
- if (retired.pollUnsafe() !== undefined || !auxiliaryPending()) break;
1685
+ if (retiredExit !== undefined && Exit.isFailure(retiredExit)) break;
1686
+
1687
+ if (
1688
+ recoveryFinished &&
1689
+ backfill.pollUnsafe() !== undefined &&
1690
+ messageActivity.quiet() &&
1691
+ hostActivity.quiet()
1692
+ ) {
1693
+ const now = yield* Clock.currentTimeMillis;
1694
+ const messageDeadline = yield* messages.pendingDeadline;
1695
+ const hostDeadline = yield* host.pendingDeadline;
1696
+
1697
+ const messageDue =
1698
+ deliveryFiber.pollUnsafe() === undefined &&
1699
+ !messageActivity.failed() &&
1700
+ Option.isSome(messageDeadline) &&
1701
+ messageDeadline.value <= now;
1702
+
1703
+ const hostDue =
1704
+ hostFiber.pollUnsafe() === undefined &&
1705
+ !hostActivity.failed() &&
1706
+ now + host.dispatchTimeoutMillis <= DateTime.toEpochMillis(dispatchUntil) &&
1707
+ Option.isSome(hostDeadline) &&
1708
+ hostDeadline.value <= now;
1709
+
1710
+ // No asynchronous operation separates the activity recheck from closure. A
1711
+ // racing registration either keeps this window open or cannot start its body.
1712
+ const closed = yield* Effect.sync(() => {
1713
+ if (messageDue || hostDue || !messageActivity.quiet() || !hostActivity.quiet())
1714
+ return false;
1715
+
1716
+ dispatchOpen = false;
1717
+
1718
+ return true;
1719
+ });
1720
+
1721
+ if (closed) break;
1722
+ yield* PubSub.publish(checks, undefined);
1723
+ }
1527
1724
  const now = yield* Clock.currentTimeMillis;
1528
1725
  const until = DateTime.toEpochMillis(yieldAfter);
1529
1726
 
@@ -1537,6 +1734,8 @@ export class ThreadMaintenance extends Context.Service<
1537
1734
 
1538
1735
  // A completion racing this iteration stays armed until the loop handles it.
1539
1736
  const recoveryDone = recoveryFinished ? Effect.never : Fiber.await(recoveryFiber);
1737
+ const retirementDone = retiredExit === undefined ? Fiber.await(retired) : Effect.never;
1738
+ const changed = activityChanged;
1540
1739
 
1541
1740
  const ready = yield* Effect.raceFirst(
1542
1741
  Effect.raceFirst(notified, Effect.sleep(Math.max(0, next - now))).pipe(
@@ -1544,16 +1743,17 @@ export class ThreadMaintenance extends Context.Service<
1544
1743
  ),
1545
1744
  Effect.raceFirst(
1546
1745
  recoveryDone.pipe(Effect.as("recovery" as const)),
1547
- Fiber.await(retired).pipe(Effect.as("retired" as const)),
1746
+ Effect.raceFirst(
1747
+ retirementDone.pipe(Effect.as("retired" as const)),
1748
+ Deferred.await(changed).pipe(Effect.as("activity" as const)),
1749
+ ),
1548
1750
  ),
1549
1751
  );
1550
1752
 
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;
1753
+ if (ready === "recovery" || ready === "retired") continue;
1754
+ if (ready === "activity") activityChanged = yield* Deferred.make<void>();
1556
1755
  if ((yield* Clock.currentTimeMillis) >= until) break;
1756
+ if (ready === "native") yield* PubSub.publish(checks, undefined);
1557
1757
 
1558
1758
  const awakened = yield* beginNative(observed);
1559
1759
 
@@ -1561,10 +1761,11 @@ export class ThreadMaintenance extends Context.Service<
1561
1761
  if (result.phase === "actionable") phase = "actionable";
1562
1762
  settled += result.settled;
1563
1763
  observed.nativeOnly = false;
1764
+ if (result.settled > 0) yield* PubSub.publish(checks, undefined);
1564
1765
  }
1565
1766
  // The original native yield deadline closes all new waves even if old recovery
1566
1767
  // is still pending. No recovery or delivery receives a renewed event budget.
1567
- yield* Deferred.succeed(dispatchClosed, undefined);
1768
+ yield* closeDispatch;
1568
1769
  // Preserve driver-owned Claim deadlines and failures, then close every
1569
1770
  // listener before the one final alarm decision.
1570
1771
  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.