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

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/index.mjs CHANGED
@@ -8,8 +8,8 @@ import { t as CloudflareThreadClient_exports } from "./CloudflareThreadClient.mj
8
8
  import { t as CloudflareScheduling_exports } from "./CloudflareScheduling.mjs";
9
9
  import { t as CloudflareSubscriptions_exports } from "./CloudflareSubscriptions.mjs";
10
10
  import { t as WakeScheduler_exports } from "./WakeScheduler.mjs";
11
- import { l as ThreadObject_exports } from "./ThreadObject-DaC-IhCm.mjs";
11
+ import { l as ThreadObject_exports } from "./ThreadObject-CVhZC-7l.mjs";
12
12
  import { t as CloudflareAiGateway_exports } from "./CloudflareAiGateway.mjs";
13
13
  import { t as BrowserCredentials_exports } from "./BrowserCredentials.mjs";
14
- import { i as BrowserSession_exports } from "./BrowserSession-DuOUcC6G.mjs";
14
+ import { i as BrowserSession_exports } from "./BrowserSession-BplIoNfi.mjs";
15
15
  export { Alarm_exports as Alarm, BrowserCredentials_exports as BrowserCredentials, BrowserSession_exports as BrowserSession, CloudflareAiGateway_exports as CloudflareAiGateway, CloudflareBindings_exports as CloudflareBindings, CloudflareBrowser_exports as CloudflareBrowser, CloudflareCodeMode_exports as CloudflareCodeMode, CloudflareConfig_exports as CloudflareConfig, CloudflareMemory_exports as CloudflareMemory, CloudflareScheduling_exports as CloudflareScheduling, CloudflareSubscriptions_exports as CloudflareSubscriptions, CloudflareThreadClient_exports as CloudflareThreadClient, ThreadObject_exports as ThreadObject, WakeScheduler_exports as WakeScheduler };
