@effect-agent/platform-cloudflare 0.1.0-beta.125 → 0.1.0-beta.126

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/Alarm.d.mts CHANGED
@@ -226,7 +226,8 @@ declare const ThreadMaintenance_base: Context.ServiceClass<ThreadMaintenance, "@
226
226
  * 2. Reconcile before each head Attempt, then checkpoint only the observed generation. A racing
227
227
  * producer keeps its generation dirty and immediately eligible. Quiescent native retries
228
228
  * retain their durable backoff.
229
- * 3. Keep native and delivery admission open together while finite waves remain active, so
229
+ * 3. Admit at most two independent Thread Attempts, one active head per Thread.
230
+ * Keep native and delivery admission open together while finite waves remain active, so
230
231
  * fresh replies and abort controls can progress during unrelated cleanup. Close atomically
231
232
  * at quiescence, or at the original ten-minute yield deadline, then join admitted waves.
232
233
  * 4. Native message delivery retains its driver-owned Claim deadline. Host/backfill waves are
package/dist/Alarm.mjs CHANGED
@@ -10,6 +10,7 @@ import { OperationAuthorizationRequest, OperationAuthorizer } from "effect-agent
10
10
  import { AbortIntentRequest, SubmissionLedger } from "effect-agent/submission-ledger";
11
11
  import { ThreadProjectionMaintenance, drainDue } from "effect-agent/thread-projection-maintenance";
12
12
  import { WakeScheduler } from "effect-agent/wake-scheduler";
13
+ import { DurableObjectStorage } from "effect-cf";
13
14
  import { SqlClient } from "effect/unstable/sql/SqlClient";
14
15
  //#region src/Alarm.ts
