@loucompanion/forge-bridge 0.1.3-dev.8687c5099dce → 0.1.3-dev.88e93f53db5b

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 (75) hide show
  1. package/README.md +18 -1
  2. package/dist/bridge/bridge.base.d.ts +2 -0
  3. package/dist/bridge/bridge.service.d.ts +25 -1
  4. package/dist/bridge/bridge.service.js +341 -74
  5. package/dist/bridge/bridge.types.d.ts +4 -0
  6. package/dist/cleanup/cleanup.service.d.ts +1 -0
  7. package/dist/cleanup/cleanup.service.js +70 -9
  8. package/dist/cli/cli.service.js +9 -4
  9. package/dist/runtime/aspnet-runtime-prebuild.d.ts +36 -0
  10. package/dist/runtime/aspnet-runtime-prebuild.js +340 -0
  11. package/dist/runtime/attempt-node-runtime-launcher.js +5 -1
  12. package/dist/runtime/attempt-process-supervisor.d.ts +11 -0
  13. package/dist/runtime/attempt-process-supervisor.js +36 -2
  14. package/dist/runtime/host-listener-inspector.d.ts +2 -0
  15. package/dist/runtime/host-listener-inspector.js +25 -0
  16. package/dist/runtime/host-runtime-lease.d.ts +66 -5
  17. package/dist/runtime/host-runtime-lease.js +714 -38
  18. package/dist/runtime/memory-capacity.d.ts +68 -0
  19. package/dist/runtime/memory-capacity.js +121 -0
  20. package/dist/runtime/node-host-runtime-adapter.d.ts +3 -0
  21. package/dist/runtime/node-host-runtime-adapter.js +24 -8
  22. package/dist/runtime/playwright-host-runtime-adapter.d.ts +1 -0
  23. package/dist/runtime/playwright-host-runtime-adapter.js +5 -0
  24. package/dist/runtime/workspace-resource-estimator.d.ts +17 -0
  25. package/dist/runtime/workspace-resource-estimator.js +174 -0
  26. package/dist/sandbox/preflight.d.ts +31 -1
  27. package/dist/sandbox/preflight.js +237 -51
  28. package/dist/sandbox/repository-lock.js +4 -2
  29. package/dist/sandbox/sandbox.types.d.ts +7 -0
  30. package/dist/session/managed-attempt-tools.d.ts +7 -1
  31. package/dist/session/managed-attempt-tools.js +257 -13
  32. package/dist/session/managed-build-coordinator.d.ts +5 -0
  33. package/dist/session/managed-build-coordinator.js +105 -0
  34. package/dist/session/managed-build-receipt.d.ts +21 -0
  35. package/dist/session/managed-build-receipt.js +335 -0
  36. package/dist/session/phase-completion.artifacts.d.ts +17 -0
  37. package/dist/session/phase-completion.artifacts.js +224 -22
  38. package/dist/session/phase-completion.prompts.js +2 -2
  39. package/dist/session/phase-completion.runtime.js +10 -3
  40. package/dist/session/phase-completion.schemas.js +1 -1
  41. package/dist/session/phase-completion.validation.js +7 -3
  42. package/dist/session/provider-cli/ci-repair-readiness.d.ts +6 -0
  43. package/dist/session/provider-cli/ci-repair-readiness.js +12 -2
  44. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +2 -1
  45. package/dist/session/provider-cli/provider-cli.artifacts.js +37 -3
  46. package/dist/session/provider-cli/provider-cli.process.d.ts +7 -0
  47. package/dist/session/provider-cli/provider-cli.process.js +218 -17
  48. package/dist/session/provider-cli/provider-cli.prompts.js +17 -4
  49. package/dist/session/provider-cli/provider-cli.runtime.js +3 -1
  50. package/dist/session/provider-cli/provider-cli.service.d.ts +5 -2
  51. package/dist/session/provider-cli/provider-cli.service.js +83 -34
  52. package/dist/session/provider-cli/provider-output-state.js +61 -14
  53. package/dist/session/provider-cli/provider-usage.d.ts +7 -0
  54. package/dist/session/provider-cli/provider-usage.js +28 -5
  55. package/dist/session/session-redaction.js +16 -1
  56. package/dist/session/session.service.js +81 -9
  57. package/dist/session/session.types.d.ts +15 -0
  58. package/dist/session/session.types.js +3 -1
  59. package/dist/shared/format/format-bytes.d.ts +1 -0
  60. package/dist/shared/format/format-bytes.js +9 -0
  61. package/dist/shared/path/path.d.ts +1 -0
  62. package/dist/shared/path/path.js +18 -0
  63. package/dist/shared/process/host-process-supervisor.d.ts +2 -0
  64. package/dist/shared/process/host-process-supervisor.js +57 -9
  65. package/dist/shared/redaction/redaction.js +6 -1
  66. package/dist/version.d.ts +1 -1
  67. package/dist/version.js +1 -1
  68. package/dist/worktree/dependency-warmer.d.ts +18 -0
  69. package/dist/worktree/dependency-warmer.js +218 -8
  70. package/dist/worktree/index.d.ts +1 -1
  71. package/dist/worktree/worktree.service.d.ts +2 -0
  72. package/dist/worktree/worktree.service.js +158 -17
  73. package/dist/worktree/worktree.types.d.ts +2 -0
  74. package/package.json +3 -2
  75. package/runtime/windows-job-supervisor.ps1 +89 -1
@@ -10,6 +10,8 @@ import { BridgeSessionService } from "../session/session.service.js";
10
10
  import { ProcessBackedProviderCliService, RoutingProviderCliService, StreamingProviderProcessRunner, } from "../session/provider-cli/provider-cli.service.js";
11
11
  import { redactRunnerDispatchEvent, redactRunOnceResultPacket } from "../session/session-redaction.js";