package/package.json CHANGED
@@ -1 +1 @@
1
- {"name":"@effect-agent/platform-cloudflare","version":"0.1.0-beta.125","dependencies":{"@effect-agent/storage-cloudflare":"0.1.0-beta.125","@effect/platform-browser":"4.0.0-rc.116","@effect/sql-sqlite-do":"4.0.0-rc.116","effect-agent":"0.1.0-beta.125","puppeteer-core":"24.29.0"},"devDependencies":{"@cloudflare/vitest-pool-workers":"0.21.3","@cloudflare/workers-types":"5.20260825.1","@effect-agent/testing":"0.1.0-beta.125","@effect/platform-node":"4.0.0-rc.116","@effect/sql-d1":"4.0.0-rc.116","@effect/vitest":"4.0.0-rc.116","effect":"4.0.0-rc.116","effect-cf":"0.44.1","esbuild":"0.28.1","miniflare":"5.20260811.1-alpha","typescript":"7.0.2","vite-plus":"0.3.2","vitest":"4.1.11"},"peerDependencies":{"effect":"^4.0.0-rc.116","effect-cf":"^0.44.1 || ^0.45.0"},"exports":{".":{"types":"./dist/index.d.mts","default":"./dist/index.mjs"},"./alarm":{"types":"./dist/Alarm.d.mts","default":"./dist/Alarm.mjs"},"./browser-rest-capture":{"types":"./dist/BrowserRestCapture.d.mts","default":"./dist/BrowserRestCapture.mjs"},"./browser-rest-crawl":{"types":"./dist/BrowserRestCrawl.d.mts","default":"./dist/BrowserRestCrawl.mjs"},"./cloudflare-bindings":{"types":"./dist/CloudflareBindings.d.mts","default":"./dist/CloudflareBindings.mjs"},"./cloudflare-browser":{"types":"./dist/CloudflareBrowser.d.mts","default":"./dist/CloudflareBrowser.mjs"},"./cloudflare-code-mode":{"types":"./dist/CloudflareCodeMode.d.mts","default":"./dist/CloudflareCodeMode.mjs"},"./cloudflare-config":{"types":"./dist/CloudflareConfig.d.mts","default":"./dist/CloudflareConfig.mjs"},"./cloudflare-memory":{"types":"./dist/CloudflareMemory.d.mts","default":"./dist/CloudflareMemory.mjs"},"./cloudflare-scheduling":{"types":"./dist/CloudflareScheduling.d.mts","default":"./dist/CloudflareScheduling.mjs"},"./cloudflare-subscriptions":{"types":"./dist/CloudflareSubscriptions.d.mts","default":"./dist/CloudflareSubscriptions.mjs"},"./cloudflare-thread-client":{"types":"./dist/CloudflareThreadClient.d.mts","default":"./dist/CloudflareThreadClient.mjs"},"./interactive-browser":{"types":"./dist/InteractiveBrowser.d.mts","default":"./dist/InteractiveBrowser.mjs"},"./browser-session":{"types":"./dist/BrowserSession.d.mts","default":"./dist/BrowserSession.mjs"},"./browser-credentials":{"types":"./dist/BrowserCredentials.d.mts","default":"./dist/BrowserCredentials.mjs"},"./thread-object":{"types":"./dist/ThreadObject.d.mts","default":"./dist/ThreadObject.mjs"},"./wake-scheduler":{"types":"./dist/WakeScheduler.d.mts","default":"./dist/WakeScheduler.mjs"},"./cloudflare-ai-gateway":{"types":"./dist/CloudflareAiGateway.d.mts","default":"./dist/CloudflareAiGateway.mjs"}},"description":"Cloudflare Layer assembly for Effect Agent: Durable Objects and Browser Run adapters.","license":"MIT","repository":{"type":"git","url":"git+https://github.com/danieljvdm/effect-agent.git","directory":"packages/platform-cloudflare"},"files":["dist","src"],"type":"module","sideEffects":[],"publishConfig":{"access":"public"},"scripts":{"build":"vp pack","check":"tsc --noEmit -p tsconfig.json"}}
1
+ {"name":"@effect-agent/platform-cloudflare","version":"0.1.0-beta.129","dependencies":{"@effect-agent/storage-cloudflare":"0.1.0-beta.129","@effect/platform-browser":"4.0.0-rc.116","@effect/sql-sqlite-do":"4.0.0-rc.116","effect-agent":"0.1.0-beta.129","puppeteer-core":"24.29.0"},"devDependencies":{"@cloudflare/vitest-pool-workers":"0.21.3","@cloudflare/workers-types":"5.20260825.1","@effect-agent/testing":"0.1.0-beta.129","@effect/platform-node":"4.0.0-rc.116","@effect/sql-d1":"4.0.0-rc.116","@effect/vitest":"4.0.0-rc.116","effect":"4.0.0-rc.116","effect-cf":"0.44.1","esbuild":"0.28.1","miniflare":"5.20260811.1-alpha","typescript":"7.0.2","vite-plus":"0.3.2","vitest":"4.1.11"},"peerDependencies":{"effect":"^4.0.0-rc.116","effect-cf":"^0.44.1 || ^0.45.0"},"exports":{".":{"types":"./dist/index.d.mts","default":"./dist/index.mjs"},"./alarm":{"types":"./dist/Alarm.d.mts","default":"./dist/Alarm.mjs"},"./browser-rest-capture":{"types":"./dist/BrowserRestCapture.d.mts","default":"./dist/BrowserRestCapture.mjs"},"./browser-rest-crawl":{"types":"./dist/BrowserRestCrawl.d.mts","default":"./dist/BrowserRestCrawl.mjs"},"./cloudflare-bindings":{"types":"./dist/CloudflareBindings.d.mts","default":"./dist/CloudflareBindings.mjs"},"./cloudflare-browser":{"types":"./dist/CloudflareBrowser.d.mts","default":"./dist/CloudflareBrowser.mjs"},"./cloudflare-code-mode":{"types":"./dist/CloudflareCodeMode.d.mts","default":"./dist/CloudflareCodeMode.mjs"},"./cloudflare-config":{"types":"./dist/CloudflareConfig.d.mts","default":"./dist/CloudflareConfig.mjs"},"./cloudflare-memory":{"types":"./dist/CloudflareMemory.d.mts","default":"./dist/CloudflareMemory.mjs"},"./cloudflare-scheduling":{"types":"./dist/CloudflareScheduling.d.mts","default":"./dist/CloudflareScheduling.mjs"},"./cloudflare-subscriptions":{"types":"./dist/CloudflareSubscriptions.d.mts","default":"./dist/CloudflareSubscriptions.mjs"},"./cloudflare-thread-client":{"types":"./dist/CloudflareThreadClient.d.mts","default":"./dist/CloudflareThreadClient.mjs"},"./interactive-browser":{"types":"./dist/InteractiveBrowser.d.mts","default":"./dist/InteractiveBrowser.mjs"},"./browser-session":{"types":"./dist/BrowserSession.d.mts","default":"./dist/BrowserSession.mjs"},"./browser-credentials":{"types":"./dist/BrowserCredentials.d.mts","default":"./dist/BrowserCredentials.mjs"},"./thread-object":{"types":"./dist/ThreadObject.d.mts","default":"./dist/ThreadObject.mjs"},"./wake-scheduler":{"types":"./dist/WakeScheduler.d.mts","default":"./dist/WakeScheduler.mjs"},"./cloudflare-ai-gateway":{"types":"./dist/CloudflareAiGateway.d.mts","default":"./dist/CloudflareAiGateway.mjs"}},"description":"Cloudflare Layer assembly for Effect Agent: Durable Objects and Browser Run adapters.","license":"MIT","repository":{"type":"git","url":"git+https://github.com/danieljvdm/effect-agent.git","directory":"packages/platform-cloudflare"},"files":["dist","src"],"type":"module","sideEffects":[],"publishConfig":{"access":"public"},"scripts":{"build":"vp pack","check":"tsc --noEmit -p tsconfig.json"}}
package/src/Alarm.ts CHANGED
@@ -46,6 +46,7 @@ import {
46
46
  type ThreadProjectionError,
47
47
  } from "effect-agent/thread-projection-maintenance";
