@yielded/agent-platform-cloudflare 0.1.0-beta.174 → 0.1.0-beta.175

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.
Files changed (30) hide show
  1. package/dist/{Alarm-4J9B8Fg8.mjs → Alarm-B44efvB1.mjs} +111 -74
  2. package/dist/Alarm-B44efvB1.mjs.map +1 -0
  3. package/dist/Alarm.d.mts +2 -0
  4. package/dist/Alarm.mjs +1 -1
  5. package/dist/CloudflareScheduling.mjs +2 -2
  6. package/dist/CloudflareSubscriptions.mjs +2 -2
  7. package/dist/{CloudflareThreadClient-DU548EEj.mjs → CloudflareThreadClient-Ai3MTs3X.mjs} +4 -4
  8. package/dist/CloudflareThreadClient-Ai3MTs3X.mjs.map +1 -0
  9. package/dist/{CloudflareThreadClient-lTm6baDP.d.mts → CloudflareThreadClient-HO_3rkM7.d.mts} +362 -362
  10. package/dist/CloudflareThreadClient.d.mts +1 -1
  11. package/dist/CloudflareThreadClient.mjs +1 -1
  12. package/dist/{ThreadObject-BLYgzcdT.d.mts → ThreadObject-CxNlhoPL.d.mts} +13 -13
  13. package/dist/{ThreadObject-C2LF3btz.mjs → ThreadObject-DGvlHWYi.mjs} +42 -43
  14. package/dist/ThreadObject-DGvlHWYi.mjs.map +1 -0
  15. package/dist/ThreadObject.d.mts +1 -1
  16. package/dist/ThreadObject.mjs +1 -1
  17. package/dist/WakeScheduler.mjs +1 -1
  18. package/dist/index.d.mts +2 -2
  19. package/dist/index.mjs +3 -3
  20. package/dist/{prepared-admission-BoUPHSNQ.mjs → prepared-admission-DYdffQ8K.mjs} +2 -2
  21. package/dist/{prepared-admission-BoUPHSNQ.mjs.map → prepared-admission-DYdffQ8K.mjs.map} +1 -1
  22. package/package.json +1 -1
  23. package/src/Alarm.ts +211 -116
  24. package/src/CloudflareThreadClient.ts +3 -2
  25. package/src/ThreadObject.ts +17 -22
  26. package/src/internal/layers.ts +55 -34
  27. package/src/internal/message-delivery.ts +61 -37
  28. package/dist/Alarm-4J9B8Fg8.mjs.map +0 -1
  29. package/dist/CloudflareThreadClient-DU548EEj.mjs.map +0 -1
  30. package/dist/ThreadObject-C2LF3btz.mjs.map +0 -1
package/src/Alarm.ts CHANGED
@@ -734,6 +734,25 @@ interface NativeDispatch {
734
734
  scanRevision?: number;
735
735
  }
736
736
 
737
+ /** Observe already-owned waves without creating one waiter fiber for each wave. */
738
+ const awaitAnyCompletion = Effect.fnUntraced(function* (
739
+ fibers: ReadonlyArray<Fiber.Fiber<unknown, MaintenancePassFailure>>,
740
+ revision: number,
741
+ ) {
742
+ const mutations = yield* ThreadMutationGate;
743
+
744
+ return yield* Effect.acquireUseRelease(
745
+ Effect.sync<Array<() => void>>(() => []),
746
+ (observers) =>
747
+ Effect.callback<"completed" | "scan">((resume) => {
748
+ observers.push(mutations.observeChange(revision, () => resume(Effect.succeed("scan"))));
749
+ for (const fiber of fibers)
750
+ observers.push(fiber.addObserver(() => resume(Effect.succeed("completed"))));
751
+ }),
752
+ (observers) => Effect.sync(() => observers.forEach((remove) => remove())),
753
+ );
754
+ });
755
+
737
756
  /** Post-native delivery shares a finite connection budget across independent host lanes. */