15
16
  var Alarm_exports = /* @__PURE__ */ __exportAll({
@@ -47,13 +48,14 @@ const alarmFailure = (operation) => (cause) => DurableAlarmError.make({
47
48
  message: safeCauseMessage(cause, "The Cloudflare alarm API failed without a diagnostic"),
48
49
  cause
49
50
  });
50
- const makeStorageOperation = Effect.map(SqlClient, (sql) => (operation, execute) => Effect.flatMap(Effect.serviceOption(sql.transactionService), (current) => {
51
- const body = Effect.uninterruptible(Effect.tryPromise({
52
- try: execute,
53
- catch: alarmFailure(operation)
54
- }));
51
+ const makeStorageEffect = Effect.map(SqlClient, (sql) => (operation, execute) => Effect.flatMap(Effect.serviceOption(sql.transactionService), (current) => {
52
+ const body = Effect.uninterruptible(execute);
55
53
  return current._tag === "Some" ? body : Effect.scoped(Effect.andThen(sql.reserve.pipe(Effect.mapError(alarmFailure(operation))), body));
56
54
  }));
55
+ const makeStorageOperation = Effect.map(makeStorageEffect, (run) => (operation, execute) => run(operation, Effect.tryPromise({
56
+ try: execute,
57
+ catch: alarmFailure(operation)
58
+ })));
57
59
  /** `ctx.storage` alarm slot as an Effect service; storage is truth, never a memory field. */
58
60
  var DurableAlarmService = class DurableAlarmService extends Context.Service()("@effect-agent/platform-cloudflare/DurableAlarmService") {
59
61
  static layer = Layer.effect(DurableAlarmService)(Effect.gen(function* () {
@@ -164,6 +166,7 @@ const decodeRecoveryFault = (threadId, encoded) => {
164
166
  return fault;
165
167
  };
166
168
  const encodeRecoveryFault = Schema.encodeSync(ThreadRecoveryFault);
169
+ const nativeDispatchConcurrency = 2;
167
170
  var MaintenanceRetry = class extends Schema.Class("MaintenanceRetry")({
168
171
  generation: MaintenanceGeneration,
169
172
  notBefore: Schema.Finite,
@@ -270,7 +273,8 @@ var ThreadMutationGate = class ThreadMutationGate extends Context.Service()("@ef
270
273
  * 2. Reconcile before each head Attempt, then checkpoint only the observed generation. A racing
271
274
  * producer keeps its generation dirty and immediately eligible. Quiescent native retries
272
275
  * retain their durable backoff.
273
- * 3. Keep native and delivery admission open together while finite waves remain active, so
276
+ * 3. Admit at most two independent Thread Attempts, one active head per Thread.
277
+ * Keep native and delivery admission open together while finite waves remain active, so
274
278
  * fresh replies and abort controls can progress during unrelated cleanup. Close atomically
275
279
  * at quiescence, or at the original ten-minute yield deadline, then join admitted waves.
276
280
  * 4. Native message delivery retains its driver-owned Claim deadline. Host/backfill waves are
@@ -288,6 +292,8 @@ var ThreadMaintenance = class ThreadMaintenance extends Context.Service()("@effe
288
292
  const alarm = yield* DurableAlarmService;
289
293
  const config = yield* CloudflareDurableRuntimeConfig;
290
294
  const { ctx } = yield* DurableObjectContext;
295
+ const storage = DurableObjectStorage.fromDurableObjectStorage(ctx.storage);
296
+ const runStorage = yield* makeStorageEffect;
291
297
  const failpoint = yield* ThreadMaintenanceFailpoint;
292
298
  const mutations = yield* ThreadMutationGate;
293
299
  const publication = yield* ThreadPublication;
@@ -448,7 +454,45 @@ var ThreadMaintenance = class ThreadMaintenance extends Context.Service()("@effe
448
454
  };
449
455
  }));
450
456
  });
451
- const advance = Effect.fn("ThreadMaintenance.advance")(function* (started, yieldAfter, observed, recovery, dispatch = true) {
457
+ const attemptSelected = Effect.fn("ThreadMaintenance.attemptSelected")(function* (selected, previousRetries, yieldAfter, recovery) {
458
+ yield* recoverThread(selected.threadId, recovery);
459
+ let bindingFailure;
460
+ const settlement = recovery.faults.has(selected.threadId) ? Option.none() : yield* runtime.processThreadHead(selected.threadId, { yieldAfter }).pipe(Effect.catchTag("BindingUnavailable", (failure) => {
461
+ bindingFailure = failure;
462
+ return Effect.succeed(Option.none());
463
+ }));
464
+ const previous = previousRetries.find((retry) => retry.submissionId === selected.submissionId);
465
+ let retry;
466
+ let reportBindingFailure = false;
467
+ if (bindingFailure !== void 0) {
468
+ const now = yield* Clock.currentTimeMillis;
469
+ const attempts = Math.min(30, (previous?.attempts ?? 0) + 1);
470
+ reportBindingFailure = previous === void 0 || now - previous.reportedAt >= 9e5;
471
+ retry = BindingRetry.make({
472
+ threadId: selected.threadId,
473
+ submissionId: selected.submissionId,
474
+ attempts,
475
+ notBefore: now + Math.min(6e4, 5e3 * 2 ** (attempts - 1)),
476
+ reportedAt: reportBindingFailure ? now : previous?.reportedAt ?? now
477
+ });
478
+ }
479
+ if (retry !== void 0 || previous !== void 0) {
480
+ yield* failpoint.hit("maintenance:binding-retry:before");
481
+ yield* runStorage("record submission binding retry", storage.transaction((transaction) => Effect.gen(function* () {
482
+ const encoded = yield* transaction.get(MAINTENANCE_STATE_KEY);
483
+ const state = encoded === void 0 ? initialMaintenanceState() : yield* Schema.decodeUnknownEffect(ThreadMaintenanceState)(encoded);
484
+ const bindingRetries = [...(state.bindingRetries ?? []).filter((entry) => entry.submissionId !== selected.submissionId), ...retry === void 0 ? [] : [retry]];
485
+ yield* transaction.put(MAINTENANCE_STATE_KEY, yield* Schema.encodeEffect(ThreadMaintenanceState)(ThreadMaintenanceState.make({
486
+ ...state,
487
+ bindingRetries
488
+ })));
489
+ })).pipe(Effect.mapError(alarmFailure("record submission binding retry"))));
490
+ yield* failpoint.hit("maintenance:binding-retry:after");
491
+ }
492
+ if (bindingFailure !== void 0) yield* reportBindingFailure ? Effect.logError("Thread awaits a current agent binding; original work remains pending", Cause.fail(bindingFailure)) : Effect.logDebug("Thread binding retry remains pending", Cause.fail(bindingFailure));
493
+ return Option.isSome(settlement) ? 1 : 0;
494
+ });
495
+ const advance = Effect.fn("ThreadMaintenance.advance")(function* (started, yieldAfter, observed, recovery, native, dispatch = true, reserved = new Set(native.active.keys())) {
452
496
  const deadline = yield* publication.pendingDeadline;
453
497
  if (started._tag === "Actionable" || Option.isSome(deadline) && deadline.value <= (yield* Clock.currentTimeMillis)) yield* publication.drain;
454
498
  const pending = yield* publication.pendingDeadline;
@@ -462,11 +506,20 @@ var ThreadMaintenance = class ThreadMaintenance extends Context.Service()("@effe
462
506
  }));
463
507
  return {
464
508
  phase: "caught-up",
465
- settled: 0,
466
509
  nonterminal: started.nonterminal,
467
510
  nextAttemptAt
468
511
  };
469
512
  }
513
+ if (native.generation !== started.generation) {
514
+ native.deferred.clear();
515
+ native.generation = started.generation;
516
+ }
517
+ if (native.active.size > 0 && native.scanGeneration === started.generation) return {
518
+ phase: "actionable",
519
+ nonterminal: started.nonterminal,
520
+ nextAttemptAt: void 0
521
+ };
522
+ native.scanGeneration = started.activeAtStart === 0 ? started.generation : void 0;
470
523
  observed.nativeOnly = true;
471
524
  const observation = recovery.observation ??= {
472
525
  generation: started.generation,
@@ -492,7 +545,7 @@ var ThreadMaintenance = class ThreadMaintenance extends Context.Service()("@effe
492
545
  }
493
546
  const stopping = /* @__PURE__ */ new Set();
494
547
  for (const head of heads.values()) {
495
- if (head.state !== "ready" || recovery.pending.has(head.threadId) || recoveryFaults.has(head.threadId)) continue;
548
+ if (head.state !== "ready" || native.active.has(head.threadId) || recovery.pending.has(head.threadId) || recoveryFaults.has(head.threadId)) continue;
496
549
  const intent = yield* isolateRecovery(ledger.readAbortIntent(AbortIntentRequest.make({ submissionId: head.submissionId })), {
497
550
  timeout: recoveryConfig.recoveryTimeout,
498
551
  phase: () => "recovery",
@@ -517,8 +570,10 @@ var ThreadMaintenance = class ThreadMaintenance extends Context.Service()("@effe
517
570
  const selection = yield* runTransaction("select maintenance lane", () => ctx.storage.transaction(async (transaction) => {
518
571
  const { state } = await readMaintenanceState(transaction);
519
572
  const retries = (state.bindingRetries ?? []).filter((retry) => heads.get(retry.threadId)?.submissionId === retry.submissionId);
520
- const runnable = eligible.filter((threadId) => !retries.some((retry) => retry.threadId === threadId && retry.notBefore > selectionTime));
521
- const next = dispatch ? runnable.find((threadId) => state.lastServedThreadId === void 0 || threadId > state.lastServedThreadId) ?? runnable[0] : void 0;
573
+ const runnable = eligible.filter((threadId) => !reserved.has(threadId) && !native.active.has(threadId) && !native.deferred.has(threadId) && !retries.some((retry) => retry.threadId === threadId && retry.notBefore > selectionTime));
574
+ const pivot = Math.max(0, runnable.findIndex((threadId) => state.lastServedThreadId === void 0 || threadId > state.lastServedThreadId));
575
+ const selected = dispatch ? [...runnable.slice(pivot), ...runnable.slice(0, pivot)].slice(0, nativeDispatchConcurrency - reserved.size) : [];
576
+ const next = selected.at(-1);
522
577
  if (next !== void 0) await transaction.put(MAINTENANCE_STATE_KEY, encodeMaintenanceState(ThreadMaintenanceState.make({
523
578
  ...state,
524
579
  lastServedThreadId: next
@@ -530,7 +585,7 @@ var ThreadMaintenance = class ThreadMaintenance extends Context.Service()("@effe
530
585
  const after = backlog.filter((threadId) => state.lastRecoveredThreadId === void 0 || threadId > state.lastRecoveredThreadId);
531
586
  const before = backlog.filter((threadId) => state.lastRecoveredThreadId !== void 0 && threadId <= state.lastRecoveredThreadId);
532
587
  return {
533
- selected: next,
588
+ selected,
534
589
  retries,
535
590
  backlog: [...after, ...before]
536
591
  };
@@ -542,45 +597,19 @@ var ThreadMaintenance = class ThreadMaintenance extends Context.Service()("@effe
542
597
  for (const threadId of selection.backlog) recovery.pending.add(threadId);
543
598
  yield* Deferred.succeed(recovery.queue, selection.backlog);
544
599
  }
545
- const selected = selection.selected === void 0 ? void 0 : heads.get(selection.selected);
546
- if (selected !== void 0) yield* recoverThread(selected.threadId, recovery);
547
- let retries = selection.retries;
548
- let bindingFailure;
549
- const settlement = selected === void 0 || recoveryFaults.has(selected.threadId) ? Option.none() : yield* runtime.processThreadHead(selected.threadId, { yieldAfter }).pipe(Effect.catchTag("BindingUnavailable", (failure) => {
550
- bindingFailure = failure;
551
- return Effect.succeed(Option.none());
552
- }));
553
- if (selected !== void 0) {
554
- const previous = retries.find((retry) => retry.submissionId === selected.submissionId);
555
- let retry;
556
- let reportBindingFailure = false;
557
- if (bindingFailure !== void 0) {
558
- const now = yield* Clock.currentTimeMillis;
559
- const attempts = Math.min(30, (previous?.attempts ?? 0) + 1);
560
- reportBindingFailure = previous === void 0 || now - previous.reportedAt >= 9e5;
561
- retry = BindingRetry.make({
562
- threadId: selected.threadId,
563
- submissionId: selected.submissionId,
564
- attempts,
565
- notBefore: now + Math.min(6e4, 5e3 * 2 ** (attempts - 1)),
566
- reportedAt: reportBindingFailure ? now : previous?.reportedAt ?? now
567
- });
568
- }
569
- if (retry !== void 0 || previous !== void 0) {
570
- yield* failpoint.hit("maintenance:binding-retry:before");
571
- retries = yield* runTransaction("record submission binding retry", () => ctx.storage.transaction(async (transaction) => {
572
- const { state } = await readMaintenanceState(transaction);
573
- const bindingRetries = [...(state.bindingRetries ?? []).filter((entry) => entry.submissionId !== selected.submissionId), ...retry === void 0 ? [] : [retry]];
574
- await transaction.put(MAINTENANCE_STATE_KEY, encodeMaintenanceState(ThreadMaintenanceState.make({
575
- ...state,
576
- bindingRetries
577
- })));
578
- return bindingRetries;
579
- }));
580
- yield* failpoint.hit("maintenance:binding-retry:after");
581
- }
582
- if (bindingFailure !== void 0) yield* reportBindingFailure ? Effect.logError("Thread awaits a current agent binding; original work remains pending", Cause.fail(bindingFailure)) : Effect.logDebug("Thread binding retry remains pending", Cause.fail(bindingFailure));
600
+ for (const threadId of selection.selected) {
601
+ const selected = heads.get(threadId);
602
+ if (selected === void 0) continue;
603
+ native.dispatched++;
604
+ native.active.set(threadId, yield* Effect.forkIn(attemptSelected(selected, selection.retries, yieldAfter, recovery), native.scope));
583
605
  }
606
+ if (native.active.size > 0) return {
607
+ phase: "actionable",
608
+ nonterminal: current.length,
609
+ nextAttemptAt: void 0,
610
+ dispatched: selection.selected.length > 0
611
+ };
612
+ const retries = selection.retries;
584
613
  const remaining = yield* Stream.runCollect(ledger.scanNonterminal);
585
614
  const waitingHeads = /* @__PURE__ */ new Map();
586
615
  const autonomous = remaining.some((snapshot) => {
@@ -590,7 +619,7 @@ var ThreadMaintenance = class ThreadMaintenance extends Context.Service()("@effe
590
619
  if (headWaiting === true && snapshot.state === "ready" && reports.get(snapshot.submissionId)?.decision._tag === "ApplyInput") return false;
591
620
  return !waiting(snapshot);
592
621
  });
593
- const progressed = Option.isSome(settlement) || recovery.repaired;
622
+ const progressed = native.progressed || recovery.repaired;
594
623
  const now = yield* Clock.currentTimeMillis;
595
624
  const ordinaryDelay = autonomous ? yield* rearmDelay(progressed, started.stalls) : 0;
596
625
  const nextEligible = [...recoveryFaults.values()].map((fault) => fault.retryAt).concat(eligible.map((threadId) => retries.find((retry) => retry.submissionId === heads.get(threadId)?.submissionId)?.notBefore ?? now));
@@ -619,9 +648,11 @@ var ThreadMaintenance = class ThreadMaintenance extends Context.Service()("@effe
619
648
  return mutationOverlap || next.dirty > next.processed ? now + minimumAlarmDelay : void 0;
620
649
  })));
621
650
  yield* failpoint.hit("maintenance:checkpoint:after");
651
+ native.deferred.clear();
652
+ native.progressed = false;
653
+ native.needsCheckpoint = false;
622
654
  return {
623
655
  phase: "actionable",
624
- settled: Option.isSome(settlement) ? 1 : 0,
625
656
  nonterminal: remaining.length,
626
657
  nextAttemptAt
627
658
  };
@@ -671,15 +702,22 @@ var ThreadMaintenance = class ThreadMaintenance extends Context.Service()("@effe
671
702
  const backfill = yield* fork(drainDue.pipe(Effect.provideService(ThreadProjectionMaintenance, projection), Effect.timeoutOption(config.projectionDispatchTimeoutMillis)));
672
703
  let backfillObserved = false;
673
704
  let recoveryObserved = false;
674
- let native = yield* fork(advance(started, yieldAfter, observed, recovery));
705
+ const dispatch = {
706
+ scope: auxiliaryScope,
707
+ active: /* @__PURE__ */ new Map(),
708
+ deferred: /* @__PURE__ */ new Set(),
709
+ dispatched: 0,
710
+ settled: 0,
711
+ progressed: false,
712
+ needsCheckpoint: false
713
+ };
714
+ let native = yield* fork(advance(started, yieldAfter, observed, recovery, dispatch));
675
715
  let result = {
676
716
  phase: "caught-up",
677
- settled: 0,
678
717
  nonterminal: started.nonterminal,
679
718
  nextAttemptAt: void 0
680
719
  };
681
720
  let phase = result.phase;
682
- let settled = 0;
683
721
  let nativeCheck = false;
684
722
  const until = DateTime.toEpochMillis(yieldAfter);
685
723
  const dispatchEnd = DateTime.toEpochMillis(dispatchUntil);
@@ -691,16 +729,33 @@ var ThreadMaintenance = class ThreadMaintenance extends Context.Service()("@effe
691
729
  result = yield* Fiber.join(native);
692
730
  native = void 0;
693
731
  if (result.phase === "actionable") phase = "actionable";
694
- settled += result.settled;
695
- observed.nativeOnly = false;
732
+ observed.nativeOnly = dispatch.active.size > 0;
733
+ if (result.dispatched === true && dispatch.active.size < nativeDispatchConcurrency) {
734
+ dispatch.scanGeneration = void 0;
735
+ nativeCheck = true;
736
+ }
696
737
  recovery.started = true;
697
738
  yield* Deferred.succeed(recovery.queue, []);
698
739
  checkLanes();
699
740
  }
741
+ for (const [threadId, attempt] of dispatch.active) {
742
+ const exit = attempt.pollUnsafe();
743
+ if (exit === void 0) continue;
744
+ dispatch.active.delete(threadId);
745
+ dispatch.scanGeneration = void 0;
746
+ if (Exit.isFailure(exit)) return yield* Effect.failCause(exit.cause);
747
+ dispatch.settled += exit.value;
748
+ dispatch.progressed ||= exit.value > 0;
749
+ dispatch.needsCheckpoint = true;
750
+ if (exit.value === 0) dispatch.deferred.add(threadId);
751
+ nativeCheck = true;
752
+ checkLanes();
753
+ }
700
754
  if (!recoveryObserved && recoveryFiber.pollUnsafe() !== void 0) {
701
755
  yield* Fiber.join(recoveryFiber);
702
756
  recoveryObserved = true;
703
- nativeCheck = recovery.needsCheckpoint;
757
+ dispatch.scanGeneration = void 0;
758
+ nativeCheck ||= recovery.needsCheckpoint;
704
759
  }
705
760
  if (!backfillObserved) {
706
761
  const exit = backfill.pollUnsafe();
@@ -726,7 +781,7 @@ var ThreadMaintenance = class ThreadMaintenance extends Context.Service()("@effe
726
781
  messageExhausted = true;
727
782
  }
728
783
  }
729
- if (failure !== void 0 && native === void 0) return yield* Effect.failCause(failure);
784
+ if (failure !== void 0 && native === void 0 && dispatch.active.size === 0) return yield* Effect.failCause(failure);
730
785
  const now = yield* Clock.currentTimeMillis;
731
786
  if (now >= until) break;
732
787
  for (const lane of lanes) {
@@ -779,19 +834,22 @@ var ThreadMaintenance = class ThreadMaintenance extends Context.Service()("@effe
779
834
  else delivery = yield* fork(wave.run);
780
835
  }
781
836
  }
782
- if (native === void 0 && recoveryObserved && !recovery.needsCheckpoint && backfillObserved && delivery === void 0 && lanes.every((lane) => lane.fiber === void 0)) break;
783
- if (native === void 0 && nativeCheck) {
837
+ if (native === void 0 && dispatch.active.size === 0 && recoveryObserved && !recovery.needsCheckpoint && backfillObserved && delivery === void 0 && lanes.every((lane) => lane.fiber === void 0)) break;
838
+ if (native === void 0 && nativeCheck && dispatch.active.size < nativeDispatchConcurrency) {
784
839
  nativeCheck = false;
785
- const checkpoint = recoveryObserved && recovery.needsCheckpoint;
840
+ const nativeCheckpoint = dispatch.needsCheckpoint && dispatch.active.size === 0;
841
+ const checkpoint = recoveryObserved && recovery.needsCheckpoint || nativeCheckpoint;
786
842
  if (checkpoint) recovery.needsCheckpoint = false;
843
+ const reserved = new Set(dispatch.active.keys());
787
844
  native = yield* fork(Effect.gen(function* () {
788
845
  const awakened = checkpoint ? started : yield* beginNative(observed);
789
- return yield* advance(awakened, yieldAfter, observed, recovery, !checkpoint || settled === 0);
846
+ return yield* advance(awakened, yieldAfter, observed, recovery, dispatch, failure === void 0 && !nativeCheckpoint && (!checkpoint || dispatch.settled === 0), reserved);
790
847
  }));
791
848
  }
792
849
  const next = Math.min(native === void 0 ? result.nextAttemptAt ?? Infinity : Infinity, now + config.wakeScanInterval, until);
793
850
  const completing = [
794
851
  ...native === void 0 ? [] : [native],
852
+ ...dispatch.active.values(),
795
853
  ...recoveryObserved ? [] : [recoveryFiber],
796
854
  ...backfillObserved ? [] : [backfill],
797
855
  ...delivery === void 0 ? [] : [delivery],
@@ -805,11 +863,17 @@ var ThreadMaintenance = class ThreadMaintenance extends Context.Service()("@effe
805
863
  if (native !== void 0) {
806
864
  result = yield* Fiber.join(native);
807
865
  if (result.phase === "actionable") phase = "actionable";
808
- settled += result.settled;
809
- observed.nativeOnly = false;
866
+ observed.nativeOnly = dispatch.active.size > 0;
810
867
  recovery.started = true;
811
868
  yield* Deferred.succeed(recovery.queue, []);
812
869
  }
870
+ for (const attempt of dispatch.active.values()) {
871
+ const settled = yield* Fiber.join(attempt);
872
+ dispatch.settled += settled;
873
+ dispatch.progressed ||= settled > 0;
874
+ }
875
+ dispatch.active.clear();
876
+ observed.nativeOnly = false;
813
877
  yield* Fiber.joinAll([
814
878
  recoveryFiber,
815
879
  backfill,
@@ -817,10 +881,9 @@ var ThreadMaintenance = class ThreadMaintenance extends Context.Service()("@effe
817
881
  ...lanes.flatMap((lane) => lane.fiber === void 0 ? [] : [lane.fiber])
818
882
  ]);
819
883
  if (failure !== void 0) return yield* Effect.failCause(failure);
820
- if (recovery.needsCheckpoint) {
821
- result = yield* advance(started, yieldAfter, observed, recovery, false);
884
+ if (recovery.needsCheckpoint || dispatch.dispatched > 0) {
885
+ result = yield* advance(started, yieldAfter, observed, recovery, dispatch, false);
822
886
  if (result.phase === "actionable") phase = "actionable";
823
- settled += result.settled;
824
887
  }
825
888
  yield* Scope.close(auxiliaryScope, Exit.void);
826
889
  yield* failpoint.hit("maintenance:finish:before");
@@ -843,7 +906,7 @@ var ThreadMaintenance = class ThreadMaintenance extends Context.Service()("@effe
843
906
  const report = MaintenancePassReport.make({
844
907
  phase,
845
908
  recovered: recovery.recovered,
846
- settled,
909
+ settled: dispatch.settled,
847
910
  nonterminal: result.nonterminal,
848
911
  alarm: disposition
849
912
  });