48
48
  import { WakeScheduler } from "effect-agent/wake-scheduler";
49
+ import { DurableObjectStorage } from "effect-cf";
49
50
  import { SqlClient } from "effect/unstable/sql/SqlClient";
50
51
 
51
52
  import { DurableObjectContext } from "./CloudflareBindings.ts";
@@ -85,14 +86,12 @@ const alarmFailure =
85
86
 
86
87
  // SQL and raw KV/alarm operations share one physical SQLite transaction. Reserve its
87
88
  // connection for each short storage operation, never around a mutation or snapshot body.
88
- const makeStorageOperation = Effect.map(
89
+ const makeStorageEffect = Effect.map(
89
90
  SqlClient,
90
91
  (sql) =>
91
- <A>(operation: string, execute: () => Promise<A>) =>
92
+ <A, R>(operation: string, execute: Effect.Effect<A, DurableAlarmError, R>) =>
92
93
  Effect.flatMap(Effect.serviceOption(sql.transactionService), (current) => {
93
- const body = Effect.uninterruptible(
94
- Effect.tryPromise({ try: execute, catch: alarmFailure(operation) }),
95
- );
94
+ const body = Effect.uninterruptible(execute);
96
95
 
97
96
  return current._tag === "Some"
98
97
  ? body
@@ -102,6 +101,13 @@ const makeStorageOperation = Effect.map(
102
101
  }),
103
102
  );
104
103
 
104
+ const makeStorageOperation = Effect.map(
105
+ makeStorageEffect,
106
+ (run) =>
107
+ <A>(operation: string, execute: () => Promise<A>) =>
108
+ run(operation, Effect.tryPromise({ try: execute, catch: alarmFailure(operation) })),
109
+ );
110
+
105
111
  /** `ctx.storage` alarm slot as an Effect service; storage is truth, never a memory field. */
106
112
  export class DurableAlarmService extends Context.Service<
107
113
  DurableAlarmService,
@@ -408,11 +414,26 @@ const encodeRecoveryFault = Schema.encodeSync(ThreadRecoveryFault);
408
414
 
409
415
  interface NativePassResult {
410
416
  readonly phase: "caught-up" | "actionable";
411
- readonly settled: number;
412
417
  readonly nonterminal: number;
413
418
  readonly nextAttemptAt: number | undefined;
419
+ readonly dispatched?: boolean;
420
+ }
421
+
422
+ /** Independent Threads share two bounded native slots; one active head per Thread. */
423
+ interface NativeDispatch {
424
+ readonly scope: Scope.Scope;
425
+ readonly active: Map<ThreadId, Fiber.Fiber<number, MaintenancePassFailure>>;
426
+ readonly deferred: Set<ThreadId>;
427
+ dispatched: number;
428
+ settled: number;
429
+ progressed: boolean;
430
+ needsCheckpoint: boolean;
431
+ generation?: bigint;
432
+ scanGeneration?: bigint;
414
433
  }
415
434
 
435
+ const nativeDispatchConcurrency = 2;
436
+
416
437
  /** Event-local observations only; durable ingress keeps racing mutations dirty. */
417
438
  interface NativeRecovery {
418
439
  readonly queue: Deferred.Deferred<ReadonlyArray<ThreadId>>;
@@ -624,7 +645,8 @@ export type MaintenancePassFailure =
624
645
  * 2. Reconcile before each head Attempt, then checkpoint only the observed generation. A racing
625
646
  * producer keeps its generation dirty and immediately eligible. Quiescent native retries
626
647
  * retain their durable backoff.
627
- * 3. Keep native and delivery admission open together while finite waves remain active, so
648
+ * 3. Admit at most two independent Thread Attempts, one active head per Thread.
649
+ * Keep native and delivery admission open together while finite waves remain active, so
628
650
  * fresh replies and abort controls can progress during unrelated cleanup. Close atomically
629
651
  * at quiescence, or at the original ten-minute yield deadline, then join admitted waves.
630
652
  * 4. Native message delivery retains its driver-owned Claim deadline. Host/backfill waves are
@@ -687,6 +709,8 @@ export class ThreadMaintenance extends Context.Service<
687
709
  const alarm = yield* DurableAlarmService;
688
710
  const config = yield* CloudflareDurableRuntimeConfig;
689
711
  const { ctx } = yield* DurableObjectContext;
712
+ const storage = DurableObjectStorage.fromDurableObjectStorage(ctx.storage);
713
+ const runStorage = yield* makeStorageEffect;
690
714
  const failpoint = yield* ThreadMaintenanceFailpoint;
691
715
 
692
716
  const mutations = yield* ThreadMutationGate;
@@ -1005,12 +1029,104 @@ export class ThreadMaintenance extends Context.Service<
1005
1029
  );
1006
1030
  });
1007
1031
 