738
757
  const afterNativeDispatchConcurrency = 2;
739
758
 
@@ -914,6 +933,8 @@ export class ThreadMutationGate extends Context.Service<
914
933
  readonly activeLanes: Effect.Effect<ReadonlySet<string>>;
915
934
  readonly revision: Effect.Effect<number>;
916
935
  readonly awaitChange: (revision: number) => Effect.Effect<void>;
936
+ /** Register an incarnation-local observer; its caller must detach it on completion. */
937
+ readonly observeChange: (revision: number, notify: () => void) => () => void;
917
938
  readonly withSnapshot: <A, E, R>(
918
939
  body: (active: number) => Effect.Effect<A, E, R>,
919
940
  ) => Effect.Effect<A, E, R>;
@@ -934,14 +955,27 @@ export class ThreadMutationGate extends Context.Service<
934
955
  const activeLanes = new Map<string, number>();
935
956
  const generationGate = yield* Semaphore.make(1);
936
957
  let revision = 0;
937
- let changed = yield* Deferred.make<void>();
958
+ const listeners = new Set<() => void>();
959
+
960
+ const observeChange = (observed: number, listener: () => void): (() => void) => {
961
+ if (observed !== revision) {
962
+ listener();
963
+
964
+ return () => {};
965
+ }
966
+ listeners.add(listener);
938
967
 
939
- const notify = Effect.gen(function* () {
940
- const previous = changed;
968
+ return () => {
969
+ listeners.delete(listener);
970
+ };
971
+ };
941
972
 
942
- changed = yield* Deferred.make<void>();
973
+ const notify = Effect.sync(() => {
943
974
  revision++;
944
- yield* Deferred.succeed(previous, undefined);
975
+ const pending = [...listeners];
976
+
977
+ listeners.clear();
978
+ for (const listener of pending) listener();
945
979
  });
946
980
 
947
981
  const minimumAlarmDelay = Math.max(1, Math.ceil(config.alarmBackoffBase / 2));
@@ -1182,8 +1216,13 @@ export class ThreadMutationGate extends Context.Service<
1182
1216
  recordProgress,
1183
1217
  activeLanes: Effect.sync(() => new Set(activeLanes.keys())),
1184
1218
  revision: Effect.sync(() => revision),
1219
+ observeChange,
1185
1220
  awaitChange: (observed) =>
1186
- Effect.suspend(() => (observed === revision ? Deferred.await(changed) : Effect.void)),
1221
+ Effect.callback((resume) => {
1222
+ const remove = observeChange(observed, () => resume(Effect.void));
1223
+
1224
+ return Effect.sync(remove);
1225
+ }),
1187
1226
  withSnapshot: (body) =>
1188
1227
  generationGate.withPermit(Effect.flatMap(Ref.get(activeMutations), body)),
1189
1228
  });
@@ -1259,6 +1298,7 @@ export class ThreadMaintenance extends Context.Service<
1259
1298
  const runtime = yield* DurableAgentRuntime;
1260
1299
  const recoveryConfig = yield* DurableRuntimeConfig;
1261
1300
  const ledger = yield* SubmissionLedger;
1301
+ const listNonterminal = ledger.listNonterminal ?? Stream.runCollect(ledger.scanNonterminal);
1262
1302
  const wakes = yield* WakeScheduler;
1263
1303
  const hints = yield* DurableAlarmService;
1264
1304
  const alarms = yield* ThreadMaintenanceAlarm;
@@ -1280,6 +1320,8 @@ export class ThreadMaintenance extends Context.Service<
1280
1320
  const messages = yield* ThreadMessageDelivery;
1281
1321
  const host = yield* ThreadHostMaintenance;
1282
1322
  const framework = yield* ThreadNativeMaintenance;
1323
+ const placement = yield* Effect.serviceOption(ThreadObjectPlacement);
1324
+ const nativeThread = Option.isSome(placement) ? placement.value.threadId : undefined;
1283
1325
 
