@effect-agent/platform-cloudflare 0.1.0-beta.93 → 0.1.0-beta.95

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
@@ -12,6 +12,7 @@ import {
12
12
  Random,
13
13
  Ref,
14
14
  Schema,
15
+ Scope,
15
16
  Semaphore,
16
17
  Stream,
17
18
  } from "effect";
@@ -31,7 +32,7 @@ import {
31
32
  import { SqlClient } from "effect/unstable/sql/SqlClient";
32
33
 
33
34
  import { DurableObjectContext } from "./CloudflareBindings.ts";
34
- import { CloudflareDurableRuntimeConfig } from "./CloudflareConfig.ts";
35
+ import { AuxiliaryDispatchMillis, CloudflareDurableRuntimeConfig } from "./CloudflareConfig.ts";
35
36
  import { safeCauseMessage } from "./internal/boundary.ts";
36
37
 
37
38
  /**
@@ -231,6 +232,8 @@ export class ThreadMaintenanceFailpoint extends Context.Service<
231
232
  * `invalidate`, `prepareGeneration` and `pendingDeadline` must be bounded local operations.
232
233
  * `prepareGeneration` durably invalidates a scan only when its generation changes; repeated
233
234
  * calls must preserve partial scan progress. It runs with no source mutation in flight.
235
+ * Use this gate only for publication required before dependent native execution. Independent
236
+ * UI relays and outboxes belong to ThreadHostMaintenance.
234
237
  * `drain` performs bounded delivery and persists retries before returning. A pending deadline
235
238
  * defers runtime recovery/Attempts, allowing committed host publications to drain first.
236
239
  * Unexpected hook failures leave the prearmed generation for retry. Hooks acquire per-call
@@ -258,38 +261,53 @@ export class ThreadPublication extends Context.Service<
258
261
  }
259
262
 
260
263
  /**
261
- * Host-assembled message recovery, supplied by ThreadObject.layer even when the application
262
- * rebuilds ThreadMaintenance. These obligations outlive source Runs and never defer a ready
263
- * source Attempt while a destination is processing an accepted message.
264
+ * Host-assembled native message recovery. The driver bounds each actual Claim and persists its
265
+ * timeout/retry before this pump returns. Do not add a second timer starting at batch selection:
266
+ * local Claim setup may take time, and expiration still owes the driver's local retry commit.
264
267
  */
265
268
  export const ThreadMessageDelivery = Context.Reference<{
266
- readonly drain: Effect.Effect<void, DurableAlarmError>;
267
- /** Drain inserts and due retries during source work, finishing the bounded wave on completion. */
268
- readonly drainUntil?: (
269
- finished: Deferred.Deferred<void>,
270
- ) => Effect.Effect<void, DurableAlarmError>;
269
+ readonly drainUntil: (
270
+ sourceFinished: Effect.Effect<void>,
271
+ dispatchUntil: DateTime.Utc,
272
+ ) => Effect.Effect<void, DurableAlarmError, Scope.Scope>;
271
273
  readonly pendingDeadline: Effect.Effect<Option.Option<number>, DurableAlarmError>;
272
274
  }>("@effect-agent/platform-cloudflare/ThreadMessageDelivery", {
273
- defaultValue: () => ({ drain: Effect.void, pendingDeadline: Effect.succeed(Option.none()) }),
275
+ defaultValue: () => ({
276
+ drainUntil: () => Effect.void,
277
+ pendingDeadline: Effect.succeed(Option.none()),
278
+ }),
274
279
  });
275
280
 
276
281
  /**
277
- * Application obligations sharing this Object's alarm. The deadline read is local and
278
- * read-only. Drain beside the native Attempt and always finish one initial bounded wave,
279
- * even when `finished` was already signalled. Then stop starting new waves on that signal
280
- * and finish the bounded current wave before returning. Native maintenance joins that work
281
- * before acknowledging a generation. Mutations use the same ThreadMutationGate; hooks never
282
- * write the raw alarm slot. Pending host work does not defer a ready model Attempt.
282
+ * Application obligations sharing this Object's alarm. Admit one initial external wave even on
283
+ * a caught-up pass, then respond to wakes while native work runs. sourceFinished closes only
284
+ * admission of NEW external waves. Keep local admission/hub subscriptions in the supplied event
285
+ * Scope until maintenance tears it down; do not scope them to sourceFinished or to this Effect.
286
+ * No deadline sleeps or automatic retry loops. Return after already-admitted waves finish.
287
+ *
288
+ * Declare a finite whole-wave allowance (1..300000ms): maximum for parallel lanes, sum for
289
+ * sequential operations. Admit a wave only if its allowance fits before dispatchUntil. Later
290
+ * arrivals cannot renew the retirement window. Maintenance bounds the join after sourceFinished,
291
+ * interrupts and joins event Scope, then reads local deadlines under the mutation gate.
292
+ *
293
+ * Setup and pendingDeadline are bounded local operations. Persist claims/envelopes before
294
+ * dispatch; interrupted waits leave exact retries/receipts recoverable. Cancellation is local,
295
+ * not remote rollback. Retry accounting is unchanged. Network waits/finalizers must be
296
+ * interruptible; short local atomic commits may be uninterruptible. Hooks never write the raw
297
+ * alarm slot. Required native publication gates belong to ThreadPublication.
283
298
  */
284
299
  export const ThreadHostMaintenance = Context.Reference<{
285
- readonly pendingDeadline: Effect.Effect<Option.Option<number>, DurableAlarmError>;
300
+ readonly dispatchTimeoutMillis: number;
286
301
  readonly drainUntil: (
287
- finished: Deferred.Deferred<void>,
288
- ) => Effect.Effect<void, DurableAlarmError>;
302
+ sourceFinished: Effect.Effect<void>,
303
+ dispatchUntil: DateTime.Utc,
304
+ ) => Effect.Effect<void, DurableAlarmError, Scope.Scope>;
305
+ readonly pendingDeadline: Effect.Effect<Option.Option<number>, DurableAlarmError>;
289
306
  }>("@effect-agent/platform-cloudflare/ThreadHostMaintenance", {
290
307
  defaultValue: () => ({
291
- pendingDeadline: Effect.succeed(Option.none()),
308
+ dispatchTimeoutMillis: 1,
292
309
  drainUntil: () => Effect.void,
310
+ pendingDeadline: Effect.succeed(Option.none()),
293
311
  }),
294
312
  });