1032
+ const attemptSelected = Effect.fn("ThreadMaintenance.attemptSelected")(function* (
1033
+ selected: SubmissionWorkItem,
1034
+ previousRetries: ReadonlyArray<BindingRetry>,
1035
+ yieldAfter: DateTime.Utc,
1036
+ recovery: NativeRecovery,
1037
+ ): Effect.fn.Return<number, MaintenancePassFailure> {
1038
+ yield* recoverThread(selected.threadId, recovery);
1039
+
1040
+ let bindingFailure: DurableBindingFailure | undefined;
1041
+
1042
+ // A missing binding releases its slot after retaining this exact Submission
1043
+ // and committing its retry deadline below.
1044
+ const settlement = recovery.faults.has(selected.threadId)
1045
+ ? Option.none()
1046
+ : yield* runtime.processThreadHead(selected.threadId, { yieldAfter }).pipe(
1047
+ Effect.catchTag("BindingUnavailable", (failure) => {
1048
+ bindingFailure = failure;
1049
+
1050
+ return Effect.succeed(Option.none());
1051
+ }),
1052
+ );
1053
+
1054
+ const previous = previousRetries.find(
1055
+ (retry) => retry.submissionId === selected.submissionId,
1056
+ );
1057
+
1058
+ let retry: BindingRetry | undefined;
1059
+ let reportBindingFailure = false;
1060
+
1061
+ if (bindingFailure !== undefined) {
1062
+ const now = yield* Clock.currentTimeMillis;
1063
+ const attempts = Math.min(30, (previous?.attempts ?? 0) + 1);
1064
+
1065
+ reportBindingFailure = previous === undefined || now - previous.reportedAt >= 15 * 60_000;
1066
+ retry = BindingRetry.make({
1067
+ threadId: selected.threadId,
1068
+ submissionId: selected.submissionId,
1069
+ attempts,
1070
+ notBefore: now + Math.min(60_000, 5_000 * 2 ** (attempts - 1)),
1071
+ reportedAt: reportBindingFailure ? now : (previous?.reportedAt ?? now),
1072
+ });
1073
+ }
1074
+ if (retry !== undefined || previous !== undefined) {
1075
+ // The Attempt released its Claim. Commit its binding wait (or clear) once,
1076
+ // before joining fallible auxiliary work. This local fact neither acknowledges
1077
+ // a generation nor changes the shared alarm.
1078
+ yield* failpoint.hit("maintenance:binding-retry:before");
1079
+ yield* runStorage(
1080
+ "record submission binding retry",
1081
+ storage
1082
+ .transaction((transaction) =>
1083
+ Effect.gen(function* () {
1084
+ const encoded = yield* transaction.get(MAINTENANCE_STATE_KEY);
1085
+
1086
+ const state =
1087
+ encoded === undefined
1088
+ ? initialMaintenanceState()
1089
+ : yield* Schema.decodeUnknownEffect(ThreadMaintenanceState)(encoded);
1090
+
1091
+ const bindingRetries = [
1092
+ ...(state.bindingRetries ?? []).filter(
1093
+ (entry) => entry.submissionId !== selected.submissionId,
1094
+ ),
1095
+ ...(retry === undefined ? [] : [retry]),
1096
+ ];
1097
+
1098
+ yield* transaction.put(
1099
+ MAINTENANCE_STATE_KEY,
1100
+ yield* Schema.encodeEffect(ThreadMaintenanceState)(
1101
+ ThreadMaintenanceState.make({ ...state, bindingRetries }),
1102
+ ),
1103
+ );
1104
+ }),
1105
+ )
1106
+ .pipe(Effect.mapError(alarmFailure("record submission binding retry"))),
1107
+ );
1108
+ yield* failpoint.hit("maintenance:binding-retry:after");
1109
+ }
1110
+ if (bindingFailure !== undefined) {
1111
+ yield* reportBindingFailure
1112
+ ? Effect.logError(
1113
+ "Thread awaits a current agent binding; original work remains pending",
1114
+ Cause.fail(bindingFailure),
1115
+ )
1116
+ : Effect.logDebug("Thread binding retry remains pending", Cause.fail(bindingFailure));
1117
+ }
1118
+
1119
+ return Option.isSome(settlement) ? 1 : 0;
1120
+ });
1121
+
1008
1122
  const advance = Effect.fn("ThreadMaintenance.advance")(function* (
1009
1123
  started: Effect.Success<ReturnType<typeof beginNative>>,
1010
1124
  yieldAfter: DateTime.Utc,
1011
1125
  observed: MaintenanceObservation,
1012
1126
  recovery: NativeRecovery,
1127
+ native: NativeDispatch,
1013
1128
  dispatch = true,
1129
+ reserved: ReadonlySet<ThreadId> = new Set(native.active.keys()),
1014
1130
  ): Effect.fn.Return<NativePassResult, MaintenancePassFailure> {
1015
1131
  const deadline = yield* publication.pendingDeadline;
1016
1132
 
@@ -1046,11 +1162,26 @@ export class ThreadMaintenance extends Context.Service<
1046
1162
 
1047
1163
  return {
1048
1164
  phase: "caught-up",
1049
- settled: 0,
1050
1165
  nonterminal: started.nonterminal,
1051
1166
  nextAttemptAt,
1052
1167
  };
1053
1168
  }
1169
+ if (native.generation !== started.generation) {
1170
+ native.deferred.clear();
1171
+ native.generation = started.generation;
1172
+ }
1173
+ // A stable generation with no freed slot needs no second ledger/history scan.
1174
+ // Enrollment precedes admission: never cache a scan overlapping a producer body,
1175
+ // which may make a head ready later without another generation increment.
1176
+ if (native.active.size > 0 && native.scanGeneration === started.generation) {
1177
+ return {
1178
+ phase: "actionable",
1179
+ nonterminal: started.nonterminal,
1180
+ nextAttemptAt: undefined,
1181
+ };
1182
+ }
1183
+ native.scanGeneration = started.activeAtStart === 0 ? started.generation : undefined;
1184
+
1054
1185
  // Select from control state before reading execution history. A recovering or faulted
1055
1186
  // Thread cannot enter dispatch; old cleanup has its own scoped opportunity below.
1056
1187
  observed.nativeOnly = true;
@@ -1094,6 +1225,7 @@ export class ThreadMaintenance extends Context.Service<
1094
1225
  for (const head of heads.values()) {
1095
1226
  if (
1096
1227
  head.state !== "ready" ||
1228
+ native.active.has(head.threadId) ||
1097
1229
  recovery.pending.has(head.threadId) ||
1098
1230
  recoveryFaults.has(head.threadId)
1099
1231
  )
@@ -1151,17 +1283,30 @@ export class ThreadMaintenance extends Context.Service<
1151
1283
 
1152
1284
  const runnable = eligible.filter(
1153
1285
  (threadId) =>
1286
+ !reserved.has(threadId) &&
1287
+ !native.active.has(threadId) &&
1288
+ !native.deferred.has(threadId) &&
1154
1289
  !retries.some(
1155
1290
  (retry) => retry.threadId === threadId && retry.notBefore > selectionTime,
1156
1291
  ),
1157
1292
  );
1158
1293
 
1159
- const next = dispatch
1160
- ? (runnable.find(
1161
- (threadId) =>
1162
- state.lastServedThreadId === undefined || threadId > state.lastServedThreadId,
1163
- ) ?? runnable[0])
1164
- : undefined;
1294
+ const pivot = Math.max(
1295
+ 0,
1296
+ runnable.findIndex(
1297
+ (threadId) =>
1298
+ state.lastServedThreadId === undefined || threadId > state.lastServedThreadId,
1299
+ ),
1300
+ );
1301
+
1302
+ const selected = dispatch
1303
+ ? [...runnable.slice(pivot), ...runnable.slice(0, pivot)].slice(
1304
+ 0,
1305
+ nativeDispatchConcurrency - reserved.size,
1306
+ )
1307
+ : [];
1308
+
1309
+ const next = selected.at(-1);
1165
1310
 
1166
1311
  if (next !== undefined) {
1167
1312
  await transaction.put(
@@ -1196,7 +1341,7 @@ export class ThreadMaintenance extends Context.Service<
1196
1341
  threadId <= state.lastRecoveredThreadId,
1197
1342
  );
1198
1343
 
1199
- return { selected: next, retries, backlog: [...after, ...before] };
1344
+ return { selected, retries, backlog: [...after, ...before] };
1200
1345
  }),
1201
1346
  );