1284
1326
  const nativeDispatchConcurrency = () => (framework.singleThreadRuntime === runtime ? 1 : 2);
1285
1327
 
@@ -1564,9 +1606,7 @@ export class ThreadMaintenance extends Context.Service<
1564
1606
  const now = yield* Clock.currentTimeMillis;
1565
1607
 
1566
1608
  // Fault-only discovery uses control state even when retained payloads cannot decode.
1567
- const submissions =
1568
- current ??
1569
- (result.blocked.length === 0 ? [] : yield* Stream.runCollect(ledger.scanNonterminal));
1609
+ const submissions = current ?? (result.blocked.length === 0 ? [] : yield* listNonterminal);
1570
1610
 
1571
1611
  yield* failpoint.hit("maintenance:recovery-status:before");
1572
1612
 
@@ -1681,7 +1721,11 @@ export class ThreadMaintenance extends Context.Service<
1681
1721
 
1682
1722
  do {
1683
1723
  const result = yield* runtime
1684
- .runRecovery({ threadId, ...(cursor === undefined ? {} : { cursor }) })
1724
+ .runRecovery({
1725
+ threadId,
1726
+ deferUnclaimedAdmissions: true,
1727
+ ...(cursor === undefined ? {} : { cursor }),
1728
+ })
1685
1729
  .pipe(Effect.provideService(CurrentNativeSource, true));
1686
1730
 
1687
1731
  // Persist each bounded pass before a claim; eviction resumes past consumed metadata.
@@ -2124,19 +2168,31 @@ export class ThreadMaintenance extends Context.Service<
2124
2168
 
2125
2169
  return {
2126
2170
  scannedGeneration: state.dirty,
2127
- afterWorkThread: state.lastRecoveredThreadId,
2171
+ // Single-Thread placement would immediately wrap past its own cursor.
2172
+ afterWorkThread:
2173
+ framework.singleThreadRuntime === runtime &&
2174
+ state.lastRecoveredThreadId === nativeThread
2175
+ ? undefined
2176
+ : state.lastRecoveredThreadId,
2128
2177
  };
2129
2178
  },
2130
2179
  );
2131
2180
 
2132
- const current = yield* Stream.runCollect(ledger.scanNonterminal);
2133
-
2134
- let workOwners = yield* runtime
2135
- .discoverWorkThreads({
2136
- limit: 32,
2137
- ...(afterWorkThread === undefined ? {} : { afterThreadId: afterWorkThread }),
2138
- })
2139
- .pipe(Effect.provideService(CurrentNativeSource, true));
2181
+ const current = yield* listNonterminal;
2182
+
2183
+ // A current admission proves this native placement is materialized. Empty Objects
2184
+ // still discover retained canonical obligations without inventing a recovery owner.
2185
+ let workOwners =
2186
+ framework.singleThreadRuntime === runtime &&
2187
+ nativeThread !== undefined &&
2188
+ current.some((submission) => submission.threadId === nativeThread)
2189
+ ? { threadIds: [nativeThread], afterThreadId: undefined }
2190
+ : yield* runtime
2191
+ .discoverWorkThreads({
2192
+ limit: 32,
2193
+ ...(afterWorkThread === undefined ? {} : { afterThreadId: afterWorkThread }),
2194
+ })
2195
+ .pipe(Effect.provideService(CurrentNativeSource, true));
2140
2196
 
2141
2197
  if (workOwners.threadIds.length === 0 && afterWorkThread !== undefined)
2142
2198
  workOwners = yield* runtime
@@ -2398,7 +2454,7 @@ export class ThreadMaintenance extends Context.Service<
2398
2454
  };
2399
2455
  }
2400
2456
 
2401
- const remaining = yield* Stream.runCollect(ledger.scanNonterminal);
2457
+ const remaining = yield* listNonterminal;
2402
2458
  const waitingHeads = new Map<ThreadId, boolean>();
2403
2459
 