295
313
 
@@ -403,7 +421,11 @@ export class ThreadMutationGate extends Context.Service<
403
421
  {
404
422
  readonly withMutation: <A, E, R>(
405
423
  body: Effect.Effect<A, E, R>,
406
- /** Indexed host work has its own durable deadline and does not invalidate ledger recovery. */
424
+ /**
425
+ * Native admission, approval, abort and unknown resolution keep the default true.
426
+ * Projection/relay/reply receipt-only bookkeeping must use false: its local durable
427
+ * pendingDeadline owns scheduling without creating native recovery debt.
428
+ */
407
429
  options?: { readonly invalidatesRecovery: boolean },
408
430
  ) => Effect.Effect<A, E | DurableAlarmError, R>;
409
431
  readonly withSnapshot: <A, E, R>(
@@ -491,9 +513,13 @@ export type MaintenancePassFailure =
491
513
  * alarm takes an O(1) path without recovery, ledger scans, or canonical-history reads.
492
514
  * 2. Recovery strictly precedes a new claim. One head Attempt advances the lane and requests
493
515
  * a safe yield after ten minutes. The whole event has a fourteen-minute cooperative timeout.
494
- * 3. The final transaction acknowledges only the generation observed at pass start. A racing
516
+ * 3. Auxiliary dispatch and listeners belong to the event Scope. After native completion,
517
+ * stop new external waves, join native delivery through its driver-owned Claim deadline,
518
+ * and bound host/backfill work by explicit allowances before closing and joining Scope.
519
+ * Ordinary auxiliary setup failures are reported after the one native opportunity.
520
+ * 4. The final transaction acknowledges only the generation observed at pass start. A racing
495
521
  * mutation therefore remains `dirty > processed` and retains its atomically-established alarm.
496
- * 4. Stable external waits acknowledge and clear. Autonomous retry, indeterminate, and lease
522
+ * 5. Stable external waits acknowledge and clear. Autonomous retry, indeterminate, and lease
497
523
  * recovery states leave their generation dirty and retain bounded backoff rearming.
498
524
  */
499
525
  export class ThreadMaintenance extends Context.Service<
@@ -618,7 +644,10 @@ export class ThreadMaintenance extends Context.Service<
618
644
  // Prearm even a publication-only pass before invoking any host hook.
619
645
  await ensureTransactionAlarmBy(transaction, now + minimumAlarmDelay);
620
646
 
621
- return { _tag: "CaughtUp" as const, nonterminal: state.nonterminal };
647
+ return {
648
+ _tag: "CaughtUp" as const,
649
+ nonterminal: state.nonterminal,
650
+ };
622
651
  }
623
652
  // Pre-arm the earliest retry before recovery. A successful finish may move this slot
624
653
  // LATER to its bounded backoff, which does not cancel the running handler.
@@ -655,7 +684,8 @@ export class ThreadMaintenance extends Context.Service<
655
684
 
656
685
  const pass = Effect.fn("ThreadMaintenance.pass")(function* (
657
686
  yieldAfter: DateTime.Utc,
658
- ): Effect.fn.Return<MaintenancePassReport, MaintenancePassFailure> {
687
+ dispatchUntil: DateTime.Utc,
688
+ ): Effect.fn.Return<MaintenancePassReport, MaintenancePassFailure, Scope.Scope> {
659
689
  const annotate = (report: MaintenancePassReport) =>
660
690
  Effect.annotateCurrentSpan({
661
691
  phase: report.phase,
@@ -678,32 +708,75 @@ export class ThreadMaintenance extends Context.Service<
678
708
  }),
679
709
  );
680
710
 
681
- // Deliver beside source work, including messages inserted by the running Attempt.
682
- // Slow destination RPCs never consume the source execution window. Stop starting
683
- // waves when source work ends, then join the bounded current wave before acknowledgement.
684
- const deliveryFinished = yield* Deferred.make<void>();
685
-
686
- const delivery = yield* Effect.forkChild(
687
- messages.drainUntil?.(deliveryFinished) ?? messages.drain,
711
+ // This scope owns auxiliary dispatch and listeners, independently of source completion.
712
+ // Close it before acknowledgement, including on failure or event interruption.
713
+ const auxiliaryScope = yield* Effect.acquireRelease(Scope.make("parallel"), (scope, exit) =>
714
+ Scope.close(scope, exit),
688
715
  );
689
716
 
690
- const hostWork = yield* Effect.forkChild(host.drainUntil(deliveryFinished));
717
+ const sourceFinished = yield* Deferred.make<void>();
718
+ const finished = Deferred.await(sourceFinished);
691
719
 
692
- const finishDelivery = Deferred.succeed(deliveryFinished, undefined).pipe(
693
- Effect.andThen(Fiber.awaitAll([delivery, hostWork])),
694
- Effect.flatMap((outcomes) =>
695
- Effect.forEach(outcomes, (outcome) => outcome, { discard: true }),
720
+ // Fork setup too: an ordinary auxiliary setup failure is reported after native work,
721
+ // rather than gating its opportunity. Event interruption still closes every fiber.
722
+ const deliveryFiber = yield* Effect.forkIn(
723
+ Scope.provide(auxiliaryScope)(messages.drainUntil(finished, dispatchUntil)),
724
+ auxiliaryScope,
725
+ );
726
+
727
+ const hostFiber = yield* Effect.forkIn(
728
+ Scope.provide(auxiliaryScope)(
729
+ Effect.gen(function* () {
730
+ yield* Schema.decodeEffect(AuxiliaryDispatchMillis)(host.dispatchTimeoutMillis).pipe(
731
+ Effect.mapError((cause) =>
732
+ DurableAlarmError.make({
733
+ operation: "host dispatch allowance",
734
+ message:
735
+ "Declare an integer whole-wave allowance between 1 and 300000 milliseconds",
736
+ cause,
737
+ }),
738
+ ),
739
+ );
740
+ yield* host.drainUntil(finished, dispatchUntil);
741
+ }),
696
742
  ),
743
+ auxiliaryScope,
697
744
  );
698
745
 
699
- // Capture derived-index failures until canonical work has had its turn. Interruption
700
- // still stops the event; ordinary failures and defects retain the prearmed generation.
701
- const projected = yield* Effect.exit(
702
- drainDue.pipe(Effect.provideService(ThreadProjectionMaintenance, projection)),
746
+ // Backfill is one disposable wave; its timer starts beside native execution.
747
+ const backfill = yield* Effect.forkIn(
748
+ drainDue.pipe(
749
+ Effect.provideService(ThreadProjectionMaintenance, projection),
750
+ Effect.timeoutOption(config.projectionDispatchTimeoutMillis),
751
+ ),
752
+ auxiliaryScope,
703
753
  );
704
754
 
705
- if (Exit.isFailure(projected) && Cause.hasInterrupts(projected.cause))
706
- return yield* Effect.failCause(projected.cause);
755
+ const finishAuxiliary = Effect.gen(function* () {
756
+ yield* Deferred.succeed(sourceFinished, undefined);
757
+
758
+ const remaining = Math.max(
759
+ 1,
760
+ DateTime.toEpochMillis(dispatchUntil) - (yield* Clock.currentTimeMillis),
761
+ );
762
+
763
+ const hostJoin = yield* Effect.forkIn(
764
+ Fiber.join(hostFiber).pipe(
765
+ Effect.timeoutOption(Math.min(host.dispatchTimeoutMillis, remaining)),
766
+ Effect.tap((result) =>
767
+ Effect.annotateCurrentSpan({ "host.timedOut": Option.isNone(result) }),
768
+ ),
769
+ ),
770
+ auxiliaryScope,
771
+ );
772
+
773
+ // Native transport uses the driver's actual Claim deadline, through Retry persistence.
774
+ // Only our host/backfill timers suppress their own interruption. Earlier failed exits,
775
+ // including pure self-interruption, remain failures while siblings are still blocked.
776
+ yield* Fiber.joinAll([deliveryFiber, hostJoin, backfill]);
777
+ yield* Scope.close(auxiliaryScope, Exit.void);
778
+ });
779
+
707
780
  const deadline = yield* publication.pendingDeadline;
708
781
 
709
782
  if (
@@ -715,8 +788,7 @@ export class ThreadMaintenance extends Context.Service<
715
788
  const pending = yield* publication.pendingDeadline;
716
789
 
717
790
  if (started._tag === "CaughtUp" || Option.isSome(pending)) {
718
- yield* finishDelivery;
719
- if (Exit.isFailure(projected)) return yield* Effect.failCause(projected.cause);
791
+ yield* finishAuxiliary;
720
792
  yield* failpoint.hit("maintenance:finish:before");
721
793
 
722
794
  const disposition = yield* mutations.withSnapshot((active) =>
@@ -817,9 +889,7 @@ export class ThreadMaintenance extends Context.Service<
817
889
  ? Option.none()
818
890
  : yield* runtime.processThreadHead(selected, { yieldAfter });
819
891
 
820
- yield* finishDelivery;
821
- if (Exit.isFailure(projected)) return yield* Effect.failCause(projected.cause);
822
- // Observe residual state before acknowledging this exact pass-start generation.
892
+ yield* finishAuxiliary;
823
893
  const remaining = yield* Stream.runCollect(ledger.scanNonterminal);
824
894
  const waitingHeads = new Map<ThreadId, boolean>();
825
895
 
@@ -940,9 +1010,13 @@ export class ThreadMaintenance extends Context.Service<
940
1010
  return ThreadMaintenance.of({
941
1011
  // A mid-pass immediate hint is droppable; durable dirty state decides the final alarm.
942
1012
  pass: Effect.gen(function* () {
943
- const yieldAfter = DateTime.makeUnsafe((yield* Clock.currentTimeMillis) + 10 * 60_000);
1013
+ const now = yield* Clock.currentTimeMillis;
1014
+ const yieldAfter = DateTime.makeUnsafe(now + 10 * 60_000);
1015
+ const dispatchUntil = DateTime.makeUnsafe(now + 14 * 60_000);
944
1016
 
945
- return yield* alarm.withWakesDeferred(maintenancePassGate.withPermit(pass(yieldAfter)));
1017
+ return yield* alarm.withWakesDeferred(
1018
+ maintenancePassGate.withPermit(Effect.scoped(pass(yieldAfter, dispatchUntil))),
1019
+ );
946
1020
  }).pipe(
947
1021
  // Include permit waiting, recovery and acknowledgement in the event deadline.
948
1022
  // Interruption releases Attempt ownership, leaving the prearmed dirty generation
@@ -10,6 +10,9 @@ import { DEFAULT_OWNERSHIP_LEASE_DURATION } from "effect-agent/submission-ledger
10
10
  */
11
11
 
12
12
  const PositiveMillis = Schema.Int.check(Schema.isGreaterThan(0));
13
+
14
+ /** Maximum supported auxiliary wave equals the native message policy's five-minute ceiling. */
15
+ export const AuxiliaryDispatchMillis = PositiveMillis.check(Schema.isLessThanOrEqualTo(300_000));
13
16
  const NonNegativeMillis = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
14
17
 
15
18
  /** The supplied Cloudflare durable runtime configuration failed validation (DEPLOY-003). */
@@ -104,6 +107,8 @@ export class CloudflareDurableRuntimeConfigValue extends Schema.Class<Cloudflare
104
107
  abortPollInterval: PositiveMillis,
105
108
  /** Canonical observation poll cadence of the Durable Object store. */
106
109
  observationPollInterval: NonNegativeMillis,
110
+ /** One disposable projection backfill wave; independent of required live applyCommitted. */
111
+ projectionDispatchTimeoutMillis: AuxiliaryDispatchMillis,
107
112
  /** Per-value byte bound; must stay under the platform's 2 MB SQLite value limit. */
108
113
  maxStoredValueBytes: Schema.Int.check(
109
114
  Schema.isGreaterThan(0),
@@ -130,6 +135,7 @@ export const CLOUDFLARE_RUNTIME_DEFAULTS = {
130
135
  leaseRenewalInterval: 10_000,
131
136
  abortPollInterval: 500,
132
137
  observationPollInterval: 25,
138
+ projectionDispatchTimeoutMillis: 30_000,
133
139
  maxStoredValueBytes: DEFAULT_MAX_STORED_VALUE_BYTES,
134
140
  verifyOnOpen: false,
135
141
  maxQueueDepthPerLane: 256,
@@ -140,6 +140,8 @@ export interface CloudflareDurableRuntimeOptions {
140
140
  readonly toolFailureObserver?: RunToolFailureObserver | undefined;
141
141
  /** Milliseconds; default 25. */
142
142
  readonly observationPollInterval?: number | undefined;
143
+ /** Whole disposable projection wave in milliseconds, 1..300000; default 30000. */
144
+ readonly projectionDispatchTimeoutMillis?: number | undefined;
143
145
  /** Bytes; default just under the 2 MB platform value limit. */
144
146
  readonly maxStoredValueBytes?: number | undefined;
145
147
  /** Default false. */
@@ -254,6 +256,9 @@ const configFromOptions = (
254
256
  abortPollInterval: options.abortPollInterval ?? CLOUDFLARE_RUNTIME_DEFAULTS.abortPollInterval,
255
257
  observationPollInterval:
256
258
  options.observationPollInterval ?? CLOUDFLARE_RUNTIME_DEFAULTS.observationPollInterval,
259
+ projectionDispatchTimeoutMillis:
260
+ options.projectionDispatchTimeoutMillis ??
261
+ CLOUDFLARE_RUNTIME_DEFAULTS.projectionDispatchTimeoutMillis,
257
262
  maxStoredValueBytes:
258
263
  options.maxStoredValueBytes ?? CLOUDFLARE_RUNTIME_DEFAULTS.maxStoredValueBytes,
259
264
  verifyOnOpen: options.verifyOnOpen ?? CLOUDFLARE_RUNTIME_DEFAULTS.verifyOnOpen,
@@ -609,8 +614,8 @@ const sharedLayer = <A, E, R, PE = never, PR = never>(
609
614
  );
610
615
 
611
616
  const messageRecovery = threadMessageDeliveryLayer.pipe(
612
- // Each wave is bounded even at the policy's five-minute attempt ceiling. Source
613
- // completion stops new waves; remaining rows retain their indexed alarm deadline.
617
+ // Four parallel attempts per wave. Native completion stops new wake-driven waves;
618
+ // retained retry deadlines schedule future alarms.
614
619
  Layer.provide(MessageDeliveryDriver.layer({ batchSize: 4, concurrency: 4 })),
615
620
  Layer.provide(cloudflarePreparedInputAdmissionLayer),
616
621
  Layer.provide(CloudflareThreadClient.layer),
@@ -1,4 +1,15 @@
1
- import { Clock, Context, Deferred, Effect, Layer, Option, Ref, Semaphore, Stream } from "effect";
1
+ import {
2
+ Clock,
3
+ Context,
4
+ DateTime,
5
+ Effect,
6
+ Fiber,
7
+ Layer,
8
+ Option,
9
+ Ref,
10
+ Semaphore,
11
+ Stream,
12
+ } from "effect";
2
13
  import { type ThreadId } from "effect-agent/identifiers";
3
14
  import {
4
15
  MessageDeliveryDriver,
@@ -9,7 +20,6 @@ import { WakeScheduler } from "effect-agent/wake-scheduler";
9
20
 
10
21
  import { DurableAlarmError, ThreadMessageDelivery, ThreadMutationGate } from "../Alarm.ts";
11
22
  import { ThreadObjectPlacement } from "../CloudflareBindings.ts";
12
- import { CloudflareDurableRuntimeConfig } from "../CloudflareConfig.ts";
13
23
 
14
24
  /** Every write prearms its owner; the delivery due index owns its recovery deadline. */
15
25
  export const guardedMessageDeliveryStoreLayer = Layer.effect(
@@ -94,8 +104,6 @@ export const threadMessageDeliveryLayer = Layer.effectContext(
94
104
  Effect.gen(function* () {
95
105
  const driver = yield* MessageDeliveryDriver;
96
106
  const store = yield* MessageDeliveryStore;
97
- const wakes = yield* WakeScheduler;
98
- const config = yield* CloudflareDurableRuntimeConfig;
99
107
 
100
108
  const failure = (operation: string) => () =>
101
109
  DurableAlarmError.make({
@@ -103,47 +111,68 @@ export const threadMessageDeliveryLayer = Layer.effectContext(
103
111
  message: "Durable message recovery remains pending",
104
112
  });
105
113
 
106
- const drain = Effect.gen(function* () {
114
+ const wakes = yield* WakeScheduler;
115
+
116
+ const prepare = Effect.gen(function* () {
107
117
  const deadline = yield* store.nextDeadline();
108
118
 
109
- if (deadline !== null && deadline <= (yield* Clock.currentTimeMillis)) {
110
- yield* driver.runDue();
111
- }
112
- }).pipe(Effect.mapError(failure("drain message delivery")));
119
+ if (deadline === null || deadline > (yield* Clock.currentTimeMillis))
120
+ return { timeoutMillis: 1, run: Effect.void };
121
+ // The assembled driver has four permits: a wave is one parallel attempt window.
122
+ const keys = yield* store.due(yield* Clock.currentTimeMillis, 4);
123
+ const records = yield* Effect.forEach(keys, (key) => store.get(key));
124
+
125
+ return {
126
+ timeoutMillis: Math.max(
127
+ 1,
128
+ ...records.map((record) => record?.policy.attemptTimeoutMillis ?? 1),
129
+ ),
130
+ run: Effect.forEach(keys, (key) => driver.process(key), {
131
+ concurrency: 4,
132
+ discard: true,
133
+ }).pipe(Effect.mapError(failure("dispatch message delivery"))),
134
+ };
135
+ }).pipe(Effect.mapError(failure("prepare message delivery")));
113
136
 
114
137
  return Context.make(ThreadMessageDelivery, {
115
- drain,
116
- drainUntil: Effect.fn("ThreadMessageDelivery.drainUntil")(function* (
117
- finished: Deferred.Deferred<void>,
118
- ) {
119
- // One scoped subscription covers every logical lane in this physical owner.
120
- // Acquire it before reading durable deadlines; hints remain droppable.
121
- const notified = (yield* Stream.toPull(wakes.wakes)).pipe(Effect.catch(() => Effect.never));
122
-
123
- // Always finish one wave; source completion prevents starting subsequent waves.
124
- yield* Effect.gen(function* () {
125
- yield* drain;
126
-
127
- const deadline = yield* store
128
- .nextDeadline()
129
- .pipe(Effect.mapError(failure("read message deadline")));
130
-
131
- // The index includes unfinished waves, lease expiry, retry and settlement polls.
132
- // Yield at least one millisecond for an already-due deadline instead of spinning.
133
- const delay =
134
- deadline === null
135
- ? config.wakeScanInterval
136
- : Math.min(
137
- config.wakeScanInterval,
138
- Math.max(1, deadline - (yield* Clock.currentTimeMillis)),
139
- );
140
-
141
- yield* Effect.raceFirst(
142
- Deferred.await(finished),
143
- Effect.raceFirst(notified, Effect.sleep(delay)),
138
+ drainUntil: (sourceFinished, dispatchUntil) =>
139
+ Effect.gen(function* () {
140
+ // Subscribe before the initial scan. Only external dispatch stops with the source;
141
+ // 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
144
  );
145
- }).pipe(Effect.repeat({ until: () => Deferred.isDone(finished) }));
146
- }, Effect.scoped),
145
+
146
+ const done = yield* Effect.forkScoped(sourceFinished);
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
+ // the source ends. 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) {
165
+ const wake = yield* Effect.raceFirst(
166
+ notified.pipe(Effect.map(Option.some)),
167
+ Fiber.join(done).pipe(Effect.as(Option.none())),
168
+ );
169
+
170
+ if (Option.isNone(wake)) return;
171
+ yield* Effect.forEach(wake.value, () => select(), { discard: true });
172
+ }
173
+ // No deadline sleeps: the driver finishes its one active parallel wave, including
174
+ // timeout/backoff commits; retained retries belong to a future physical alarm.
175
+ }),
147
176
  pendingDeadline: store
148
177
  .nextDeadline()
149
178
  .pipe(Effect.map(Option.fromNullishOr), Effect.mapError(failure("read message deadline"))),