1202
1347
 
@@ -1208,81 +1353,33 @@ export class ThreadMaintenance extends Context.Service<
1208
1353
  yield* Deferred.succeed(recovery.queue, selection.backlog);
1209
1354
  }
1210
1355
 
1211
- const selected =
1212
- selection.selected === undefined ? undefined : heads.get(selection.selected);
1213
-
1214
- if (selected !== undefined) yield* recoverThread(selected.threadId, recovery);
1215
-
1216
- let retries = selection.retries;
1217
- let bindingFailure: DurableBindingFailure | undefined;
1218
-
1219
- // One runnable FIFO head per native opportunity. An absent agent waits for a deployment,
1220
- // including for children; other local lanes and host deliveries remain independently due.
1221
- const settlement =
1222
- selected === undefined || recoveryFaults.has(selected.threadId)
1223
- ? Option.none()
1224
- : yield* runtime.processThreadHead(selected.threadId, { yieldAfter }).pipe(
1225
- Effect.catchTag("BindingUnavailable", (failure) => {
1226
- bindingFailure = failure;
1227
-
1228
- return Effect.succeed(Option.none());
1229
- }),
1230
- );
1231
-
1232
- if (selected !== undefined) {
1233
- const previous = retries.find((retry) => retry.submissionId === selected.submissionId);
1234
- let retry: BindingRetry | undefined;
1235
- let reportBindingFailure = false;
1236
-
1237
- if (bindingFailure !== undefined) {
1238
- const now = yield* Clock.currentTimeMillis;
1239
- const attempts = Math.min(30, (previous?.attempts ?? 0) + 1);
1240
-
1241
- reportBindingFailure =
1242
- previous === undefined || now - previous.reportedAt >= 15 * 60_000;
1243
- retry = BindingRetry.make({
1244
- threadId: selected.threadId,
1245
- submissionId: selected.submissionId,
1246
- attempts,
1247
- notBefore: now + Math.min(60_000, 5_000 * 2 ** (attempts - 1)),
1248
- reportedAt: reportBindingFailure ? now : (previous?.reportedAt ?? now),
1249
- });
1250
- }
1251
- if (retry !== undefined || previous !== undefined) {
1252
- // The Attempt released its Claim. Commit its binding wait (or clear) once,
1253
- // before joining fallible auxiliary work. This local fact neither acknowledges
1254
- // a generation nor changes the shared alarm.
1255
- yield* failpoint.hit("maintenance:binding-retry:before");
1256
- retries = yield* runTransaction("record submission binding retry", () =>
1257
- ctx.storage.transaction(async (transaction) => {
1258
- const { state } = await readMaintenanceState(transaction);
1259
-
1260
- const bindingRetries = [
1261
- ...(state.bindingRetries ?? []).filter(
1262
- (entry) => entry.submissionId !== selected.submissionId,
1263
- ),
1264
- ...(retry === undefined ? [] : [retry]),
1265
- ];
1266
-
1267
- await transaction.put(
1268
- MAINTENANCE_STATE_KEY,
1269
- encodeMaintenanceState(ThreadMaintenanceState.make({ ...state, bindingRetries })),
1270
- );
1356
+ for (const threadId of selection.selected) {
1357
+ const selected = heads.get(threadId);
1358
+
1359
+ if (selected === undefined) continue;
1360
+ // Reserve before recovery or claim acquisition. The durable Claim remains the
1361
+ // authority; this event-local reservation only prevents duplicate dispatch.
1362
+ native.dispatched++;
1363
+ native.active.set(
1364
+ threadId,
1365
+ yield* Effect.forkIn(
1366
+ attemptSelected(selected, selection.retries, yieldAfter, recovery),
1367
+ native.scope,
1368
+ ),
1369
+ );
1370
+ }
1271
1371
 
1272
- return bindingRetries;
1273
- }),
1274
- );
1275
- yield* failpoint.hit("maintenance:binding-retry:after");
1276
- }
1277
- if (bindingFailure !== undefined) {
1278
- yield* reportBindingFailure
1279
- ? Effect.logError(
1280
- "Thread awaits a current agent binding; original work remains pending",
1281
- Cause.fail(bindingFailure),
1282
- )
1283
- : Effect.logDebug("Thread binding retry remains pending", Cause.fail(bindingFailure));
1284
- }
1372
+ if (native.active.size > 0) {
1373
+ // Active Attempts own their claims and may still append/settle. Only acknowledge
1374
+ // a generation after joining every admitted native Attempt.
1375
+ return {
1376
+ phase: "actionable",
1377
+ nonterminal: current.length,
1378
+ nextAttemptAt: undefined,
1379
+ dispatched: selection.selected.length > 0,
1380
+ };
1285
1381
  }
1382
+ const retries = selection.retries;
1286
1383
 
1287
1384
  const remaining = yield* Stream.runCollect(ledger.scanNonterminal);
1288
1385
  const waitingHeads = new Map<ThreadId, boolean>();
@@ -1304,7 +1401,7 @@ export class ThreadMaintenance extends Context.Service<
1304
1401
  return !waiting(snapshot);
1305
1402
  });