2404
2460
  // Selected recovery must finish before its generation can be acknowledged: terminal
@@ -2565,12 +2621,22 @@ export class ThreadMaintenance extends Context.Service<
2565
2621
  dispatchUntil: DateTime.Utc,
2566
2622
  observed: MaintenanceObservation,
2567
2623
  ) {
2624
+ const leanSelection =
2625
+ framework.singleThreadRuntime === runtime &&
2626
+ publication === emptyPublication &&
2627
+ host.lanes.length === 0 &&
2628
+ framework.lanes.length === 0;
2629
+
2568
2630
  // Subscribe before the first snapshot. Durable enrollment recovers eviction;
2569
2631
  // producer completion and wake hints make work visible during this incarnation.
2570
- const notified = (yield* Stream.toPull(wakes.wakes)).pipe(
2571
- Effect.asVoid,
2572
- Effect.catch(() => Effect.never),
2573
- );
2632
+ // Native source mutations already notify the revision waiter below. A replacement
2633
+ // or shared runtime keeps the generic wake stream; hints confer no authority.
2634
+ const notified = leanSelection
2635
+ ? Effect.never
2636
+ : (yield* Stream.toPull(wakes.wakes)).pipe(
2637
+ Effect.asVoid,
2638
+ Effect.catch(() => Effect.never),
2639
+ );
2574
2640
 
2575
2641
  // A wake may observe temporary backoff while this event's recovery is still running.
2576
2642
  // Its completion must retain the original actionable observation for acknowledgement.
@@ -2622,56 +2688,8 @@ export class ThreadMaintenance extends Context.Service<
2622
2688
  discoveryPending: false,
2623
2689
  };
2624
2690
 
2625
- const recoveryFiber = yield* fork(
2626
- Effect.gen(function* () {
2627
- for (const threadId of yield* Deferred.await(recovery.queue)) {
2628
- yield* failpoint.hit("maintenance:select:before");
2629
- yield* runTransaction("select old recovery lane", () =>
2630
- dueQueue.transaction(async (transaction) => {
2631
- const { state } = await readMaintenanceState(transaction);
2632
-
2633
- await transaction.put(
2634
- MAINTENANCE_STATE_KEY,
2635
- encodeMaintenanceState(
2636
- ThreadMaintenanceState.make({
2637
- ...state,
2638
- lastRecoveredThreadId: threadId,
2639
- }),
2640
- ),
2641
- );
2642
- }),
2643
- );
2644
- yield* failpoint.hit("maintenance:select:after");
2645
- yield* recoverThread(threadId, recovery);
2646
- recovery.pending.delete(threadId);
2647
- if (!recovery.faults.has(threadId)) yield* wakes.notify(threadId);
2648
- }
2649
- }),
2650
- );
2651
-
2652
- // Backfill is one disposable wave; native and host work keep their own opportunities.
2653
- const backfill = yield* fork(
2654
- Effect.gen(function* () {
2655
- if ((yield* mutations.activeLanes).has(DueQueue.Projection)) return;
2656
- const row = (yield* queueSnapshot).find((row) => row.id === DueQueue.Projection);
2657
-
2658
- if (
2659
- row?.dueAt === null ||
2660
- row === undefined ||
2661
- row.dueAt > (yield* Clock.currentTimeMillis)
2662
- )
2663
- return;
2664
-
2665
- return yield* runQueued(
2666
- row,
2667
- projection.drain.pipe(Effect.andThen(projection.pendingDeadline)),
2668
- observed,
2669
- ).pipe(Effect.timeoutOption(config.projectionDispatchTimeoutMillis));
2670
- }),
2671
- );
2672
-
2673
- let backfillObserved = false;
2674
- let recoveryObserved = false;
2691
+ let recoveryFiber: Fiber.Fiber<void, MaintenancePassFailure> | undefined;
2692
+ let backfill: Fiber.Fiber<void, DurableAlarmError | ThreadProjectionError> | undefined;
2675
2693
 