12
12
  import { HostRuntimeLeaseManager } from "../runtime/host-runtime-lease.js";
13
+ import { buildMemoryAdmissionDiagnostics } from "../runtime/memory-capacity.js";
14
+ import { estimateWorkspaceBytesFromRepo } from "../runtime/workspace-resource-estimator.js";
13
15
  import { GitWorktreeService } from "../worktree/worktree.service.js";
14
16
  import { RepositoryCoordinatorLock } from "../sandbox/repository-lock.js";
15
17
  import { AttemptCoordinatedProviderProcessRunner, AttemptProcessSupervisorRegistry, } from "../runtime/attempt-process-supervisor-registry.js";
@@ -20,6 +22,20 @@ import { validateSandboxContract } from "../sandbox/contract.js";
20
22
  import { runSandboxProcess } from "../sandbox/process.js";
21
23
  import { DockerSandboxRuntime, selectSandboxConfigRefs } from "../sandbox/runtime.js";
22
24
  import { codexSandboxProviderAuthAdapter, sandboxProviderAuthAdapterFor, sandboxProviderAuthUnavailableMessage } from "../sandbox/provider-auth/codex.js";
25
+ import { formatBytes } from "../shared/format/format-bytes.js";
26
+ /**
27
+ * Protocol-v2 events are acknowledged one at a time before runOnce returns.
28
+ * Sending the complete transcript again in DispatchResult made long QA and CI
29
+ * sessions exceed SignalR's receive limit, disconnecting the bridge after the
30
+ * provider had already completed. The API combines its acknowledged streamed
31
+ * events with the terminal result, so the callback only needs the outcome and
32
+ * artifacts. Protocol v1 retains the transcript for legacy servers.
33
+ */
34
+ export function compactTerminalResult(dispatch, result) {
35
+ return dispatch.schemaVersion >= 2 && result.events.length > 0
36
+ ? { ...result, events: [] }
37
+ : result;
38
+ }
23
39
  export class ForgeBridgeService {
24
40
  config;
25
41
  connections;
@@ -32,6 +48,8 @@ export class ForgeBridgeService {
32
48
  sandboxMaintenanceTimer = null;
33
49
  sandboxMaintenanceRunning = false;
34
50
  closed = null;
51
+ transportRecovery = null;
52
+ transportRecoveryGeneration = 0;
35
53
  stopped = false;
36
54
  activeSessions = new Map();
37
55
  reservations = new Map();
@@ -40,6 +58,7 @@ export class ForgeBridgeService {
40
58
  sandboxRuntime;
41
59
  sandboxResourceResolver;
42
60
  sessions;
61
+ sandboxAmbientMemoryCommitmentBytes = null;
43
62
  constructor(config, sessions = undefined, connections = new SignalRBridgeConnectionFactory(), preflight, sandboxRuntime, sandboxResourceResolver) {
44
63
  this.config = config;
45
64
  this.connections = connections;
@@ -82,14 +101,39 @@ export class ForgeBridgeService {
82
101
  }
83
102
  const connection = this.connections.create(this.config);
84
103
  this.connection = connection;
104
+ this.transportRecovery = null;
85
105
  this.closed = new Promise((resolve) => {
86
- connection.onClose(() => {
106
+ connection.onClose((error) => {
87
107
  this.stopHeartbeat();
108
+ this.completeTransportRecovery(error ?? new Error("bridge connection closed"));
88
109
  this.abortActiveSessions("bridge connection closed");
89
110
  this.abortActiveReadinessProbes("bridge connection closed");
90
111
  resolve();
91
112
  });
92
113
  });
114
+ connection.onReconnecting?.((error) => {
115
+ if (connection !== this.connection || this.stopped)
116
+ return;
117
+ this.stopHeartbeat();
118
+ this.beginTransportRecovery();
119
+ logBridgeStatus(`Forge connection interrupted${error ? `: ${errorMessage(error)}` : ""}. Reconnecting in place; active dispatches remain supervised...`);
120
+ });
121
+ connection.onReconnected?.(async () => {
122
+ if (connection !== this.connection || this.stopped)
123
+ return;
124
+ try {
125
+ await this.registerConnection(connection);
126
+ this.completeTransportRecovery();
127
+ this.startHeartbeat();
128
+ await this.flushCleanupReports().catch(() => undefined);
129
+ logBridgeStatus("Forge connection restored; active dispatches remain attached to this runner process.");
130
+ }
131
+ catch (error) {
132
+ this.completeTransportRecovery(error);
133
+ logBridgeStatus(`Forge reconnect registration failed: ${errorMessage(error)}. Closing the connection for a clean retry...`);
134
+ await connection.stop().catch(() => undefined);
135
+ }
136
+ });
93
137
  connection.onDispatch((dispatch) => this.handleDispatch(dispatch));
94
138
  connection.onOffer?.((dispatch) => this.handleOffer(dispatch));
95
139
  connection.onStart?.((dispatch) => this.handleStart(dispatch));
@@ -98,18 +142,39 @@ export class ForgeBridgeService {
98
142
  connection.onReap?.((request) => this.handleReap(request));
99
143
  connection.onReadinessProbe?.((request) => this.handleReadinessProbe(request));
100
144
  await connection.start();
101
- await this.invokeAck("Hello", {
102
- version: this.config.version,
103
- protocolVersion: this.config.protocolVersion,
104
- minServerVersion: this.config.minServerVersion ?? null,
105
- runnerInstanceId: this.config.runnerInstanceId,
106
- capabilities: this.capabilities()
107
- });
145
+ await this.registerConnection(connection);
108
146
  await this.flushCleanupReports();
109
147
  this.startHeartbeat();
110
148
  this.startLeaseWatchdog();
111
149
  this.startSandboxMaintenance();
112
150
  }
151
+ async registerConnection(connection) {
152
+ let storageRecoveryAttempt = 0;
153
+ while (true) {
154
+ try {
155
+ const ack = await connection.invoke("Hello", {
156
+ version: this.config.version,
157
+ protocolVersion: this.config.protocolVersion,
158
+ minServerVersion: this.config.minServerVersion ?? null,
159
+ runnerInstanceId: this.config.runnerInstanceId,
160
+ capabilities: this.capabilities()
161
+ });
162
+ if (!ack.accepted) {
163
+ throw new BridgeCallbackRejectedError(ack.code ?? null, ack.message ?? ack.code ?? "Hello callback was rejected.");
164
+ }
165
+ return;
166
+ }
167
+ catch (error) {
168
+ if (!isProviderStorageUnavailable(error) || storageRecoveryAttempt >= this.config.reconnectBackoffMs.length) {
169
+ throw error;
170
+ }
171
+ const backoff = this.config.reconnectBackoffMs[storageRecoveryAttempt] ?? 5000;
172
+ storageRecoveryAttempt += 1;
173
+ logBridgeStatus(`Hello reached Forge while storage was recovering. Retrying registration ${storageRecoveryAttempt} in ${formatDelay(backoff)}...`);
174
+ await delay(backoff);
175
+ }
176
+ }
177
+ }
113
178
  async run(signal) {
114
179
  let attempt = 0;
115
180
  try {
@@ -223,7 +288,8 @@ export class ForgeBridgeService {
223
288
  if (normalized.sandbox?.executionMode === "docker") {
224
289
  await this.flushCleanupReports().catch(() => undefined);
225
290
  }
226
- await this.sendResult(dispatch, redactRunOnceResultPacket(result, redactionSecrets));
291
+ const redactedResult = redactRunOnceResultPacket(result, redactionSecrets);
292
+ await this.sendResult(dispatch, compactTerminalResult(dispatch, redactedResult));
227
293
  }
228
294
  catch (error) {
229
295
  const summary = controller.signal.aborted
@@ -254,13 +320,12 @@ export class ForgeBridgeService {
254
320
  || dispatch.lease.fencingToken !== envelope.fencingToken)
255
321
  return;
256
322
  const sandbox = dispatch.sandbox;
257
- let existing = this.reservations.get(sandbox.dispatchAttemptId);
258
- if (existing && Date.parse(existing.reservedUntil) <= Date.now()) {
259
- this.reservations.delete(sandbox.dispatchAttemptId);
260
- existing = undefined;
261
- }
323
+ this.expireReservations(Date.now());
324
+ const existing = this.reservations.get(sandbox.dispatchAttemptId);
262
325
  if (existing) {
263
- const sameFence = existing.fencingToken === dispatch.lease.fencingToken;
326
+ const sameFence = existing.leaseId === dispatch.lease.id
327
+ && existing.fencingToken === dispatch.lease.fencingToken
328
+ && existing.runnerInstanceId === sandbox.runnerInstanceId;
264
329
  await this.sendOfferDecision(dispatch, sameFence, sameFence ? null : "stale_fence", sameFence ? null : "Offer fence conflicts with the existing reservation.", sameFence ? existing.reservedUntil : null, sameFence ? existing.resources : null);
265
330
  return;
266
331
  }
@@ -287,7 +352,7 @@ export class ForgeBridgeService {
287
352
  }
288
353
  const providerNetworkFailure = sandboxProviderNetworkFailure(dispatch, this.config);
289
354
  if (providerNetworkFailure) {
290
- await this.sendOfferDecision(dispatch, false, "provider_network_unavailable", providerNetworkFailure);
355
+ await this.sendOfferDecision(dispatch, false, "provider_network_policy_denied", providerNetworkFailure);
291
356
  return;
292
357
  }
293
358
  const authFailure = sandboxAuthFailure(dispatch, this.config);
@@ -296,15 +361,12 @@ export class ForgeBridgeService {
296
361
  return;
297
362
  }
298
363
  }
299
- const runtimeState = this.sandboxRuntime.snapshot();
300
- const usedResources = this.usedSandboxResources(runtimeState.degraded);
301
- const activeResources = this.activeSandboxResources(runtimeState.degraded);
302
- const capacity = this.sandboxResourceCapacity(preflight, activeResources);
303
364
  let requestedResources = sandbox.requestedResources;
304
365
  let sandboxConfigCommitSha = sandbox.sandboxConfigCommitSha ?? null;
366
+ const normalizedDispatch = this.normalizeDispatch(dispatch);
305
367
  if (sandbox.executionMode === "docker") {
306
368
  try {
307
- const profile = await this.sandboxResourceResolver(this.normalizeDispatch(dispatch));
369
+ const profile = await this.sandboxResourceResolver(normalizedDispatch);
308
370
  requestedResources = profile.resources;
309
371
  sandboxConfigCommitSha = profile.sandboxConfigCommitSha;
310
372
  }
@@ -313,22 +375,85 @@ export class ForgeBridgeService {
313
375
  return;
314
376
  }
315
377
  }
316
- if (sandbox.executionMode === "docker"
317
- && !canReserveSandboxResources(usedResources, requestedResources, capacity)) {
318
- await this.sendOfferDecision(dispatch, false, "capacity_unavailable", sandboxCapacityFailureMessage(usedResources, requestedResources, capacity, preflight, this.config));
319
- return;
378
+ else if (normalizedDispatch.repo.serverLocalPath) {
379
+ // Host execution uses the same project-derived disk reservation as Docker.
380
+ // The API supplies a placement-time estimate; the runner can safely raise it
381
+ // from its authoritative checkout before admitting the attempt.
382
+ const measuredWorkspaceBytes = await estimateWorkspaceBytesFromRepo(normalizedDispatch.repo.serverLocalPath, { role: normalizedDispatch.execution.role }).catch(() => null);
383
+ if (measuredWorkspaceBytes !== null) {
384
+ requestedResources = {
385
+ ...requestedResources,
386
+ workspaceBytes: Math.max(requestedResources.workspaceBytes, measuredWorkspaceBytes),
387
+ };
388
+ }
389
+ }
390
+ // Resource profiling can await filesystem and Docker work. Admission needs
391
+ // a dynamic host/daemon sample that began after profiling completed, so an
392
+ // older in-flight heartbeat inspection cannot supply the commit decision.
393
+ const admissionPreflight = sandbox.executionMode === "docker"
394
+ ? await this.preflight.refreshCapacityForAdmission()
395
+ : preflight;
396
+ // Resource profiling and preflight may await slow filesystem or Docker I/O.
397
+ // Re-read every mutable capacity input only after that work, then decide and
398
+ // commit synchronously. JavaScript cannot interleave another Offer handler
399
+ // inside this non-async method, so the ledger check and insertion are one
400
+ // atomic event-loop turn without holding a lock across external I/O.
401
+ const decision = this.commitOfferReservation(dispatch, requestedResources, admissionPreflight);
402
+ await this.sendOfferDecision(dispatch, decision.accepted, decision.code, decision.message, decision.reservedUntil, decision.resources, sandboxConfigCommitSha);
403
+ }
404
+ commitOfferReservation(dispatch, requestedResources, preparedPreflight) {
405
+ const sandbox = dispatch.sandbox;
406
+ const now = Date.now();
407
+ this.expireReservations(now);
408
+ const existing = this.reservations.get(sandbox.dispatchAttemptId);
409
+ if (existing) {
410
+ const sameFence = existing.leaseId === dispatch.lease.id
411
+ && existing.fencingToken === dispatch.lease.fencingToken
412
+ && existing.runnerInstanceId === sandbox.runnerInstanceId;
413
+ return {
414
+ accepted: sameFence,
415
+ code: sameFence ? null : "stale_fence",
416
+ message: sameFence ? null : "Offer fence conflicts with the existing reservation.",
417
+ reservedUntil: sameFence ? existing.reservedUntil : null,
418
+ resources: sameFence ? existing.resources : null,
419
+ };
320
420
  }
321
- const reservedUntil = new Date(Math.min(Date.parse(dispatch.lease.expiresAt), Date.now() + 30_000)).toISOString();
322
- this.reservations.set(sandbox.dispatchAttemptId, {
421
+ if (Date.parse(dispatch.lease.expiresAt) <= now) {
422
+ return rejectedOfferReservation("lease_expired", "Offer lease is already expired.");
423
+ }
424
+ if (sandbox.executionMode === "docker") {
425
+ // A concurrent heartbeat/preflight refresh may have produced a newer
426
+ // conservative dual-domain sample while this offer was being profiled.
427
+ const admissionPreflight = this.preflight.current() ?? preparedPreflight;
428
+ if (!this.config.dockerEnabled || !admissionPreflight?.healthy) {
429
+ return rejectedOfferReservation(admissionPreflight?.unavailableCode ?? "docker_unavailable", admissionPreflight?.unavailableReason ?? "Docker sandbox preflight is unavailable.");
430
+ }
431
+ const runtimeState = this.sandboxRuntime.snapshot();
432
+ const usedResources = this.usedSandboxResources(runtimeState.degraded);
433
+ const activeResources = this.activeSandboxResources(runtimeState.degraded);
434
+ const capacity = this.sandboxResourceCapacity(admissionPreflight, activeResources, usedResources);
435
+ if (!canReserveSandboxResources(usedResources, requestedResources, capacity)) {
436
+ return rejectedOfferReservation("capacity_unavailable", sandboxCapacityFailureMessage(usedResources, requestedResources, capacity, admissionPreflight, this.config));
437
+ }
438
+ }
439
+ const reservedUntil = new Date(Math.min(Date.parse(dispatch.lease.expiresAt), now + 30_000)).toISOString();
440
+ const reservation = {
323
441
  leaseId: dispatch.lease.id,
324
442
  fencingToken: dispatch.lease.fencingToken,
325
443
  runnerInstanceId: sandbox.runnerInstanceId,
326
444
  expiresAt: dispatch.lease.expiresAt,
327
445
  reservedUntil,
328
446
  sandboxed: sandbox.executionMode === "docker",
329
- resources: requestedResources
330
- });
331
- await this.sendOfferDecision(dispatch, true, null, null, reservedUntil, requestedResources, sandboxConfigCommitSha);
447
+ resources: requestedResources,
448
+ };
449
+ this.reservations.set(sandbox.dispatchAttemptId, reservation);
450
+ return {
451
+ accepted: true,
452
+ code: null,
453
+ message: null,
454
+ reservedUntil,
455
+ resources: requestedResources,
456
+ };
332
457
  }
333
458
  async handleStart(rawDispatch) {
334
459
  const envelope = parseStartEnvelope(rawDispatch);
@@ -637,17 +762,20 @@ export class ForgeBridgeService {
637
762
  }
638
763
  expireLeases() {
639
764
  const now = Date.now();
640
- for (const [attemptId, reservation] of this.reservations) {
641
- if (Date.parse(reservation.expiresAt) <= now || Date.parse(reservation.reservedUntil) <= now) {
642
- this.reservations.delete(attemptId);
643
- }
644
- }
765
+ this.expireReservations(now);
645
766
  for (const active of this.activeSessions.values()) {
646
767
  if (Date.parse(active.leaseExpiresAt) <= now && !active.controller.signal.aborted) {
647
768
  active.controller.abort("dispatch lease expired");
648
769
  }
649
770
  }
650
771
  }
772
+ expireReservations(now) {
773
+ for (const [attemptId, reservation] of this.reservations) {
774
+ if (Date.parse(reservation.expiresAt) <= now || Date.parse(reservation.reservedUntil) <= now) {
775
+ this.reservations.delete(attemptId);
776
+ }
777
+ }
778
+ }
651
779
  stopHeartbeat() {
652
780
  if (this.heartbeatTimer) {
653
781
  clearInterval(this.heartbeatTimer);
@@ -716,6 +844,11 @@ export class ForgeBridgeService {
716
844
  }
717
845
  }
718
846
  async sendHeartbeat() {
847
+ // Heartbeats are the recovery path for capacity-blocked work. Refresh the
848
+ // dynamic sample before advertising it, while reusing the static Docker
849
+ // capability snapshot established at startup or by an offer.
850
+ if (this.config.dockerEnabled)
851
+ await this.preflight.refreshCapacity();
719
852
  await this.invokeAck("Heartbeat", {
720
853
  status: "online",
721
854
  capabilities: this.capabilities(),
@@ -729,7 +862,18 @@ export class ForgeBridgeService {
729
862
  const reserved = [...this.reservations.values()].filter(reservation => reservation.sandboxed).length;
730
863
  const used = this.usedSandboxResources(runtimeState.degraded);
731
864
  const activeResources = this.activeSandboxResources(runtimeState.degraded);
732
- const capacity = this.sandboxResourceCapacity(preflight, activeResources);
865
+ const capacity = this.sandboxResourceCapacity(preflight, activeResources, used);
866
+ const ambientMemoryCommitmentBytes = this.sandboxAmbientMemoryCommitmentBytes ?? 0;
867
+ const memoryAdmission = preflight ? buildMemoryAdmissionDiagnostics({
868
+ sampledAt: preflight.sampledAt,
869
+ totalMemoryBytes: preflight.totalMemoryBytes,
870
+ rawAvailableMemoryBytes: preflight.rawAvailableMemoryBytes,
871
+ stabilizedAvailableMemoryBytes: preflight.availableMemoryBytes,
872
+ controlPlaneMemoryBytes: process.memoryUsage().rss,
873
+ reserveMemoryBytes: preflight.hostReserveBytes,
874
+ committedMemoryBytes: ambientMemoryCommitmentBytes + used.memoryBytes,
875
+ pendingCommitmentMemoryBytes: Math.max(0, used.memoryBytes - activeResources.memoryBytes),
876
+ }) : null;
733
877
  return {
734
878
  schemaVersion: 3,
735
879
  activeSessionCount: this.activeSessions.size,
@@ -752,7 +896,7 @@ export class ForgeBridgeService {
752
896
  allowsOpenPublicSandbox: this.config.allowOpenPublicSandbox,
753
897
  sandboxPublicationModes: [this.config.sandboxPublicationMode ?? "shared_filesystem"],
754
898
  sandboxAuthModes: sandboxAuthModesFor(this.config),
755
- availableMemoryBytes: preflight ? Math.max(0, capacity.memoryBytes - used.memoryBytes) : null,
899
+ availableMemoryBytes: memoryAdmission?.availableForAdmissionBytes ?? null,
756
900
  availableCpuMillis: preflight ? Math.max(0, capacity.cpuMillis - used.cpuMillis) : null,
757
901
  availableWorkspaceBytes: preflight ? Math.max(0, capacity.workspaceBytes - used.workspaceBytes) : null,
758
902
  diskLow: preflight?.diskLow ?? null,
@@ -766,8 +910,11 @@ export class ForgeBridgeService {
766
910
  hostReserveBytes: preflight.hostReserveBytes,
767
911
  activeReservationBytes: used.memoryBytes,
768
912
  daemonTotalBytes: preflight.totalMemoryBytes,
769
- daemonAvailableBytes: preflight.availableMemoryBytes,
770
- availableForAdmissionBytes: Math.max(0, capacity.memoryBytes - used.memoryBytes),
913
+ daemonAvailableBytes: preflight.rawDaemonAvailableMemoryBytes ?? 0,
914
+ hostAvailableBytes: preflight.rawHostAvailableMemoryBytes,
915
+ conservativeAvailableBytes: preflight.rawAvailableMemoryBytes,
916
+ availableForAdmissionBytes: memoryAdmission.availableForAdmissionBytes,
917
+ memoryAdmission: memoryAdmission,
771
918
  } : null,
772
919
  hostRuntime: this.sessions.runtimeCapacity?.() ?? null,
773
920
  supportsApiSecretGrant: this.config.supportsApiSecretGrant,
@@ -799,9 +946,15 @@ export class ForgeBridgeService {
799
946
  ...Array.from({ length: degradedReservations }, () => defaultSandboxResources())
800
947
  ]);
801
948
  }
802
- sandboxResourceCapacity(preflight, activeResources) {
949
+ sandboxResourceCapacity(preflight, activeResources, usedResources) {
803
950
  if (!preflight)
804
951
  return zeroSandboxResources();
952
+ if (usedResources.memoryBytes === 0 || this.sandboxAmbientMemoryCommitmentBytes === null) {
953
+ // Freeze non-Forge daemon use while reservations exist. If the bridge
954
+ // reconnects with managed containers already present, the initial value
955
+ // is deliberately conservative until their ledger drains.
956
+ this.sandboxAmbientMemoryCommitmentBytes = Math.max(0, preflight.totalMemoryBytes - preflight.rawAvailableMemoryBytes);
957
+ }
805
958
  return {
806
959
  // Host free memory and disk already reflect active containers. Add their
807
960
  // commitments back to form a stable total capacity, then subtract them
@@ -811,9 +964,7 @@ export class ForgeBridgeService {
811
964
  workerMemoryLimitBytes: 0,
812
965
  serviceMemoryLimitBytes: 0,
813
966
  runtimeOverheadBytes: 0,
814
- memoryBytes: Math.max(0, preflight.availableMemoryBytes
815
- - (this.config.sandboxHostMemoryReserveBytes ?? 2 * 1024 ** 3)
816
- + activeResources.memoryBytes),
967
+ memoryBytes: sandboxMemoryCommitmentCapacity(preflight, activeResources.memoryBytes, this.sandboxAmbientMemoryCommitmentBytes),
817
968
  workspaceBytes: Math.max(0, preflight.availableWorkspaceBytes
818
969
  - this.config.minimumWorkspaceBytes
819
970
  + activeResources.workspaceBytes),
@@ -822,21 +973,81 @@ export class ForgeBridgeService {
822
973
  };
823
974
  }
824
975
  async invokeAck(methodName, payload) {
825
- const connection = this.connection;
826
- if (!connection) {
827
- throw new Error("Bridge is not connected.");
976
+ let storageRecoveryAttempt = 0;
977
+ while (true) {
978
+ const connection = this.connection;
979
+ if (!connection) {
980
+ throw new Error("Bridge is not connected.");
981
+ }
982
+ await this.waitForTransportRecovery(connection, this.transportRecovery);
983
+ const recoveryGeneration = this.transportRecoveryGeneration;
984
+ try {
985
+ const ack = await connection.invoke(methodName, payload);
986
+ if (!ack.accepted) {
987
+ throw new BridgeCallbackRejectedError(ack.code ?? null, ack.message ?? ack.code ?? `${methodName} callback was rejected.`);
988
+ }
989
+ return ack;
990
+ }
991
+ catch (error) {
992
+ if (connection !== this.connection || this.stopped)
993
+ throw error;
994
+ const recovery = this.transportRecovery;
995
+ if (recovery && recovery.generation > recoveryGeneration) {
996
+ await this.waitForTransportRecovery(connection, recovery);
997
+ continue;
998
+ }
999
+ if (isProviderStorageUnavailable(error) && storageRecoveryAttempt < this.config.reconnectBackoffMs.length) {
1000
+ const backoff = this.config.reconnectBackoffMs[storageRecoveryAttempt] ?? 5000;
1001
+ storageRecoveryAttempt += 1;
1002
+ logBridgeStatus(`${methodName} reached Forge while storage was recovering. Retrying callback ${storageRecoveryAttempt} in ${formatDelay(backoff)}...`);
1003
+ await delay(backoff);
1004
+ continue;
1005
+ }
1006
+ throw error;
1007
+ }
828
1008
  }
829
- const ack = await connection.invoke(methodName, payload);
830
- if (!ack.accepted) {
831
- throw new BridgeCallbackRejectedError(ack.code ?? null, ack.message ?? ack.code ?? `${methodName} callback was rejected.`);
1009
+ }
1010
+ beginTransportRecovery() {
1011
+ if (this.transportRecovery && !this.transportRecovery.completed)
1012
+ return;
1013
+ let resolve = () => undefined;
1014
+ const promise = new Promise((complete) => { resolve = complete; });
1015
+ this.transportRecoveryGeneration += 1;
1016
+ this.transportRecovery = {
1017
+ generation: this.transportRecoveryGeneration,
1018
+ promise,
1019
+ resolve,
1020
+ completed: false,
1021
+ error: null,
1022
+ };
1023
+ }
1024
+ completeTransportRecovery(error = null) {
1025
+ const recovery = this.transportRecovery;
1026
+ if (!recovery || recovery.completed)
1027
+ return;
1028
+ recovery.completed = true;
1029
+ recovery.error = error;
1030
+ recovery.resolve();
1031
+ }
1032
+ async waitForTransportRecovery(connection, recovery) {
1033
+ if (!recovery)
1034
+ return;
1035
+ if (recovery.completed) {
1036
+ if (recovery.error)
1037
+ throw recovery.error;
1038
+ return;
832
1039
  }
833
- return ack;
1040
+ await recovery.promise;
1041
+ if (connection !== this.connection)
1042
+ throw new Error("Bridge connection changed while recovering.");
1043
+ if (recovery.error)
1044
+ throw recovery.error;
834
1045
  }
835
1046
  async waitForClose(signal) {
836
1047
  if (!this.closed) {
837
1048
  return;
838
1049
  }
839
- await Promise.race([this.closed, abortPromise(signal)]);
1050
+ await waitForPromiseOrAbort(this.closed, signal);
840
1051
  }
841
1052
  async stopConnection() {
842
1053
  this.stopHeartbeat();
@@ -862,6 +1073,11 @@ function isDispatchNoLongerPending(error) {
862
1073
  }
863
1074
  return error instanceof Error && error.message === "Dispatch is no longer pending.";
864
1075
  }
1076
+ function isProviderStorageUnavailable(error) {
1077
+ const message = errorMessage(error).toLowerCase();
1078
+ return message.includes("provider_storage_unavailable")
1079
+ || (message.includes("storage is recovering") && message.includes("503"));
1080
+ }
865
1081
  function createDefaultSessionService(sandboxStateRoot) {
866
1082
  const rawProcessRunner = new StreamingProviderProcessRunner();
867
1083
  const processSupervisors = new AttemptProcessSupervisorRegistry();
@@ -890,10 +1106,11 @@ export class SignalRBridgeConnection {
890
1106
  Authorization: `Bearer ${config.deviceToken}`
891
1107
  }
892
1108
  })
1109
+ .withAutomaticReconnect([...config.reconnectBackoffMs])
893
1110
  .configureLogging(LogLevel.Warning)
894
1111
  .build();
895
1112
  this.connection.on("Superseded", () => {
896
- void this.connection.stop();
1113
+ void this.connection.stop().catch(() => undefined);
897
1114
  });
898
1115
  }
899
1116
  start() {
@@ -905,6 +1122,14 @@ export class SignalRBridgeConnection {
905
1122
  onClose(handler) {
906
1123
  this.connection.onclose(handler);
907
1124
  }
1125
+ onReconnecting(handler) {
1126
+ this.connection.onreconnecting(handler);
1127
+ }
1128
+ onReconnected(handler) {
1129
+ this.connection.onreconnected((connectionId) => {
1130
+ void Promise.resolve(handler(connectionId)).catch(() => undefined);
1131
+ });
1132
+ }
908
1133
  onDispatch(handler) {
909
1134
  this.connection.on("Dispatch", (raw) => {
910
1135
  void handler(raw).catch(() => undefined);
@@ -947,9 +1172,7 @@ export class SignalRBridgeConnection {
947
1172
  export function defaultBridgeServiceConfig(overrides = {}) {
948
1173
  const defaults = defaultForgeBridgeConfig();
949
1174
  const cliProviders = splitCsv(process.env.FORGE_CLI_PROVIDERS ?? "codex");
950
- const sandboxHostMemoryReserveDefault = process.platform === "linux"
951
- ? 2 * 1024 ** 3
952
- : 1024 ** 3;
1175
+ const configuredSandboxHostMemoryReserveBytes = optionalPositiveFiniteNumber(process.env.FORGE_SANDBOX_HOST_MEMORY_RESERVE_BYTES);
953
1176
  const sandboxPublicationMode = process.env.FORGE_SANDBOX_PUBLICATION_MODE === "bridge_push" ? "bridge_push" : "shared_filesystem";
954
1177
  const config = {
955
1178
  serverUrl: process.env.FORGE_URL ?? "http://localhost:5208",
@@ -969,7 +1192,9 @@ export function defaultBridgeServiceConfig(overrides = {}) {
969
1192
  diskHighWatermarkPercent: positiveFiniteNumber(Number(process.env.FORGE_DISK_HIGH_WATERMARK_PERCENT ?? 85), 85),
970
1193
  diskLowWatermarkPercent: positiveFiniteNumber(Number(process.env.FORGE_DISK_LOW_WATERMARK_PERCENT ?? 75), 75),
971
1194
  minimumWorkspaceBytes: positiveFiniteNumber(Number(process.env.FORGE_MINIMUM_WORKSPACE_BYTES ?? 10 * 1024 ** 3), 10 * 1024 ** 3),
972
- sandboxHostMemoryReserveBytes: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_HOST_MEMORY_RESERVE_BYTES ?? sandboxHostMemoryReserveDefault), sandboxHostMemoryReserveDefault),
1195
+ // Undefined means preflight derives the bounded reserve from the actual
1196
+ // Docker/host capacity domain after sampling it.
1197
+ sandboxHostMemoryReserveBytes: configuredSandboxHostMemoryReserveBytes,
973
1198
  defaultSandboxWorkerMemoryLimitBytes: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_DEFAULT_WORKER_MEMORY_LIMIT_BYTES ?? 2 * 1024 ** 3), 2 * 1024 ** 3),
974
1199
  defaultSandboxServiceMemoryLimitBytes: nonNegativeFiniteNumber(Number(process.env.FORGE_SANDBOX_DEFAULT_SERVICE_MEMORY_LIMIT_BYTES ?? 0), 0),
975
1200
  sandboxRuntimeOverheadBytes: Math.max(128 * 1024 ** 2, nonNegativeFiniteNumber(Number(process.env.FORGE_SANDBOX_RUNTIME_OVERHEAD_BYTES ?? 128 * 1024 ** 2), 128 * 1024 ** 2)),
@@ -1022,6 +1247,12 @@ function normalizeServerUrl(serverUrl) {
1022
1247
  function positiveFiniteNumber(value, fallback) {
1023
1248
  return Number.isFinite(value) && value > 0 ? value : fallback;
1024
1249
  }
1250
+ function optionalPositiveFiniteNumber(value) {
1251
+ if (value === undefined || value.trim().length === 0)
1252
+ return undefined;
1253
+ const parsed = Number(value);
1254
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
1255
+ }
1025
1256
  function nonNegativeFiniteNumber(value, fallback) {
1026
1257
  return Number.isFinite(value) && value >= 0 ? value : fallback;
1027
1258
  }
@@ -1195,26 +1426,39 @@ function formatDelay(ms) {
1195
1426
  function logBridgeStatus(message) {
1196
1427
  process.stderr.write(`[${new Date().toISOString()}] ${message}\n`);
1197
1428
  }
1198
- function delay(ms, signal) {
1429
+ export function delay(ms, signal) {
1199
1430
  if (signal?.aborted) {
1200
1431
  return Promise.resolve();
1201
1432
  }
1202
1433
  return new Promise((resolve) => {
1203
- const timeout = setTimeout(resolve, ms);
1204
- signal?.addEventListener("abort", () => {
1434
+ const complete = () => {
1205
1435
  clearTimeout(timeout);
1436
+ signal?.removeEventListener("abort", complete);
1206
1437
  resolve();
1207
- }, { once: true });
1438
+ };
1439
+ const timeout = setTimeout(complete, ms);
1440
+ signal?.addEventListener("abort", complete, { once: true });
1441
+ if (signal?.aborted)
1442
+ complete();
1208
1443
  });
1209
1444
  }
1210
- function abortPromise(signal) {
1445
+ export async function waitForPromiseOrAbort(promise, signal) {
1211
1446
  if (!signal) {
1212
- return new Promise(() => undefined);
1447
+ await promise;
1448
+ return;
1213
1449
  }
1214
1450
  if (signal.aborted) {
1215
- return Promise.resolve();
1451
+ return;
1452
+ }
1453
+ let resolveAbort;
1454
+ const abort = new Promise(resolve => { resolveAbort = resolve; });
1455
+ signal.addEventListener("abort", resolveAbort, { once: true });
1456
+ try {
1457
+ await Promise.race([promise, abort]);
1458
+ }
1459
+ finally {
1460
+ signal.removeEventListener("abort", resolveAbort);
1216
1461
  }
1217
- return new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
1218
1462
  }
1219
1463
  export function canReapManagedSandboxes(preflight) {
1220
1464
  return canRunDockerMaintenance(preflight);
@@ -1234,9 +1478,14 @@ function sandboxNetworkPolicyFailure(dispatch, config) {
1234
1478
  }
1235
1479
  return null;
1236
1480
  }
1237
- function infrastructureFailureEventType(summary) {
1481
+ export function infrastructureFailureEventType(summary) {
1482
+ const sandboxFailure = /^(sandbox_[a-z0-9_.-]+|image_[a-z0-9_.-]+|contract_[a-z0-9_.-]+|teardown_[a-z0-9_.-]+):/u.exec(summary)?.[1];
1483
+ if (sandboxFailure)
1484
+ return sandboxFailure;
1238
1485
  if (summary.startsWith("worktree_prepare_failed:"))
1239
1486
  return "worktree_prepare_failed";
1487
+ if (summary.startsWith("provider_network_unavailable:"))
1488
+ return "provider_network_unavailable";
1240
1489
  if (summary.startsWith("provider_start_unavailable:"))
1241
1490
  return "provider_start_unavailable";
1242
1491
  if (summary.startsWith("host_port_collision:"))
@@ -1308,6 +1557,15 @@ function sandboxAuthFailure(dispatch, config) {
1308
1557
  const doctor = adapter.doctor();
1309
1558
  return doctor.available ? null : doctor.message;
1310
1559
  }
1560
+ function rejectedOfferReservation(code, message) {
1561
+ return {
1562
+ accepted: false,
1563
+ code,
1564
+ message,
1565
+ reservedUntil: null,
1566
+ resources: null,
1567
+ };
1568
+ }
1311
1569
  export function canReserveSandboxResources(used, requested, capacity) {
1312
1570
  return used.sandboxSlots + requested.sandboxSlots <= capacity.sandboxSlots
1313
1571
  && used.cpuMillis + requested.cpuMillis <= capacity.cpuMillis
@@ -1316,6 +1574,15 @@ export function canReserveSandboxResources(used, requested, capacity) {
1316
1574
  && used.pids + requested.pids <= capacity.pids
1317
1575
  && used.buildSlots + requested.buildSlots <= capacity.buildSlots;
1318
1576
  }
1577
+ export function sandboxMemoryCommitmentCapacity(preflight, activeMemoryBytes, ambientMemoryCommitmentBytes = 0) {
1578
+ const ambientCapacity = preflight.availableMemoryBytes
1579
+ - preflight.hostReserveBytes
1580
+ + activeMemoryBytes;
1581
+ const commitmentCapacity = preflight.totalMemoryBytes
1582
+ - preflight.hostReserveBytes
1583
+ - Math.max(0, ambientMemoryCommitmentBytes);
1584
+ return Math.max(0, Math.min(ambientCapacity, commitmentCapacity));
1585
+ }
1319
1586
  export function sandboxCapacityFailureMessage(used, requested, capacity, preflight, config) {
1320
1587
  const availableSlots = Math.max(0, capacity.sandboxSlots - used.sandboxSlots);
1321
1588
  if (requested.sandboxSlots > availableSlots) {
@@ -1346,14 +1613,6 @@ export function sandboxCapacityFailureMessage(used, requested, capacity, preflig
1346
1613
  const availableBuildSlots = Math.max(0, capacity.buildSlots - used.buildSlots);
1347
1614
  return `Sandbox build capacity is insufficient: requested ${requested.buildSlots}, available ${availableBuildSlots}, ${used.buildSlots} already reserved.`;
1348
1615
  }
1349
- function formatBytes(bytes) {
1350
- if (!Number.isFinite(bytes) || bytes <= 0)
1351
- return "0 B";
1352
- const units = ["B", "KiB", "MiB", "GiB", "TiB"];
1353
- const power = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
1354
- const value = bytes / 1024 ** power;
1355
- return `${value >= 10 || power === 0 ? value.toFixed(0) : value.toFixed(2)} ${units[power]}`;
1356
- }
1357
1616
  function sumSandboxResources(resources) {
1358
1617
  return resources.reduce((total, resource) => ({
1359
1618
  sandboxSlots: total.sandboxSlots + resource.sandboxSlots,
@@ -1425,8 +1684,16 @@ export async function resolveSandboxResourcesFromRepo(dispatch, baseRuntimeOverh
1425
1684
  throw new Error("The approved immutable sandbox contract is unavailable in the mapped repository.");
1426
1685
  }
1427
1686
  const contract = validateSandboxContract(contractResult.stdout);
1687
+ const workspaceBytes = await estimateWorkspaceBytesFromRepo(repoPath, {
1688
+ role: dispatch.execution.role,
1689
+ serviceCount: contract.contract.services.length,
1690
+ browser: contract.contract.browser,
1691
+ });
1428
1692
  return {
1429
- resources: resolveDynamicSandboxResources(sandbox.requestedResources, contract.contract.services.length, sandbox.networkPolicy.mode, baseRuntimeOverheadBytes),
1693
+ resources: resolveDynamicSandboxResources({
1694
+ ...sandbox.requestedResources,
1695
+ workspaceBytes: Math.max(sandbox.requestedResources.workspaceBytes, workspaceBytes),
1696
+ }, contract.contract.services.length, sandbox.networkPolicy.mode, baseRuntimeOverheadBytes),
1430
1697
  sandboxConfigCommitSha: configCommitSha,
1431
1698
  };
1432
1699
  }