1306
1403
 
1307
- const progressed = Option.isSome(settlement) || recovery.repaired;
1404
+ const progressed = native.progressed || recovery.repaired;
1308
1405
 
1309
1406
  const now = yield* Clock.currentTimeMillis;
1310
1407
  const ordinaryDelay = autonomous ? yield* rearmDelay(progressed, started.stalls) : 0;
@@ -1379,10 +1476,12 @@ export class ThreadMaintenance extends Context.Service<
1379
1476
  );
1380
1477
 
1381
1478
  yield* failpoint.hit("maintenance:checkpoint:after");
1479
+ native.deferred.clear();
1480
+ native.progressed = false;
1481
+ native.needsCheckpoint = false;
1382
1482
 
1383
1483
  return {
1384
1484
  phase: "actionable",
1385
- settled: Option.isSome(settlement) ? 1 : 0,
1386
1485
  nonterminal: remaining.length,
1387
1486
  nextAttemptAt,
1388
1487
  };
@@ -1473,19 +1572,27 @@ export class ThreadMaintenance extends Context.Service<
1473
1572
  let backfillObserved = false;
1474
1573
  let recoveryObserved = false;
1475
1574
 
1575
+ const dispatch: NativeDispatch = {
1576
+ scope: auxiliaryScope,
1577
+ active: new Map(),
1578
+ deferred: new Set(),
1579
+ dispatched: 0,
1580
+ settled: 0,
1581
+ progressed: false,
1582
+ needsCheckpoint: false,
1583
+ };
1584
+
1476
1585
  let native: Fiber.Fiber<NativePassResult, MaintenancePassFailure> | undefined = yield* fork(
1477
- advance(started, yieldAfter, observed, recovery),
1586
+ advance(started, yieldAfter, observed, recovery, dispatch),
1478
1587
  );
1479
1588
 