2676
2694
  const dispatch: NativeDispatch = {
2677
2695
  scope: auxiliaryScope,
@@ -2683,18 +2701,79 @@ export class ThreadMaintenance extends Context.Service<
2683
2701
  needsCheckpoint: false,
2684
2702
  };
2685
2703
 
2686
- let native: Fiber.Fiber<NativePassResult, MaintenancePassFailure> | undefined = yield* fork(
2687
- advance(started, yieldAfter, observed, recovery, dispatch),
2688
- );
2704
+ // Selection is bounded local work, not an independently owned wave. It opens
2705
+ // actual Attempt scopes, then returns; empty lanes create no fibers.
2706
+ let native: Fiber.Fiber<NativePassResult, MaintenancePassFailure> | undefined;
2707
+ let result: NativePassResult;
2708
+ let backlog: ReadonlyArray<ThreadId> | undefined;
2689
2709
 
2690
- let result: NativePassResult = {
2691
- phase: "caught-up",
2692
- nonterminal: started.nonterminal,
2693
- nextAttemptAt: undefined,
2694
- };
2710
+ if (leanSelection) {
2711
+ result = yield* advance(started, yieldAfter, observed, recovery, dispatch);
2712
+ recovery.started = true;
2713
+ yield* Deferred.succeed(recovery.queue, []);
2714
+ backlog = yield* Deferred.await(recovery.queue);
2715
+ } else {
2716
+ // Required publication and arbitrary host lanes keep independent opportunities
2717
+ // while selection is suspended in an application callback.
2718
+ native = yield* fork(advance(started, yieldAfter, observed, recovery, dispatch));
2719
+ result = {
2720
+ phase: "caught-up",
2721
+ nonterminal: started.nonterminal,
2722
+ nextAttemptAt: undefined,
2723
+ };
2724
+ }
2725
+
2726
+ if (backlog === undefined || backlog.length > 0) {
2727
+ recoveryFiber = yield* fork(
2728
+ Effect.gen(function* () {
2729
+ for (const threadId of backlog ?? (yield* Deferred.await(recovery.queue))) {
2730
+ yield* failpoint.hit("maintenance:select:before");
2731
+ yield* runTransaction("select old recovery lane", () =>
2732
+ dueQueue.transaction(async (transaction) => {
2733
+ const { state } = await readMaintenanceState(transaction);
2734
+
2735
+ await transaction.put(
2736
+ MAINTENANCE_STATE_KEY,
2737
+ encodeMaintenanceState(
2738
+ ThreadMaintenanceState.make({
2739
+ ...state,
2740
+ lastRecoveredThreadId: threadId,
2741
+ }),
2742
+ ),
2743
+ );
2744
+ }),
2745
+ );
2746
+ yield* failpoint.hit("maintenance:select:after");
2747
+ yield* recoverThread(threadId, recovery);
2748
+ recovery.pending.delete(threadId);
2749
+ if (!recovery.faults.has(threadId)) yield* wakes.notify(threadId);
2750
+ }
2751
+ }),
2752
+ );
2753
+ }
2754
+ const projectionRow = (yield* queueSnapshot).find((row) => row.id === DueQueue.Projection);
2755
+
2756
+ if (
2757
+ projectionRow !== undefined &&
2758
+ projectionRow.dueAt !== null &&
2759
+ projectionRow.dueAt <= (yield* Clock.currentTimeMillis) &&
2760
+ !(yield* mutations.activeLanes).has(DueQueue.Projection)
2761
+ ) {
2762
+ backfill = yield* fork(
2763
+ runQueued(
2764
+ projectionRow,
2765
+ projection.drain.pipe(Effect.andThen(projection.pendingDeadline)),
2766
+ observed,
2767
+ ).pipe(Effect.timeoutOption(config.projectionDispatchTimeoutMillis), Effect.asVoid),
2768
+ );
2769
+ }
2695
2770
 
2696
2771
  let phase = result.phase;
