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

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
@@ -11,6 +11,7 @@ import {
11
11
  Option,
12
12
  Random,
13
13
  Ref,
14
+ Result,
14
15
  Schema,
15
16
  Scope,
16
17
  Semaphore,
@@ -20,11 +21,25 @@ import {
20
21
  import { type DurableBindingFailure } from "effect-agent/agent-registration";
21
22
  import {
22
23
  DurableAgentRuntime,
24
+ DurableRuntimeConfig,
25
+ isolateRecovery,
26
+ RecoveryBlocked,
27
+ RecoveryFailure,
23
28
  type DurableWorkerFailure,
24
29
  type RecoveryReport,
30
+ RecoverySweepResult,
25
31
  } from "effect-agent/durable-agent-runtime";
26
32
  import { ThreadId, SubmissionId } from "effect-agent/identifiers";
27
- import { SubmissionLedger, type SubmissionSnapshot } from "effect-agent/submission-ledger";
33
+ import {
34
+ OperationAuthorizationRequest,
35
+ OperationAuthorizer,
36
+ type OperationDenied,
37
+ } from "effect-agent/operation-authorizer";
38
+ import {
39
+ AbortIntentRequest,
40
+ SubmissionLedger,
41
+ type SubmissionWorkItem,
42
+ } from "effect-agent/submission-ledger";
28
43
  import {
29
44
  ThreadProjectionMaintenance,
30
45
  drainDue,
@@ -187,7 +202,7 @@ export class MaintenancePassReport extends Schema.Class<MaintenancePassReport>(
187
202
  )({
188
203
  /** `caught-up` ran no runtime work (publication may be pending); `actionable` ran recovery. */
189
204
  phase: Schema.Literals(["caught-up", "actionable"]),
190
- /** Recovery decisions executed (or deferred) BEFORE any new claim in this pass. */
205
+ /** Recovery decisions and Thread faults observed during this event. */
191
206
  recovered: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
192
207
  /** Head Attempts settled during the event. Joined input may settle with each head. */
193
208
  settled: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
@@ -211,6 +226,8 @@ export type ThreadMaintenanceFailpointLocation =
211
226
  | "maintenance:select:after"
212
227
  | "maintenance:binding-retry:before"
213
228
  | "maintenance:binding-retry:after"
229
+ | "maintenance:recovery-status:before"
230
+ | "maintenance:recovery-status:after"
214
231
  | "maintenance:retry:before"
215
232
  | "maintenance:retry:after"
216
233
  | "maintenance:checkpoint:before"
@@ -354,14 +371,64 @@ class BindingRetry extends Schema.Class<BindingRetry>("BindingRetry")({
354
371
  reportedAt: Schema.Finite,
355
372
  }) {}
356
373
 
374
+ /**
375
+ * A durable observation of blocked recovery, independent of the execution journal. This is
376
+ * neither a Settlement nor proof that external effects did not happen. A successful recovery
377
+ * sweep clears it; repair must preserve canonical history and the original admission identity.
378
+ */
379
+ export class ThreadRecoveryFault extends Schema.Class<ThreadRecoveryFault>(
380
+ "@effect-agent/platform-cloudflare/ThreadRecoveryFault",
381
+ )({
382
+ schemaVersion: Schema.Literal(1),
383
+ threadId: ThreadId,
384
+ firstFailedAt: Schema.Finite,
385
+ lastFailedAt: Schema.Finite,
386
+ /** Earliest automatic recovery retry; new admissions do not erase this deadline. */
387
+ retryAt: Schema.Finite,
388
+ /** Saturates at 2^31 - 1; one observation per Thread per recovery sweep. */
389
+ attempts: Schema.Int.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(2_147_483_647)),
390
+ failure: RecoveryFailure,
391
+ }) {}
392
+
393
+ const recoveryFaultKey = (threadId: ThreadId) =>
394
+ `effect-agent:thread-recovery-fault:v1:${threadId}`;
395
+
396
+ const decodeRecoveryFaultValue = Schema.decodeUnknownSync(ThreadRecoveryFault);
397
+
398
+ const decodeRecoveryFault = (threadId: ThreadId, encoded: unknown) => {
399
+ const fault = decodeRecoveryFaultValue(encoded);
400
+
401
+ if (fault.threadId !== threadId) throw new Error("Recovery status does not match its Thread key");
402
+
403
+ return fault;
404
+ };
405
+
406
+ const encodeRecoveryFault = Schema.encodeSync(ThreadRecoveryFault);
407
+
357
408
  interface NativePassResult {
358
409
  readonly phase: "caught-up" | "actionable";
359
- readonly recovered: number;
360
410
  readonly settled: number;
361
411
  readonly nonterminal: number;
362
412
  readonly nextAttemptAt: number | undefined;
363
413
  }
364
414
 
415
+ /** Event-local observations only; durable ingress keeps racing mutations dirty. */
416
+ interface NativeRecovery {
417
+ readonly queue: Deferred.Deferred<ReadonlyArray<ThreadId>>;
418
+ readonly pending: Set<ThreadId>;
419
+ readonly loaded: Set<ThreadId>;
420
+ readonly reports: Map<SubmissionId, RecoveryReport>;
421
+ readonly faults: Map<ThreadId, ThreadRecoveryFault>;
422
+ observation?: {
423
+ readonly generation: bigint;
424
+ readonly activeAtStart: number;
425
+ };
426
+ started: boolean;
427
+ needsCheckpoint: boolean;
428
+ recovered: number;
429
+ repaired: boolean;
430
+ }
431
+
365
432
  interface MaintenanceObservation {
366
433
  generation?: bigint;
367
434
  nativeOnly: boolean;
@@ -384,6 +451,8 @@ class ThreadMaintenanceState extends Schema.Class<ThreadMaintenanceState>(
384
451
  nonterminal: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
385
452
  /** One physical-owner cursor; old single-lane records need no conversion. */
386
453
  lastServedThreadId: Schema.optionalKey(ThreadId),
454
+ /** Rotate old recovery independently of dispatch, including after eviction or timeout. */
455
+ lastRecoveredThreadId: Schema.optionalKey(ThreadId),
387
456
  bindingRetries: Schema.optionalKey(Schema.Array(BindingRetry)),
388
457
  /** Absent on older records. A newer mutation makes this retry obsolete. */
389
458
  retry: Schema.optionalKey(MaintenanceRetry),
@@ -425,7 +494,7 @@ const ensureTransactionAlarmBy = async (
425
494
  };
426
495
 
427
496
  const stableExternalWait = (
428
- snapshot: SubmissionSnapshot,
497
+ snapshot: SubmissionWorkItem,
429
498
  reports: ReadonlyMap<string, RecoveryReport>,
430
499
  ): boolean => {
431
500
  const report = reports.get(snapshot.submissionId);
@@ -553,9 +622,9 @@ export type MaintenancePassFailure =
553
622
  * recovery, ledger scans or canonical-history reads.
554
623
  * 2. Reconcile before each head Attempt, then checkpoint only the observed generation. A racing
555
624
  * producer keeps its newer generation dirty. Native retries retain their durable backoff.
556
- * 3. After the initial native opportunity, stop admitting new external waves and let the
557
- * active waves finish. While they remain in flight, native wakes and bounded scans can
558
- * advance more heads. All Attempts share the event's original ten-minute yield deadline.
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.
559
628
  * 4. Native message delivery retains its driver-owned Claim deadline. Host/backfill joins are
560
629
  * bounded independently; incoming native work never restarts or cancels their attempts.
561
630
  * Auxiliary failures are reported after the current native opportunity.
@@ -572,6 +641,15 @@ export class ThreadMaintenance extends Context.Service<
572
641
  * generation has an alarm. It never scans the ledger or canonical history.
573
642
  */
574
643
  readonly ensureAlarm: Effect.Effect<void, MaintenancePassFailure>;
644
+ /**
645
+ * Authorize `explain`, then read one bounded local record without reading execution history.
646
+ * None means no recorded fault, not proof of health or settlement. The host authenticates
647
+ * callers and verifies local Thread membership before exposing this service across RPC.
648
+ * Provide OperationAuthorizer when constructing this Layer, as for DurableAgentRuntime.
649
+ */
650
+ readonly recoveryStatus: (
651
+ threadId: ThreadId,
652
+ ) => Effect.Effect<Option.Option<ThreadRecoveryFault>, DurableAlarmError | OperationDenied>;
575
653
  /**
576
654
  * Serialize the pre-arm boundary with pass acknowledgement, advance the durable dirty
577
655
  * generation and arm the alarm in one transaction BEFORE running the caller's mutation.
@@ -589,6 +667,7 @@ export class ThreadMaintenance extends Context.Service<
589
667
  | ThreadPublication
590
668
  | ThreadProjectionMaintenance
591
669
  | DurableAgentRuntime
670
+ | DurableRuntimeConfig
592
671
  | SubmissionLedger
593
672
  | WakeScheduler
594
673
  | DurableAlarmService
@@ -599,6 +678,7 @@ export class ThreadMaintenance extends Context.Service<
599
678
  > = Layer.effect(ThreadMaintenance)(
600
679
  Effect.gen(function* () {
601
680
  const runtime = yield* DurableAgentRuntime;
681
+ const recoveryConfig = yield* DurableRuntimeConfig;
602
682
  const ledger = yield* SubmissionLedger;
603
683
  const wakes = yield* WakeScheduler;
604
684
  const alarm = yield* DurableAlarmService;
@@ -611,6 +691,7 @@ export class ThreadMaintenance extends Context.Service<
611
691
  const projection = yield* ThreadProjectionMaintenance;
612
692
  const messages = yield* ThreadMessageDelivery;
613
693
  const host = yield* ThreadHostMaintenance;
694
+ const authorizer = yield* OperationAuthorizer;
614
695
 
615
696
  // A broken disposable index still needs a retry alarm and must not prevent startup.
616
697
  const projectionDeadline = projection.pendingDeadline.pipe(
@@ -635,6 +716,97 @@ export class ThreadMaintenance extends Context.Service<
635
716
 
636
717
  const runTransaction = yield* makeStorageOperation;
637
718
 
719
+ const recoveryStatus = Effect.fn("ThreadMaintenance.recoveryStatus")(function* (
720
+ threadId: ThreadId,
721
+ ) {
722
+ yield* authorizer.authorize(
723
+ OperationAuthorizationRequest.make({ operation: "explain", threadId }),
724
+ );
725
+
726
+ return yield* runTransaction("read Thread recovery status", async () => {
727
+ const encoded = await ctx.storage.get(recoveryFaultKey(threadId));
728
+
729
+ return encoded === undefined
730
+ ? Option.none()
731
+ : Option.some(decodeRecoveryFault(threadId, encoded));
732
+ });
733
+ });
734
+
735
+ const recordRecoveryStatus = Effect.fn("ThreadMaintenance.recordRecoveryStatus")(function* (
736
+ result: RecoverySweepResult,
737
+ ) {
738
+ const threads = new Map<ThreadId, RecoveryFailure | undefined>();
739
+
740
+ for (const report of result.reports) threads.set(report.threadId, undefined);
741
+ for (const blocked of result.blocked) threads.set(blocked.threadId, blocked.failure);
742
+ if (threads.size === 0) return new Map<ThreadId, ThreadRecoveryFault>();
743
+ const now = yield* Clock.currentTimeMillis;
744
+
745
+ yield* failpoint.hit("maintenance:recovery-status:before");
746
+
747
+ const retained = yield* runTransaction("record Thread recovery status", () =>
748
+ ctx.storage.transaction(async (transaction) => {
749
+ const newlyBlocked: Array<ThreadRecoveryFault> = [];
750
+ const faults = new Map<ThreadId, ThreadRecoveryFault>();
751
+
752
+ for (const [threadId, failure] of threads) {
753
+ const key = recoveryFaultKey(threadId);
754
+ const encoded = await transaction.get(key);
755
+
756
+ const previous =
757
+ encoded === undefined ? undefined : decodeRecoveryFault(threadId, encoded);
758
+
759
+ if (failure === undefined) {
760
+ if (previous !== undefined) await transaction.delete(key);
761
+ continue;
762
+ }
763
+
764
+ const fault = ThreadRecoveryFault.make({
765
+ schemaVersion: 1,
766
+ threadId,
767
+ firstFailedAt: previous?.firstFailedAt ?? now,
768
+ lastFailedAt: now,
769
+ attempts: Math.min(2_147_483_647, (previous?.attempts ?? 0) + 1),
770
+ retryAt: now + Math.min(60_000, 5_000 * 2 ** Math.min(30, previous?.attempts ?? 0)),
771
+ failure,
772
+ });
773
+
774
+ await transaction.put(key, encodeRecoveryFault(fault));
775
+ faults.set(threadId, fault);
776
+ if (previous === undefined) newlyBlocked.push(fault);
777
+ }
778
+
779
+ return { newlyBlocked, faults };
780
+ }),
781
+ );
782
+
783
+ yield* failpoint.hit("maintenance:recovery-status:after");
784
+ for (const fault of retained.newlyBlocked)
785
+ yield* Effect.logError(
786
+ "Native Thread recovery blocked; accepted work remains pending",
787
+ fault.failure.reason === "defect"
788
+ ? Cause.die(fault.failure)
789
+ : Cause.fail(fault.failure),
790
+ ).pipe(Effect.annotateLogs({ threadId: fault.threadId }));
791
+
792
+ return retained.faults;
793
+ });
794
+
795
+ const recoverThread = Effect.fn("ThreadMaintenance.recoverThread")(function* (
796
+ threadId: ThreadId,
797
+ recovery: NativeRecovery,
798
+ ) {
799
+ const result = yield* runtime.runRecovery({ threadId });
800
+ // Visibility is committed before a claim or any fallible auxiliary join.
801
+ const faults = yield* recordRecoveryStatus(result);
802
+
803
+ for (const report of result.reports) recovery.reports.set(report.submissionId, report);
804
+ recovery.faults.delete(threadId);
805
+ for (const [id, fault] of faults) recovery.faults.set(id, fault);
806
+ recovery.recovered += result.reports.length + result.blocked.length;
807
+ recovery.repaired ||= result.reports.some((report) => report.disposition === "repaired");
808
+ });
809
+
638
810
  const ensureAlarm = Effect.fn("ThreadMaintenance.ensureAlarm")(function* () {
639
811
  yield* failpoint.hit("maintenance:ensure:before");
640
812
  const now = yield* Clock.currentTimeMillis;
@@ -821,6 +993,8 @@ export class ThreadMaintenance extends Context.Service<
821
993
  started: Effect.Success<ReturnType<typeof beginNative>>,
822
994
  yieldAfter: DateTime.Utc,
823
995
  observed: MaintenanceObservation,
996
+ recovery: NativeRecovery,
997
+ dispatch = true,
824
998
  ): Effect.fn.Return<NativePassResult, MaintenancePassFailure> {
825
999
  const deadline = yield* publication.pendingDeadline;
826
1000
 
@@ -856,33 +1030,99 @@ export class ThreadMaintenance extends Context.Service<
856
1030
 
857
1031
  return {
858
1032
  phase: "caught-up",
859
- recovered: 0,
860
1033
  settled: 0,
861
1034
  nonterminal: started.nonterminal,
862
1035
  nextAttemptAt,
863
1036
  };
864
1037
  }
865
- // Step 2 — reconciliation strictly precedes new work in this pass (exit gate).
1038
+ // Select from control state before reading execution history. A recovering or faulted
1039
+ // Thread cannot enter dispatch; old cleanup has its own scoped opportunity below.
866
1040
  observed.nativeOnly = true;
867
- const recovered: ReadonlyArray<RecoveryReport> = yield* runtime.runRecovery;
868
- const reports = new Map(recovered.map((report) => [report.submissionId, report]));
1041
+
1042
+ // Every checkpoint keeps the producer overlap that belongs to this recovery wave.
1043
+ const observation = (recovery.observation ??= {
1044
+ generation: started.generation,
1045
+ activeAtStart: started.activeAtStart,
1046
+ });
1047
+
869
1048
  const current = yield* Stream.runCollect(ledger.scanNonterminal);
870
- const heads = new Map<ThreadId, SubmissionSnapshot>();
1049
+ const selectionTime = yield* Clock.currentTimeMillis;
1050
+
1051
+ yield* runTransaction("read Thread recovery deadlines", async () => {
1052
+ for (const threadId of new Set(current.map((row) => row.threadId))) {
1053
+ if (recovery.loaded.has(threadId)) continue;
1054
+ const encoded = await ctx.storage.get(recoveryFaultKey(threadId));
1055
+
1056
+ if (encoded !== undefined)
1057
+ recovery.faults.set(threadId, decodeRecoveryFault(threadId, encoded));
1058
+ recovery.loaded.add(threadId);
1059
+ }
1060
+ });
1061
+ const reports = recovery.reports;
1062
+ const recoveryFaults = recovery.faults;
1063
+
1064
+ const waiting = (row: SubmissionWorkItem) =>
1065
+ !recovery.pending.has(row.threadId) &&
1066
+ !recoveryFaults.has(row.threadId) &&
1067
+ stableExternalWait(row, reports);
1068
+
1069
+ const heads = new Map<ThreadId, SubmissionWorkItem>();
871
1070
 
872
1071
  for (const row of current) {
873
- // Parked uncertainty keeps its settlement obligation, but later input can run.
874
- // Accepted aborts and every other wait remain subject to the lane's FIFO barrier.
875
- if (row.state === "unknown" && stableExternalWait(row, reports)) continue;
1072
+ // Only recovered uncertainty may release later input; accepted aborts retain FIFO.
1073
+ if (row.state === "unknown" && waiting(row)) continue;
876
1074
  if (!heads.has(row.threadId)) heads.set(row.threadId, row);
877
1075
  }
1076
+ const stopping = new Set<ThreadId>();
1077
+
1078
+ for (const head of heads.values()) {
1079
+ if (
1080
+ head.state !== "ready" ||
1081
+ recovery.pending.has(head.threadId) ||
1082
+ recoveryFaults.has(head.threadId)
1083
+ )
1084
+ continue;
1085
+
1086
+ // An accepted abort is cleanup even when its input was never claimed. This
1087
+ // control-only read must not decode the execution journal or a recovery snapshot.
1088
+ const intent = yield* isolateRecovery(
1089
+ ledger.readAbortIntent(AbortIntentRequest.make({ submissionId: head.submissionId })),
1090
+ {
1091
+ timeout: recoveryConfig.recoveryTimeout,
1092
+ phase: () => "recovery",
1093
+ operation: "read abort intent",
1094
+ },
1095
+ );
1096
+
1097
+ if (Result.isSuccess(intent)) {
1098
+ if (intent.success !== undefined) stopping.add(head.threadId);
1099
+ } else {
1100
+ const faults = yield* recordRecoveryStatus(
1101
+ RecoverySweepResult.make({
1102
+ reports: [],
1103
+ blocked: [
1104
+ RecoveryBlocked.make({ threadId: head.threadId, failure: intent.failure }),
1105
+ ],
1106
+ }),
1107
+ );
1108
+
1109
+ for (const [threadId, fault] of faults) recoveryFaults.set(threadId, fault);
1110
+ recovery.recovered++;
1111
+ }
1112
+ }
878
1113
 
879
1114
  const eligible = [...heads.values()]
880
- .filter((head) => !stableExternalWait(head, reports))
1115
+ .filter(
1116
+ (head) =>
1117
+ !recovery.pending.has(head.threadId) &&
1118
+ !stopping.has(head.threadId) &&
1119
+ !recoveryFaults.has(head.threadId) &&
1120
+ !waiting(head) &&
1121
+ (head.state === "ready" || reports.has(head.submissionId)),
1122
+ )
881
1123
  .map((head) => head.threadId)
882
1124
  .sort();
883
1125
 
884
- const selectionTime = yield* Clock.currentTimeMillis;
885
-
886
1126
  yield* failpoint.hit("maintenance:select:before");
887
1127
 
888
1128
  const selection = yield* runTransaction("select maintenance lane", () =>
@@ -900,11 +1140,12 @@ export class ThreadMaintenance extends Context.Service<
900
1140
  ),
901
1141
  );
902
1142
 
903
- const next =
904
- runnable.find(
905
- (threadId) =>
906
- state.lastServedThreadId === undefined || threadId > state.lastServedThreadId,
907
- ) ?? runnable[0];
1143
+ const next = dispatch
1144
+ ? (runnable.find(
1145
+ (threadId) =>
1146
+ state.lastServedThreadId === undefined || threadId > state.lastServedThreadId,
1147
+ ) ?? runnable[0])
1148
+ : undefined;
908
1149
 
909
1150
  if (next !== undefined) {
910
1151
  await transaction.put(
@@ -915,22 +1156,54 @@ export class ThreadMaintenance extends Context.Service<
915
1156
  );
916
1157
  }
917
1158
 
918
- return { selected: next, retries };
1159
+ const backlog = recovery.started
1160
+ ? []
1161
+ : [...heads.keys()].filter((threadId) => {
1162
+ const fault = recoveryFaults.get(threadId);
1163
+
1164
+ return (
1165
+ !eligible.includes(threadId) &&
1166
+ (fault === undefined || fault.retryAt <= selectionTime)
1167
+ );
1168
+ });
1169
+
1170
+ // One finite wave, one Thread at a time, with the runtime's per-Thread deadline.
1171
+ // This cursor ensures an event deadline/eviction cannot always restart at the front.
1172
+ const after = backlog.filter(
1173
+ (threadId) =>
1174
+ state.lastRecoveredThreadId === undefined || threadId > state.lastRecoveredThreadId,
1175
+ );
1176
+
1177
+ const before = backlog.filter(
1178
+ (threadId) =>
1179
+ state.lastRecoveredThreadId !== undefined &&
1180
+ threadId <= state.lastRecoveredThreadId,
1181
+ );
1182
+
1183
+ return { selected: next, retries, backlog: [...after, ...before] };
919
1184
  }),
920
1185
  );
921
1186
 
922
1187
  yield* failpoint.hit("maintenance:select:after");
1188
+ if (!recovery.started) {
1189
+ recovery.started = true;
1190
+ recovery.needsCheckpoint = selection.backlog.length > 0;
1191
+ for (const threadId of selection.backlog) recovery.pending.add(threadId);
1192
+ yield* Deferred.succeed(recovery.queue, selection.backlog);
1193
+ }
923
1194
 
924
1195
  const selected =
925
1196
  selection.selected === undefined ? undefined : heads.get(selection.selected);
926
1197
 
1198
+ if (selected !== undefined) yield* recoverThread(selected.threadId, recovery);
1199
+
927
1200
  let retries = selection.retries;
928
1201
  let bindingFailure: DurableBindingFailure | undefined;
929
1202
 
930
1203
  // One runnable FIFO head per native opportunity. An absent agent waits for a deployment,
931
1204
  // including for children; other local lanes and host deliveries remain independently due.
932
1205
  const settlement =
933
- selected === undefined
1206
+ selected === undefined || recoveryFaults.has(selected.threadId)
934
1207
  ? Option.none()
935
1208
  : yield* runtime.processThreadHead(selected.threadId, { yieldAfter }).pipe(
936
1209
  Effect.catchTag("BindingUnavailable", (failure) => {
@@ -999,11 +1272,10 @@ export class ThreadMaintenance extends Context.Service<
999
1272
  const waitingHeads = new Map<ThreadId, boolean>();
1000
1273
 
1001
1274
  const autonomous = remaining.some((snapshot) => {
1002
- if (snapshot.state === "unknown" && stableExternalWait(snapshot, reports)) return false;
1275
+ if (snapshot.state === "unknown" && waiting(snapshot)) return false;
1003
1276
  const headWaiting = waitingHeads.get(snapshot.threadId);
1004
1277
 
1005
- if (headWaiting === undefined)
1006
- waitingHeads.set(snapshot.threadId, stableExternalWait(snapshot, reports));
1278
+ if (headWaiting === undefined) waitingHeads.set(snapshot.threadId, waiting(snapshot));
1007
1279
  // FIFO followers cannot execute through a stable external wait. Only plain queued
1008
1280
  // input is dormant here; admission repairs and accepted aborts still need a pass.
1009
1281
  if (
@@ -1013,26 +1285,28 @@ export class ThreadMaintenance extends Context.Service<
1013
1285
  )
1014
1286
  return false;
1015
1287
 
1016
- return !stableExternalWait(snapshot, reports);
1288
+ return !waiting(snapshot);
1017
1289
  });
1018
1290
 
1019
- const progressed =
1020
- Option.isSome(settlement) ||
1021
- recovered.some((report) => report.disposition === "repaired");
1291
+ const progressed = Option.isSome(settlement) || recovery.repaired;
1022
1292
 
1023
1293
  const now = yield* Clock.currentTimeMillis;
1024
1294
  const ordinaryDelay = autonomous ? yield* rearmDelay(progressed, started.stalls) : 0;
1025
1295
 
1026
- const nextEligible = eligible.map(
1027
- (threadId) =>
1028
- retries.find((retry) => retry.submissionId === heads.get(threadId)?.submissionId)
1029
- ?.notBefore ?? now,
1030
- );
1296
+ const nextEligible = [...recoveryFaults.values()]
1297
+ .map((fault) => fault.retryAt)
1298
+ .concat(
1299
+ eligible.map(
1300
+ (threadId) =>
1301
+ retries.find((retry) => retry.submissionId === heads.get(threadId)?.submissionId)
1302
+ ?.notBefore ?? now,
1303
+ ),
1304
+ );
1031
1305
 
1032
- const bindingDelay =
1306
+ const retryDelay =
1033
1307
  nextEligible.length === 0 ? 0 : Math.max(0, Math.min(...nextEligible) - now);
1034
1308
 
1035
- const delay = Math.max(ordinaryDelay, bindingDelay);
1309
+ const delay = Math.max(ordinaryDelay, retryDelay);
1036
1310
 
1037
1311
  // Checkpoint native progress without changing the physical alarm. Auxiliary
1038
1312
  // delivery remains live; later mutations still advance the shared generation.
@@ -1044,11 +1318,14 @@ export class ThreadMaintenance extends Context.Service<
1044
1318
  const { state } = await readMaintenanceState(transaction);
1045
1319
 
1046
1320
  const processed =
1047
- autonomous || started.activeAtStart > 0 || active > 0
1321
+ autonomous ||
1322
+ observation.activeAtStart > 0 ||
1323
+ started.activeAtStart > 0 ||
1324
+ active > 0
1048
1325
  ? state.processed
1049
- : state.processed > started.generation
1326
+ : state.processed > observation.generation
1050
1327
  ? state.processed
1051
- : started.generation;
1328
+ : observation.generation;
1052
1329
 
1053
1330
  const next = ThreadMaintenanceState.make({
1054
1331
  ...Struct.omit(state, ["retry"]),
@@ -1087,7 +1364,6 @@ export class ThreadMaintenance extends Context.Service<
1087
1364
 
1088
1365
  return {
1089
1366
  phase: "actionable",
1090
- recovered: recovered.length,
1091
1367
  settled: Option.isSome(settlement) ? 1 : 0,
1092
1368
  nonterminal: remaining.length,
1093
1369
  nextAttemptAt,
@@ -1106,7 +1382,9 @@ export class ThreadMaintenance extends Context.Service<
1106
1382
  Effect.catch(() => Effect.never),
1107
1383
  );
1108
1384
 
1109
- let started = yield* beginNative(observed);
1385
+ // A wake may observe temporary backoff while this event's recovery is still running.
1386
+ // Its completion must retain the original actionable observation for acknowledgement.
1387
+ const started = yield* beginNative(observed);
1110
1388
 
1111
1389
  // This scope owns auxiliary dispatch and listeners, independently of native progress.
1112
1390
  // Close it before final alarm rearming, including on failure or event interruption.
@@ -1114,6 +1392,43 @@ export class ThreadMaintenance extends Context.Service<
1114
1392
  Scope.close(scope, exit),
1115
1393
  );
1116
1394
 
1395
+ const recovery: NativeRecovery = {
1396
+ queue: yield* Deferred.make<ReadonlyArray<ThreadId>>(),
1397
+ pending: new Set(),
1398
+ loaded: new Set(),
1399
+ reports: new Map(),
1400
+ faults: new Map(),
1401
+ started: false,
1402
+ needsCheckpoint: false,
1403
+ recovered: 0,
1404
+ repaired: false,
1405
+ };
1406
+
1407
+ const recoveryFiber = yield* Effect.forkIn(
1408
+ Effect.gen(function* () {
1409
+ for (const threadId of yield* Deferred.await(recovery.queue)) {
1410
+ yield* failpoint.hit("maintenance:select:before");
1411
+ yield* runTransaction("select old recovery lane", () =>
1412
+ ctx.storage.transaction(async (transaction) => {
1413
+ const { state } = await readMaintenanceState(transaction);
1414
+
1415
+ await transaction.put(
1416
+ MAINTENANCE_STATE_KEY,
1417
+ encodeMaintenanceState(
1418
+ ThreadMaintenanceState.make({ ...state, lastRecoveredThreadId: threadId }),
1419
+ ),
1420
+ );
1421
+ }),
1422
+ );
1423
+ yield* failpoint.hit("maintenance:select:after");
1424
+ yield* recoverThread(threadId, recovery);
1425
+ recovery.pending.delete(threadId);
1426
+ yield* wakes.notify(threadId);
1427
+ }
1428
+ }),
1429
+ auxiliaryScope,
1430
+ );
1431
+
1117
1432
  const dispatchClosed = yield* Deferred.make<void>();
1118
1433
  const stopDispatch = Deferred.await(dispatchClosed);
1119
1434
 
@@ -1152,41 +1467,63 @@ export class ThreadMaintenance extends Context.Service<
1152
1467
  auxiliaryScope,
1153
1468
  );
1154
1469
 
1155
- let result = yield* advance(started, yieldAfter, observed);
1470
+ let result = yield* advance(started, yieldAfter, observed, recovery);
1471
+
1472
+ // This event owns one finite old-recovery wave, including an empty caught-up wave.
1473
+ recovery.started = true;
1474
+ yield* Deferred.succeed(recovery.queue, []);
1156
1475
  let phase = result.phase;
1157
- let recovered = result.recovered;
1158
1476
  let settled = result.settled;
1159
1477
 
1160
1478
  observed.nativeOnly = false;
1161
1479
 
1162
- // Close admission of new delivery waves once, then keep advancing native
1163
- // work while the already-admitted waves finish. Neither lane restarts the
1164
- // other's work or receives a fresh event budget.
1165
- yield* Deferred.succeed(dispatchClosed, undefined);
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.
1483
+ const hostJoin = yield* Effect.forkIn(
1484
+ Effect.gen(function* () {
1485
+ yield* stopDispatch;
1166
1486
 
1167
- const remaining = Math.max(
1168
- 1,
1169
- DateTime.toEpochMillis(dispatchUntil) - (yield* Clock.currentTimeMillis),
1170
- );
1487
+ const remaining = Math.max(
1488
+ 1,
1489
+ DateTime.toEpochMillis(dispatchUntil) - (yield* Clock.currentTimeMillis),
1490
+ );
1171
1491
 
1172
- const hostJoin = yield* Effect.forkIn(
1173
- Fiber.join(hostFiber).pipe(
1174
- Effect.timeoutOption(Math.min(host.dispatchTimeoutMillis, remaining)),
1175
- Effect.tap((outcome) =>
1176
- Effect.annotateCurrentSpan({ "host.timedOut": Option.isNone(outcome) }),
1177
- ),
1178
- ),
1492
+ const outcome = yield* Fiber.join(hostFiber).pipe(
1493
+ Effect.timeoutOption(Math.min(host.dispatchTimeoutMillis, remaining)),
1494
+ );
1495
+
1496
+ yield* Effect.annotateCurrentSpan({ "host.timedOut": Option.isNone(outcome) });
1497
+ }),
1179
1498
  auxiliaryScope,
1180
1499
  );
1181
1500
 
1182
- const retired = yield* Effect.forkChild(Fiber.joinAll([deliveryFiber, hostJoin, backfill]));
1501
+ const retired = yield* Effect.forkChild(
1502
+ Fiber.joinAll([deliveryFiber, hostJoin, backfill, recoveryFiber]),
1503
+ );
1183
1504
 
1184
1505
  const auxiliaryPending = () =>
1185
1506
  deliveryFiber.pollUnsafe() === undefined ||
1186
1507
  hostFiber.pollUnsafe() === undefined ||
1187
- backfill.pollUnsafe() === undefined;
1188
-
1189
- while (retired.pollUnsafe() === undefined && auxiliaryPending()) {
1508
+ backfill.pollUnsafe() === undefined ||
1509
+ recoveryFiber.pollUnsafe() === undefined;
1510
+
1511
+ while (true) {
1512
+ const recoveryFinished = recoveryFiber.pollUnsafe() !== undefined;
1513
+
1514
+ if (recoveryFinished) {
1515
+ if (recovery.needsCheckpoint) {
1516
+ 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.
1519
+ result = yield* advance(started, yieldAfter, observed, recovery, settled === 0);
1520
+ if (result.phase === "actionable") phase = "actionable";
1521
+ settled += result.settled;
1522
+ recovery.needsCheckpoint = false;
1523
+ }
1524
+ yield* Deferred.succeed(dispatchClosed, undefined);
1525
+ }
1526
+ if (retired.pollUnsafe() !== undefined || !auxiliaryPending()) break;
1190
1527
  const now = yield* Clock.currentTimeMillis;
1191
1528
  const until = DateTime.toEpochMillis(yieldAfter);
1192
1529
 
@@ -1198,24 +1535,46 @@ export class ThreadMaintenance extends Context.Service<
1198
1535
  until,
1199
1536
  );
1200
1537
 
1538
+ // A completion racing this iteration stays armed until the loop handles it.
1539
+ const recoveryDone = recoveryFinished ? Effect.never : Fiber.await(recoveryFiber);
1540
+
1201
1541
  const ready = yield* Effect.raceFirst(
1202
- Effect.raceFirst(notified, Effect.sleep(Math.max(0, next - now))).pipe(Effect.as(true)),
1203
- Fiber.await(retired).pipe(Effect.as(false)),
1542
+ Effect.raceFirst(notified, Effect.sleep(Math.max(0, next - now))).pipe(
1543
+ Effect.as("native" as const),
1544
+ ),
1545
+ Effect.raceFirst(
1546
+ recoveryDone.pipe(Effect.as("recovery" as const)),
1547
+ Fiber.await(retired).pipe(Effect.as("retired" as const)),
1548
+ ),
1204
1549
  );
1205
1550
 
1206
- if (!ready || retired.pollUnsafe() !== undefined || !auxiliaryPending()) break;
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;
1207
1556
  if ((yield* Clock.currentTimeMillis) >= until) break;
1208
1557
 
1209
- started = yield* beginNative(observed);
1210
- result = yield* advance(started, yieldAfter, observed);
1558
+ const awakened = yield* beginNative(observed);
1559
+
1560
+ result = yield* advance(awakened, yieldAfter, observed, recovery);
1211
1561
  if (result.phase === "actionable") phase = "actionable";
1212
- recovered += result.recovered;
1213
1562
  settled += result.settled;
1214
1563
  observed.nativeOnly = false;
1215
1564
  }
1565
+ // The original native yield deadline closes all new waves even if old recovery
1566
+ // is still pending. No recovery or delivery receives a renewed event budget.
1567
+ yield* Deferred.succeed(dispatchClosed, undefined);
1216
1568
  // Preserve driver-owned Claim deadlines and failures, then close every
1217
1569
  // listener before the one final alarm decision.
1218
1570
  yield* Fiber.join(retired);
1571
+ if (recovery.needsCheckpoint) {
1572
+ // The native yield deadline ended dispatch before old recovery finished. Fold its
1573
+ // control state into acknowledgement without starting an Attempt after retirement.
1574
+ result = yield* advance(started, yieldAfter, observed, recovery, false);
1575
+ if (result.phase === "actionable") phase = "actionable";
1576
+ settled += result.settled;
1577
+ }
1219
1578
  yield* Scope.close(auxiliaryScope, Exit.void);
1220
1579
  yield* failpoint.hit("maintenance:finish:before");
1221
1580
 
@@ -1256,7 +1615,7 @@ export class ThreadMaintenance extends Context.Service<
1256
1615
 
1257
1616
  const report = MaintenancePassReport.make({
1258
1617
  phase,
1259
- recovered,
1618
+ recovered: recovery.recovered,
1260
1619
  settled,
1261
1620
  nonterminal: result.nonterminal,
1262
1621
  alarm: disposition,
@@ -1309,6 +1668,7 @@ export class ThreadMaintenance extends Context.Service<
1309
1668
  }),
1310
1669
  ),
1311
1670
  ensureAlarm: mutations.withSnapshot(() => ensureAlarm()),
1671
+ recoveryStatus,
1312
1672
  withMutation: (body) =>
1313
1673
  mutations.withMutation(
1314
1674
  body.pipe(