1480
1589
  let result: NativePassResult = {
1481
1590
  phase: "caught-up",
1482
- settled: 0,
1483
1591
  nonterminal: started.nonterminal,
1484
1592
  nextAttemptAt: undefined,
1485
1593
  };
1486
1594
 
1487
1595
  let phase = result.phase;
1488
- let settled = 0;
1489
1596
  let nativeCheck = false;
1490
1597
  const until = DateTime.toEpochMillis(yieldAfter);
1491
1598
  const dispatchEnd = DateTime.toEpochMillis(dispatchUntil);
@@ -1499,17 +1606,35 @@ export class ThreadMaintenance extends Context.Service<
1499
1606
  result = yield* Fiber.join(native);
1500
1607
  native = undefined;
1501
1608
  if (result.phase === "actionable") phase = "actionable";
1502
- settled += result.settled;
1503
- observed.nativeOnly = false;
1609
+ observed.nativeOnly = dispatch.active.size > 0;
1610
+ if (result.dispatched === true && dispatch.active.size < nativeDispatchConcurrency) {
1611
+ dispatch.scanGeneration = undefined;
1612
+ nativeCheck = true;
1613
+ }
1504
1614
  // An empty initial scan still opens exactly one old-recovery opportunity.
1505
1615
  recovery.started = true;
1506
1616
  yield* Deferred.succeed(recovery.queue, []);
1507
1617
  checkLanes();
1508
1618
  }
1619
+ for (const [threadId, attempt] of dispatch.active) {
1620
+ const exit = attempt.pollUnsafe();
1621
+
1622
+ if (exit === undefined) continue;
1623
+ dispatch.active.delete(threadId);
1624
+ dispatch.scanGeneration = undefined;
1625
+ if (Exit.isFailure(exit)) return yield* Effect.failCause(exit.cause);
1626
+ dispatch.settled += exit.value;
1627
+ dispatch.progressed ||= exit.value > 0;
1628
+ dispatch.needsCheckpoint = true;
1629
+ if (exit.value === 0) dispatch.deferred.add(threadId);
1630
+ nativeCheck = true;
1631
+ checkLanes();
1632
+ }
1509
1633
  if (!recoveryObserved && recoveryFiber.pollUnsafe() !== undefined) {
1510
1634
  yield* Fiber.join(recoveryFiber);
1511
1635
  recoveryObserved = true;
1512
- nativeCheck = recovery.needsCheckpoint;
1636
+ dispatch.scanGeneration = undefined;
1637
+ nativeCheck ||= recovery.needsCheckpoint;
1513
1638
  }
1514
1639
  if (!backfillObserved) {
1515
1640
  const exit = backfill.pollUnsafe();
@@ -1542,7 +1667,7 @@ export class ThreadMaintenance extends Context.Service<
1542
1667
  }
1543
1668
 
1544
1669
  // Preserve the initial native opportunity even when auxiliary setup fails.
1545
- if (failure !== undefined && native === undefined)
1670
+ if (failure !== undefined && native === undefined && dispatch.active.size === 0)
1546
1671
  return yield* Effect.failCause(failure);
1547
1672
  const now = yield* Clock.currentTimeMillis;
1548
1673
 
@@ -1642,6 +1767,7 @@ export class ThreadMaintenance extends Context.Service<
1642
1767
  // Recovery still gets its checkpoint and, if needed, initial dispatch opportunity.
1643
1768
  if (
1644
1769
  native === undefined &&
1770
+ dispatch.active.size === 0 &&
1645
1771
  recoveryObserved &&
1646
1772
  !recovery.needsCheckpoint &&
1647
1773
  backfillObserved &&
@@ -1650,11 +1776,20 @@ export class ThreadMaintenance extends Context.Service<
1650
1776
  )
1651
1777
  break;
1652
1778
 