2697
- let nativeCheck = false;
2772
+
2773
+ let nativeCheck =
2774
+ result.dispatched === true && dispatch.active.size < nativeDispatchConcurrency();
2775
+
2776
+ observed.nativeOnly = dispatch.active.size > 0;
2698
2777
  const until = DateTime.toEpochMillis(yieldAfter);
2699
2778
  const dispatchEnd = DateTime.toEpochMillis(dispatchUntil);
2700
2779
 
@@ -2725,19 +2804,17 @@ export class ThreadMaintenance extends Context.Service<
2725
2804
  if (exit.value === 0) dispatch.deferred.add(threadId);
2726
2805
  nativeCheck = true;
2727
2806
  }
2728
- if (!recoveryObserved && recoveryFiber.pollUnsafe() !== undefined) {
2807
+ if (recoveryFiber?.pollUnsafe() !== undefined) {
2729
2808
  yield* Fiber.join(recoveryFiber);
2730
- recoveryObserved = true;
2809
+ recoveryFiber = undefined;
2731
2810
  dispatch.scanGeneration = undefined;
2732
2811
  nativeCheck ||= recovery.needsCheckpoint;
2733
2812
  }
2734
- if (!backfillObserved) {
2735
- const exit = backfill.pollUnsafe();
2813
+ const backfillExit = backfill?.pollUnsafe();
2736
2814
 
2737
- if (exit !== undefined) {
2738
- backfillObserved = true;
2739
- if (Exit.isFailure(exit)) failure ??= exit.cause;
2740
- }
2815
+ if (backfillExit !== undefined) {
2816
+ backfill = undefined;
2817
+ if (Exit.isFailure(backfillExit)) failure ??= backfillExit.cause;
2741
2818
  }
2742
2819
  for (const lane of lanes) {
2743
2820
  const exit = lane.fiber?.pollUnsafe();
@@ -2844,9 +2921,9 @@ export class ThreadMaintenance extends Context.Service<
2844
2921
  if (
2845
2922
  native === undefined &&
2846
2923
  dispatch.active.size === 0 &&
2847
- recoveryObserved &&
2924
+ recoveryFiber === undefined &&
2848
2925
  !recovery.needsCheckpoint &&
2849
- backfillObserved &&
2926
+ backfill === undefined &&
2850
2927
  delivery === undefined &&
2851
2928
  lanes.every((lane) => lane.fiber === undefined)
2852
2929
  )
@@ -2859,7 +2936,9 @@ export class ThreadMaintenance extends Context.Service<
2859
2936
  ) {
2860
2937
  nativeCheck = false;
2861
2938
  const nativeCheckpoint = dispatch.needsCheckpoint && dispatch.active.size === 0;
2862
- const checkpoint = (recoveryObserved && recovery.needsCheckpoint) || nativeCheckpoint;
2939
+
2940
+ const checkpoint =
2941
+ (recoveryFiber === undefined && recovery.needsCheckpoint) || nativeCheckpoint;
2863
2942
 
2864
2943
  if (checkpoint) recovery.needsCheckpoint = false;
2865
2944
  // Keep this opportunity's reservations across beginNative's storage awaits.
@@ -2894,26 +2973,39 @@ export class ThreadMaintenance extends Context.Service<
2894
2973
  );
2895
2974
 
2896
2975
  const completing = [
2897
- ...(native === undefined ? [] : [native]),
2976
+ native,
2898
2977
  ...dispatch.active.values(),
2899
- ...(recoveryObserved ? [] : [recoveryFiber]),
2900
- ...(backfillObserved ? [] : [backfill]),
2901
- ...(delivery === undefined ? [] : [delivery]),
2902
- ...lanes.flatMap((lane) => (lane.fiber === undefined ? [] : [lane.fiber])),
2903
- ];
2904
-
2905
- const ready = yield* Effect.raceAllFirst([
2906
- ...completing.map((fiber) =>
2907
- Fiber.await<unknown, MaintenancePassFailure>(fiber).pipe(
2908
- Effect.as("completed" as const),
2909
- ),
2910
- ),
2911
- Effect.raceAllFirst([
2912
- notified,
2913
- mutations.awaitChange(queueRevision),
2914
- Effect.sleep(Math.max(0, next - now)),
2915
- ]).pipe(Effect.as("scan" as const)),
2916
- ]);
2978
+ recoveryFiber,
2979
+ backfill,
2980
+ delivery,
2981
+ ...lanes.map((lane) => lane.fiber),
2982
+ ].filter((fiber) => fiber !== undefined);
2983
+
2984
+ const changed = awaitAnyCompletion(completing, queueRevision);
2985
+
2986
+ // An occupied native-only slot cannot admit another Attempt. With no auxiliary
2987
+ // wake before yield, the timer would only switch to joining that same Attempt.
2988
+ // Incoming work still wakes this callback; all new waves recheck the deadline.
2989
+ const joiningNative =
2990
+ leanSelection &&
2991
+ native === undefined &&
2992
+ dispatch.active.size === 1 &&
2993
+ completing.length === 1 &&
2994
+ !queued.some(
2995
+ (row) =>
2996
+ row.id !== DueQueue.Native &&
2997
+ row.dueAt !== null &&
2998
+ row.dueAt > now &&
2999
+ row.dueAt < until,
3000
+ );
3001
+
3002
+ const ready = yield* joiningNative
3003
+ ? changed
3004
+ : Effect.raceAllFirst([
3005
+ changed,
3006
+ ...(leanSelection ? [] : [notified.pipe(Effect.as("scan" as const))]),
3007
+ Effect.sleep(Math.max(0, next - now)).pipe(Effect.as("scan" as const)),
3008
+ ]);
2917
3009
 
2918
3010
  if (ready === "scan") {
2919
3011
  nativeCheck = true;
@@ -2939,9 +3031,10 @@ export class ThreadMaintenance extends Context.Service<
2939
3031
  for (const fiber of [
2940
3032
  recoveryFiber,
2941
3033
  backfill,
2942
- ...(delivery === undefined ? [] : [delivery]),
2943
- ...lanes.flatMap((lane) => (lane.fiber === undefined ? [] : [lane.fiber])),
3034
+ delivery,
3035
+ ...lanes.map((lane) => lane.fiber),
2944
3036
  ]) {
3037
+ if (fiber === undefined) continue;
2945
3038
  // Await exits without failing fast: every admitted wave owns its original
2946
3039
  // bounded allowance and cleanup even if another wave has already failed.
2947
3040
  const exit = yield* Fiber.await<unknown, MaintenancePassFailure>(fiber);
@@ -2964,7 +3057,9 @@ export class ThreadMaintenance extends Context.Service<
2964
3057
  observed: MaintenanceObservation,
2965
3058
  ): Effect.fn.Return<MaintenancePassReport, MaintenancePassFailure, Scope.Scope> {
2966
3059
  const concurrent = yield* Effect.exit(
2967
- Effect.scoped(dispatch(yieldAfter, dispatchUntil, observed)),
3060
+ Effect.scoped(dispatch(yieldAfter, dispatchUntil, observed)).pipe(
3061
+ Effect.provideService(ThreadMutationGate, mutations),
3062
+ ),
2968
3063
  );
2969
3064
 
2970
3065
  // Closing the concurrent scope first releases every native Attempt and its resources.
@@ -386,13 +386,14 @@ const liveTextProtocolFailure = () =>
386
386
  message: "Provisional text is incomplete or invalid; discard drafts and read canonical records",
387
387
  });
388
388
 
389
+ const decodeLiveTextFrame = Schema.decodeUnknownSync(Schema.fromJsonString(LiveTextFrame));
390
+
389
391
  const decodeLiveText = (
390
392
  threadId: ThreadId,
391
393
  source: ReadableStream<unknown>,
392
394
  ): Stream.Stream<LiveTextFrame, HostProtocolError | ThreadClientError> =>
393
395
  Stream.suspend(() => {
394
396
  const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false });
395
- const decode = Schema.decodeUnknownSync(Schema.fromJsonString(LiveTextFrame));
396
397
  let pending = "";
397
398
  let pendingBytes = 0;
398
399
  let expected = 0;
@@ -488,7 +489,7 @@ const decodeLiveText = (
488
489
  if (newline < 0) break;
489
490
 
490
491
  const frame = yield* Effect.try({
491
- try: () => decode(pending),
492
+ try: () => decodeLiveTextFrame(pending),
492
493
  catch: liveTextProtocolFailure,
493
494
  });
494
495
 
@@ -351,16 +351,17 @@ const gateAdmissionLimits = Effect.fnUntraced(function* (
351
351
  );
352
352
  }
353
353
 
354
- const nonterminal = yield* ledger.scanNonterminal.pipe(
355
- Stream.filter((submission) => submission.threadId === threadId),
356
- Stream.runCollect,
354
+ const queueDepth = yield* Stream.runFold(
355
+ ledger.scanNonterminal,
356
+ () => 0,
357
+ (depth, submission) => depth + (submission.threadId === threadId ? 1 : 0),
357
358
  );
358
359
 
359
- if (nonterminal.length >= config.limits.maxQueueDepthPerLane) {
360
+ if (queueDepth >= config.limits.maxQueueDepthPerLane) {
360
361
  return yield* refuseFresh(
361
362
  AdmissionLimitExceeded.make({
362
363
  limit: "queue-depth",
363
- actual: nonterminal.length,
364
+ actual: queueDepth,
364
365
  maximum: config.limits.maxQueueDepthPerLane,
365
366
  }),
366
367
  );
@@ -1186,31 +1187,25 @@ const effectCfPlatformLayer = (
1186
1187
  DurableObjectContext | ThreadObjectNamespace,
1187
1188
  CloudflareBindingError,
1188
1189
  EffectCfDurableObjectState.DurableObjectState | WorkerEnvironment
1189
- > => {
1190
- const context = Layer.effect(DurableObjectContext)(
1190
+ > =>
1191
+ Layer.effectContext(
1191
1192
  Effect.gen(function* () {
1192
1193
  const state = yield* EffectCfDurableObjectState.DurableObjectState;
1193
1194
  const env = yield* WorkerEnvironment;
1194
-
1195
- return DurableObjectContext.of({ ctx: state.raw, env });
1196
- }),
1197
- );
1198
-
1199
- const namespace = Layer.effect(ThreadObjectNamespace)(
1200
- Effect.gen(function* () {
1201
- const env = yield* WorkerEnvironment;
1202
1195
  const binding = yield* threadNamespaceFromEnv(env, namespaceBinding);
1203
1196
 
1204
- return ThreadObjectNamespace.of({
1205
- get: (threadId) => binding.get(binding.idFromName(threadId)),
1206
- ...(rpcTracing === true ? { rpcTracing: namespaceBinding } : {}),
1207
- });
1197
+ return Context.make(DurableObjectContext, { ctx: state.raw, env }).pipe(
1198
+ Context.add(
1199
+ ThreadObjectNamespace,
1200
+ ThreadObjectNamespace.of({
1201
+ get: (threadId) => binding.get(binding.idFromName(threadId)),
1202
+ ...(rpcTracing === true ? { rpcTracing: namespaceBinding } : {}),
1203
+ }),
1204
+ ),
1205
+ );
1208
1206
  }),
1209
1207
  );
1210
1208
 
1211
- return Layer.merge(context, namespace);
1212
- };
1213
-
1214
1209
  /** The public endpoints and effect-cf invocation hook of one Thread Object instance. */
1215
1210
  export interface Instance<EventServices = never> extends InstanceType<
1216
1211
  EffectCfDurableObject.DurableObjectClass<