1653
- if (native === undefined && nativeCheck) {
1779
+ if (
1780
+ native === undefined &&
1781
+ nativeCheck &&
1782
+ dispatch.active.size < nativeDispatchConcurrency
1783
+ ) {
1654
1784
  nativeCheck = false;
1655
- const checkpoint = recoveryObserved && recovery.needsCheckpoint;
1785
+ const nativeCheckpoint = dispatch.needsCheckpoint && dispatch.active.size === 0;
1786
+ const checkpoint = (recoveryObserved && recovery.needsCheckpoint) || nativeCheckpoint;
1656
1787
 
1657
1788
  if (checkpoint) recovery.needsCheckpoint = false;
1789
+ // Keep this opportunity's reservations across beginNative's storage awaits.
1790
+ // A finishing Attempt cannot turn a refill into another same-Thread Attempt.
1791
+ const reserved = new Set(dispatch.active.keys());
1792
+
1658
1793
  native = yield* fork(
1659
1794
  Effect.gen(function* () {
1660
1795
  const awakened = checkpoint ? started : yield* beginNative(observed);
@@ -1664,7 +1799,11 @@ export class ThreadMaintenance extends Context.Service<
1664
1799
  yieldAfter,
1665
1800
  observed,
1666
1801
  recovery,
1667
- !checkpoint || settled === 0,
1802
+ dispatch,
1803
+ failure === undefined &&
1804
+ !nativeCheckpoint &&
1805
+ (!checkpoint || dispatch.settled === 0),
1806
+ reserved,
1668
1807
  );
1669
1808
  }),
1670
1809
  );
@@ -1678,6 +1817,7 @@ export class ThreadMaintenance extends Context.Service<
1678
1817
 
1679
1818
  const completing = [
1680
1819
  ...(native === undefined ? [] : [native]),
1820
+ ...dispatch.active.values(),
1681
1821
  ...(recoveryObserved ? [] : [recoveryFiber]),
1682
1822
  ...(backfillObserved ? [] : [backfill]),
1683
1823
  ...(delivery === undefined ? [] : [delivery]),
@@ -1705,11 +1845,18 @@ export class ThreadMaintenance extends Context.Service<
1705
1845
  if (native !== undefined) {
1706
1846
  result = yield* Fiber.join(native);
1707
1847
  if (result.phase === "actionable") phase = "actionable";
1708
- settled += result.settled;
1709
- observed.nativeOnly = false;
1848
+ observed.nativeOnly = dispatch.active.size > 0;
1710
1849
  recovery.started = true;
1711
1850
  yield* Deferred.succeed(recovery.queue, []);
1712
1851
  }
1852
+ for (const attempt of dispatch.active.values()) {
1853
+ const settled = yield* Fiber.join(attempt);
1854
+
1855
+ dispatch.settled += settled;
1856
+ dispatch.progressed ||= settled > 0;
1857
+ }
1858
+ dispatch.active.clear();
1859
+ observed.nativeOnly = false;
1713
1860
  yield* Fiber.joinAll([
1714
1861
  recoveryFiber,
1715
1862
  backfill,
@@ -1717,10 +1864,9 @@ export class ThreadMaintenance extends Context.Service<
1717
1864
  ...lanes.flatMap((lane) => (lane.fiber === undefined ? [] : [lane.fiber])),
1718
1865
  ]);
1719
1866
  if (failure !== undefined) return yield* Effect.failCause(failure);
1720
- if (recovery.needsCheckpoint) {
1721
- result = yield* advance(started, yieldAfter, observed, recovery, false);
1867
+ if (recovery.needsCheckpoint || dispatch.dispatched > 0) {
1868
+ result = yield* advance(started, yieldAfter, observed, recovery, dispatch, false);
1722
1869
  if (result.phase === "actionable") phase = "actionable";
1723
- settled += result.settled;
1724
1870
  }
1725
1871
  yield* Scope.close(auxiliaryScope, Exit.void);
1726
1872
  yield* failpoint.hit("maintenance:finish:before");
@@ -1763,7 +1909,7 @@ export class ThreadMaintenance extends Context.Service<
1763
1909
  const report = MaintenancePassReport.make({
1764
1910
  phase,
1765
1911
  recovered: recovery.recovered,
1766
- settled,
1912
+ settled: dispatch.settled,
1767
1913
  nonterminal: result.nonterminal,
1768
1914
  alarm: disposition,
1769
1915
  });
@@ -242,8 +242,10 @@ export class BrowserSessions extends Context.Service<
242
242
  attachment === undefined
243
243
  ? Effect.void
244
244
  : attachment.retire.pipe(
245
+ // Dispatch is already fenced; give the socket close handshake the same
246
+ // bounded window as keepalive instead of failing a settled command after 1s.
245
247
  Effect.timeoutOrElse({
246
- duration: "1 second",
248
+ duration: "10 seconds",
247
249
  orElse: () => Effect.fail(failure("cleanup")),
248
250
  }),
249
251
  Effect.catchCause((cause) =>
@@ -148,7 +148,8 @@ export class BrowserRunBinding extends Context.Service<
148
148
  retirementFailure ??= browserFailure(disconnectOperation, cause);
149
149
  }
150
150
  try {
151
- if (socket !== undefined && socket.readyState < WebSocket.CLOSING) socket.close();
151
+ // Manual-close runtimes also require a reply when the peer leaves us CLOSING.
152
+ if (socket !== undefined && socket.readyState < WebSocket.CLOSED) socket.close();
152
153
  } catch (cause) {
153
154
  retirementFailure ??= browserFailure(disconnectOperation, cause);
154
155
  }
@@ -183,7 +184,7 @@ export class BrowserRunBinding extends Context.Service<
183
184
  "close",
184
185
  () => {
185
186
  try {
186
- fence();
187
+ retireNow();
187
188
  } catch (cause) {
188
189
  retirementFailure ??= browserFailure(disconnectOperation, cause);
189
190
  }
@@ -191,7 +192,8 @@ export class BrowserRunBinding extends Context.Service<
191
192
  { once: true },
192
193
  );
193
194
 
194
- return await puppeteer.connect({ transport });
195
+ // Attaching must not replace the viewport owned by the host or another viewer.
196
+ return await puppeteer.connect({ transport, defaultViewport: null });
195
197
  } catch (cause) {
196
198
  try {
197
199
  retireNow();