@loucompanion/forge-bridge 0.1.3-dev.8777774c67bc → 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 (137) hide show
  1. package/README.md +51 -0
  2. package/dist/bridge/bridge.base.d.ts +4 -1
  3. package/dist/bridge/bridge.service.d.ts +50 -3
  4. package/dist/bridge/bridge.service.js +730 -87
  5. package/dist/bridge/bridge.types.d.ts +63 -1
  6. package/dist/cleanup/cleanup.service.d.ts +14 -2
  7. package/dist/cleanup/cleanup.service.js +496 -3
  8. package/dist/cleanup/cleanup.types.d.ts +10 -0
  9. package/dist/cleanup/index.d.ts +1 -1
  10. package/dist/cleanup/index.js +1 -1
  11. package/dist/cleanup/internals.d.ts +2 -2
  12. package/dist/cleanup/internals.js +1 -1
  13. package/dist/cli/cli.help.js +37 -3
  14. package/dist/cli/cli.service.d.ts +30 -2
  15. package/dist/cli/cli.service.js +191 -27
  16. package/dist/cli/local-config.d.ts +4 -0
  17. package/dist/cli/local-config.js +41 -1
  18. package/dist/runtime/aspnet-runtime-prebuild.d.ts +36 -0
  19. package/dist/runtime/aspnet-runtime-prebuild.js +340 -0
  20. package/dist/runtime/attempt-node-runtime-launcher.d.ts +17 -0
  21. package/dist/runtime/attempt-node-runtime-launcher.js +105 -0
  22. package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
  23. package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
  24. package/dist/runtime/attempt-process-supervisor.d.ts +97 -0
  25. package/dist/runtime/attempt-process-supervisor.js +384 -0
  26. package/dist/runtime/generated-output-reset.d.ts +10 -0
  27. package/dist/runtime/generated-output-reset.js +103 -0
  28. package/dist/runtime/host-listener-inspector.d.ts +46 -0
  29. package/dist/runtime/host-listener-inspector.js +349 -0
  30. package/dist/runtime/host-port-broker.d.ts +76 -0
  31. package/dist/runtime/host-port-broker.js +333 -0
  32. package/dist/runtime/host-runtime-contract.d.ts +47 -0
  33. package/dist/runtime/host-runtime-contract.js +700 -0
  34. package/dist/runtime/host-runtime-lease.d.ts +194 -0
  35. package/dist/runtime/host-runtime-lease.js +2138 -0
  36. package/dist/runtime/host-runtime-promotion.d.ts +23 -0
  37. package/dist/runtime/host-runtime-promotion.js +159 -0
  38. package/dist/runtime/memory-capacity.d.ts +68 -0
  39. package/dist/runtime/memory-capacity.js +121 -0
  40. package/dist/runtime/node-host-runtime-adapter.d.ts +54 -0
  41. package/dist/runtime/node-host-runtime-adapter.js +284 -0
  42. package/dist/runtime/playwright-host-runtime-adapter.d.ts +21 -0
  43. package/dist/runtime/playwright-host-runtime-adapter.js +225 -0
  44. package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
  45. package/dist/runtime/runtime-isolation-lease.js +158 -0
  46. package/dist/runtime/workspace-resource-estimator.d.ts +17 -0
  47. package/dist/runtime/workspace-resource-estimator.js +174 -0
  48. package/dist/sandbox/image-policy.d.ts +3 -1
  49. package/dist/sandbox/image-policy.js +4 -2
  50. package/dist/sandbox/preflight.d.ts +49 -1
  51. package/dist/sandbox/preflight.js +313 -31
  52. package/dist/sandbox/process.js +125 -40
  53. package/dist/sandbox/repository-lock.js +41 -3
  54. package/dist/sandbox/runtime.d.ts +85 -4
  55. package/dist/sandbox/runtime.js +806 -126
  56. package/dist/sandbox/sandbox.types.d.ts +10 -0
  57. package/dist/sandbox/smoke.d.ts +76 -0
  58. package/dist/sandbox/smoke.js +380 -0
  59. package/dist/sandbox/worker-supervisor.js +1 -1
  60. package/dist/sandbox/worker.js +2 -5
  61. package/dist/sandbox/workspace.service.d.ts +1 -0
  62. package/dist/sandbox/workspace.service.js +28 -5
  63. package/dist/session/managed-attempt-tools.d.ts +18 -0
  64. package/dist/session/managed-attempt-tools.js +854 -0
  65. package/dist/session/managed-build-coordinator.d.ts +5 -0
  66. package/dist/session/managed-build-coordinator.js +105 -0
  67. package/dist/session/managed-build-receipt.d.ts +21 -0
  68. package/dist/session/managed-build-receipt.js +335 -0
  69. package/dist/session/phase-completion.artifacts.d.ts +17 -0
  70. package/dist/session/phase-completion.artifacts.js +233 -24
  71. package/dist/session/phase-completion.d.ts +11 -2
  72. package/dist/session/phase-completion.helpers.d.ts +5 -0
  73. package/dist/session/phase-completion.helpers.js +51 -10
  74. package/dist/session/phase-completion.js +12 -3
  75. package/dist/session/phase-completion.prompts.d.ts +3 -3
  76. package/dist/session/phase-completion.prompts.js +21 -6
  77. package/dist/session/phase-completion.runtime.d.ts +13 -1
  78. package/dist/session/phase-completion.runtime.js +189 -8
  79. package/dist/session/phase-completion.schemas.js +108 -3
  80. package/dist/session/phase-completion.validation.d.ts +4 -1
  81. package/dist/session/phase-completion.validation.js +151 -21
  82. package/dist/session/provider-cli/ci-repair-readiness.d.ts +14 -0
  83. package/dist/session/provider-cli/ci-repair-readiness.js +105 -0
  84. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +3 -2
  85. package/dist/session/provider-cli/provider-cli.artifacts.js +65 -7
  86. package/dist/session/provider-cli/provider-cli.deterministic.js +47 -1
  87. package/dist/session/provider-cli/provider-cli.process.d.ts +23 -0
  88. package/dist/session/provider-cli/provider-cli.process.js +420 -32
  89. package/dist/session/provider-cli/provider-cli.prompts.d.ts +4 -1
  90. package/dist/session/provider-cli/provider-cli.prompts.js +99 -19
  91. package/dist/session/provider-cli/provider-cli.qa-sidecars.js +1 -0
  92. package/dist/session/provider-cli/provider-cli.runtime.d.ts +1 -1
  93. package/dist/session/provider-cli/provider-cli.runtime.js +6 -3
  94. package/dist/session/provider-cli/provider-cli.service.d.ts +20 -2
  95. package/dist/session/provider-cli/provider-cli.service.js +464 -102
  96. package/dist/session/provider-cli/provider-cli.types.d.ts +5 -0
  97. package/dist/session/provider-cli/provider-output-state.d.ts +2 -0
  98. package/dist/session/provider-cli/provider-output-state.js +91 -16
  99. package/dist/session/provider-cli/provider-usage.d.ts +7 -0
  100. package/dist/session/provider-cli/provider-usage.js +28 -5
  101. package/dist/session/session-redaction.d.ts +3 -0
  102. package/dist/session/session-redaction.js +38 -0
  103. package/dist/session/session.base.d.ts +6 -1
  104. package/dist/session/session.service.d.ts +8 -2
  105. package/dist/session/session.service.js +522 -41
  106. package/dist/session/session.types.d.ts +70 -0
  107. package/dist/session/session.types.js +76 -4
  108. package/dist/shared/format/format-bytes.d.ts +1 -0
  109. package/dist/shared/format/format-bytes.js +9 -0
  110. package/dist/shared/path/path.d.ts +1 -0
  111. package/dist/shared/path/path.js +21 -1
  112. package/dist/shared/process/host-process-recovery.d.ts +12 -0
  113. package/dist/shared/process/host-process-recovery.js +78 -0
  114. package/dist/shared/process/host-process-supervisor.d.ts +59 -0
  115. package/dist/shared/process/host-process-supervisor.js +330 -0
  116. package/dist/shared/process/process.d.ts +1 -0
  117. package/dist/shared/process/process.js +1 -0
  118. package/dist/shared/redaction/redaction.js +12 -2
  119. package/dist/version.d.ts +1 -1
  120. package/dist/version.js +1 -1
  121. package/dist/worktree/artifact-injector.d.ts +3 -2
  122. package/dist/worktree/artifact-injector.js +43 -4
  123. package/dist/worktree/dependency-warmer.d.ts +28 -1
  124. package/dist/worktree/dependency-warmer.js +308 -9
  125. package/dist/worktree/index.d.ts +2 -1
  126. package/dist/worktree/index.js +1 -0
  127. package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
  128. package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
  129. package/dist/worktree/worktree-paths.d.ts +4 -0
  130. package/dist/worktree/worktree-paths.js +30 -0
  131. package/dist/worktree/worktree.base.d.ts +3 -0
  132. package/dist/worktree/worktree.service.d.ts +9 -2
  133. package/dist/worktree/worktree.service.js +217 -48
  134. package/dist/worktree/worktree.types.d.ts +5 -1
  135. package/package.json +5 -3
  136. package/runtime/windows-job-supervisor.ps1 +444 -0
  137. package/runtime/windows-listener-owner.ps1 +33 -0
@@ -5,32 +5,64 @@ import { randomUUID } from "node:crypto";
5
5
  import os from "node:os";
6
6
  import { defaultForgeBridgeConfig } from "../config.js";
7
7
  import { supportsDraftImageAttachmentsFor } from "../shared/provider-capabilities.js";
8
- import { redactSecrets, redactText } from "../shared/redaction/redaction.js";
8
+ import { redactText } from "../shared/redaction/redaction.js";
9
9
  import { BridgeSessionService } from "../session/session.service.js";
10
+ import { ProcessBackedProviderCliService, RoutingProviderCliService, StreamingProviderProcessRunner, } from "../session/provider-cli/provider-cli.service.js";
11
+ import { redactRunnerDispatchEvent, redactRunOnceResultPacket } from "../session/session-redaction.js";
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";
15
+ import { GitWorktreeService } from "../worktree/worktree.service.js";
16
+ import { RepositoryCoordinatorLock } from "../sandbox/repository-lock.js";
17
+ import { AttemptCoordinatedProviderProcessRunner, AttemptProcessSupervisorRegistry, } from "../runtime/attempt-process-supervisor-registry.js";
10
18
  import { failureResult, parseRunOnceDispatchPacket } from "../session/session.types.js";
11
19
  import { forgeBridgeVersion } from "../version.js";
12
- import { DockerPreflight } from "../sandbox/preflight.js";
13
- import { DockerSandboxRuntime } from "../sandbox/runtime.js";
20
+ import { canRunDockerMaintenance, DockerPreflight } from "../sandbox/preflight.js";
21
+ import { validateSandboxContract } from "../sandbox/contract.js";
22
+ import { runSandboxProcess } from "../sandbox/process.js";
23
+ import { DockerSandboxRuntime, selectSandboxConfigRefs } from "../sandbox/runtime.js";
14
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
+ }
15
39
  export class ForgeBridgeService {
16
40
  config;
17
- sessions;
18
41
  connections;
19
42
  connection = null;
20
43
  heartbeatTimer = null;
44
+ heartbeatInFlight = false;
45
+ consecutiveHeartbeatFailures = 0;
46
+ reconnectingFromHeartbeatFailure = false;
21
47
  leaseWatchdogTimer = null;
22
48
  sandboxMaintenanceTimer = null;
23
49
  sandboxMaintenanceRunning = false;
24
50
  closed = null;
51
+ transportRecovery = null;
52
+ transportRecoveryGeneration = 0;
25
53
  stopped = false;
26
54
  activeSessions = new Map();
27
55
  reservations = new Map();
56
+ activeReadinessProbes = new Map();
28
57
  preflight;
29
58
  sandboxRuntime;
30
- constructor(config, sessions = new BridgeSessionService(), connections = new SignalRBridgeConnectionFactory(), preflight, sandboxRuntime) {
59
+ sandboxResourceResolver;
60
+ sessions;
61
+ sandboxAmbientMemoryCommitmentBytes = null;
62
+ constructor(config, sessions = undefined, connections = new SignalRBridgeConnectionFactory(), preflight, sandboxRuntime, sandboxResourceResolver) {
31
63
  this.config = config;
32
- this.sessions = sessions;
33
64
  this.connections = connections;
65
+ this.sessions = sessions ?? createDefaultSessionService(config.sandboxStateRoot);
34
66
  this.preflight = preflight ?? new DockerPreflight({
35
67
  dockerExecutable: config.dockerExecutable,
36
68
  dockerContext: config.dockerContext,
@@ -40,47 +72,109 @@ export class ForgeBridgeService {
40
72
  diskHighWatermarkPercent: config.diskHighWatermarkPercent,
41
73
  diskLowWatermarkPercent: config.diskLowWatermarkPercent,
42
74
  minimumWorkspaceBytes: config.minimumWorkspaceBytes,
75
+ hostMemoryReserveBytes: config.sandboxHostMemoryReserveBytes,
76
+ defaultWorkerMemoryLimitBytes: config.defaultSandboxWorkerMemoryLimitBytes,
77
+ defaultServiceMemoryLimitBytes: config.defaultSandboxServiceMemoryLimitBytes,
78
+ runtimeOverheadBytes: config.sandboxRuntimeOverheadBytes,
43
79
  requireRootless: config.requireRootlessDocker
44
80
  });
45
81
  this.sandboxRuntime = sandboxRuntime ?? new DockerSandboxRuntime(config, (request, signal) => this.redeemPublishGrant(request, signal));
82
+ this.sandboxResourceResolver = sandboxResourceResolver
83
+ ?? (dispatch => resolveSandboxResourcesFromRepo(dispatch, this.config.sandboxRuntimeOverheadBytes));
46
84
  }
47
85
  async connectOnce() {
48
86
  await this.stopConnection();
49
87
  this.stopped = false;
88
+ const hostReap = await this.sessions.reapHostRuntime?.({
89
+ runnerId: this.config.runnerId,
90
+ runnerInstanceId: this.config.runnerInstanceId,
91
+ });
92
+ if (hostReap && (hostReap.reaped > 0 || hostReap.degraded > 0)) {
93
+ logBridgeStatus(`Managed-host recovery reaped ${hostReap.reaped} stale attempt(s), protected ${hostReap.reusedPidsProtected} reused PID(s), and left ${hostReap.degraded} degraded attempt(s) for reconciliation.`);
94
+ }
50
95
  if (this.config.dockerEnabled) {
51
96
  const preflight = await this.preflight.refresh();
52
97
  if (canReapManagedSandboxes(preflight)) {
53
98
  await this.sandboxRuntime.reap({ diskLow: preflight.diskLow });
99
+ await this.preflight.refresh();
54
100
  }
55
101
  }
56
102
  const connection = this.connections.create(this.config);
57
103
  this.connection = connection;
104
+ this.transportRecovery = null;
58
105
  this.closed = new Promise((resolve) => {
59
- connection.onClose(() => {
106
+ connection.onClose((error) => {
60
107
  this.stopHeartbeat();
108
+ this.completeTransportRecovery(error ?? new Error("bridge connection closed"));
61
109
  this.abortActiveSessions("bridge connection closed");
110
+ this.abortActiveReadinessProbes("bridge connection closed");
62
111
  resolve();
63
112
  });
64
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
+ });
65
137
  connection.onDispatch((dispatch) => this.handleDispatch(dispatch));
66
138
  connection.onOffer?.((dispatch) => this.handleOffer(dispatch));
67
139
  connection.onStart?.((dispatch) => this.handleStart(dispatch));
68
140
  connection.onLeaseUpdate?.((request) => this.handleLeaseUpdate(request));
69
141
  connection.onCancel((request) => this.handleCancel(request));
70
142
  connection.onReap?.((request) => this.handleReap(request));
143
+ connection.onReadinessProbe?.((request) => this.handleReadinessProbe(request));
71
144
  await connection.start();
72
- await this.invokeAck("Hello", {
73
- version: this.config.version,
74
- protocolVersion: this.config.protocolVersion,
75
- minServerVersion: this.config.minServerVersion ?? null,
76
- runnerInstanceId: this.config.runnerInstanceId,
77
- capabilities: this.capabilities()
78
- });
145
+ await this.registerConnection(connection);
79
146
  await this.flushCleanupReports();
80
147
  this.startHeartbeat();
81
148
  this.startLeaseWatchdog();
82
149
  this.startSandboxMaintenance();
83
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
+ }
84
178
  async run(signal) {
85
179
  let attempt = 0;
86
180
  try {
@@ -110,6 +204,7 @@ export class ForgeBridgeService {
110
204
  finally {
111
205
  if (signal?.aborted) {
112
206
  this.abortActiveSessions(String(signal.reason ?? "bridge stopping"));
207
+ this.abortActiveReadinessProbes(String(signal.reason ?? "bridge stopping"));
113
208
  }
114
209
  await this.stopConnection();
115
210
  }
@@ -117,6 +212,7 @@ export class ForgeBridgeService {
117
212
  async stop() {
118
213
  this.stopped = true;
119
214
  this.abortActiveSessions("bridge stopping");
215
+ this.abortActiveReadinessProbes("bridge stopping");
120
216
  await this.stopConnection();
121
217
  await this.preflight.release();
122
218
  }
@@ -174,7 +270,7 @@ export class ForgeBridgeService {
174
270
  dispatchAttemptId: dispatch.sandbox.dispatchAttemptId,
175
271
  runnerInstanceId: dispatch.sandbox.runnerInstanceId
176
272
  } : {}),
177
- event: redactSecrets(event, redactionSecrets)
273
+ event: redactRunnerDispatchEvent(event, redactionSecrets)
178
274
  });
179
275
  }
180
276
  catch (error) {
@@ -192,13 +288,21 @@ export class ForgeBridgeService {
192
288
  if (normalized.sandbox?.executionMode === "docker") {
193
289
  await this.flushCleanupReports().catch(() => undefined);
194
290
  }
195
- await this.sendResult(dispatch, redactSecrets(result, redactionSecrets));
291
+ const redactedResult = redactRunOnceResultPacket(result, redactionSecrets);
292
+ await this.sendResult(dispatch, compactTerminalResult(dispatch, redactedResult));
196
293
  }
197
294
  catch (error) {
198
295
  const summary = controller.signal.aborted
199
296
  ? `Dispatch cancelled: ${String(controller.signal.reason ?? "cancelled")}`
200
297
  : redactText(error instanceof Error ? error.message : String(error), redactionSecrets);
201
- await this.sendResult(dispatch, failureResult(summary)).catch(() => undefined);
298
+ const failureEventType = infrastructureFailureEventType(summary);
299
+ const failureEvents = failureEventType ? [{
300
+ role: "runner",
301
+ kind: "error",
302
+ content: summary,
303
+ eventType: failureEventType,
304
+ }] : [];
305
+ await this.sendResult(dispatch, failureResult(summary, failureEvents)).catch(() => undefined);
202
306
  }
203
307
  finally {
204
308
  this.activeSessions.delete(leaseId);
@@ -216,14 +320,13 @@ export class ForgeBridgeService {
216
320
  || dispatch.lease.fencingToken !== envelope.fencingToken)
217
321
  return;
218
322
  const sandbox = dispatch.sandbox;
219
- let existing = this.reservations.get(sandbox.dispatchAttemptId);
220
- if (existing && Date.parse(existing.reservedUntil) <= Date.now()) {
221
- this.reservations.delete(sandbox.dispatchAttemptId);
222
- existing = undefined;
223
- }
323
+ this.expireReservations(Date.now());
324
+ const existing = this.reservations.get(sandbox.dispatchAttemptId);
224
325
  if (existing) {
225
- const sameFence = existing.fencingToken === dispatch.lease.fencingToken;
226
- await this.sendOfferDecision(dispatch, sameFence, sameFence ? null : "stale_fence", sameFence ? null : "Offer fence conflicts with the existing reservation.", sameFence ? existing.reservedUntil : null);
326
+ const sameFence = existing.leaseId === dispatch.lease.id
327
+ && existing.fencingToken === dispatch.lease.fencingToken
328
+ && existing.runnerInstanceId === sandbox.runnerInstanceId;
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);
227
330
  return;
228
331
  }
229
332
  if (sandbox.runnerInstanceId !== this.config.runnerInstanceId) {
@@ -234,42 +337,123 @@ export class ForgeBridgeService {
234
337
  await this.sendOfferDecision(dispatch, false, "lease_expired", "Offer lease is already expired.");
235
338
  return;
236
339
  }
237
- const preflight = this.preflight.current();
340
+ const preflight = sandbox.executionMode === "docker" && this.config.dockerEnabled
341
+ ? await this.preflight.run()
342
+ : this.preflight.current();
238
343
  if (sandbox.executionMode === "docker") {
239
344
  if (!this.config.dockerEnabled || !preflight?.healthy) {
240
345
  await this.sendOfferDecision(dispatch, false, preflight?.unavailableCode ?? "docker_unavailable", preflight?.unavailableReason ?? "Docker sandbox preflight is unavailable.");
241
346
  return;
242
347
  }
243
- const networkFailure = sandboxNetworkPolicyFailure(dispatch);
348
+ const networkFailure = sandboxNetworkPolicyFailure(dispatch, this.config);
244
349
  if (networkFailure) {
245
350
  await this.sendOfferDecision(dispatch, false, "policy_mismatch", networkFailure);
246
351
  return;
247
352
  }
353
+ const providerNetworkFailure = sandboxProviderNetworkFailure(dispatch, this.config);
354
+ if (providerNetworkFailure) {
355
+ await this.sendOfferDecision(dispatch, false, "provider_network_policy_denied", providerNetworkFailure);
356
+ return;
357
+ }
248
358
  const authFailure = sandboxAuthFailure(dispatch, this.config);
249
359
  if (authFailure) {
250
360
  await this.sendOfferDecision(dispatch, false, "sandbox_auth_unavailable", authFailure);
251
361
  return;
252
362
  }
253
363
  }
254
- const runtimeState = this.sandboxRuntime.snapshot();
255
- const usedResources = this.usedSandboxResources(runtimeState.degraded);
256
- const capacity = this.sandboxResourceCapacity(preflight);
257
- if (sandbox.executionMode === "docker"
258
- && !canReserveSandboxResources(usedResources, sandbox.requestedResources, capacity)) {
259
- await this.sendOfferDecision(dispatch, false, "capacity_unavailable", "Runner has insufficient sandbox CPU, memory, workspace, PID, build, or slot capacity.");
260
- return;
364
+ let requestedResources = sandbox.requestedResources;
365
+ let sandboxConfigCommitSha = sandbox.sandboxConfigCommitSha ?? null;
366
+ const normalizedDispatch = this.normalizeDispatch(dispatch);
367
+ if (sandbox.executionMode === "docker") {
368
+ try {
369
+ const profile = await this.sandboxResourceResolver(normalizedDispatch);
370
+ requestedResources = profile.resources;
371
+ sandboxConfigCommitSha = profile.sandboxConfigCommitSha;
372
+ }
373
+ catch (error) {
374
+ await this.sendOfferDecision(dispatch, false, "sandbox_capacity_profile_unavailable", error instanceof Error ? error.message : "The immutable sandbox contract could not be profiled for admission.");
375
+ return;
376
+ }
377
+ }
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
+ };
261
420
  }
262
- const reservedUntil = new Date(Math.min(Date.parse(dispatch.lease.expiresAt), Date.now() + 30_000)).toISOString();
263
- 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 = {
264
441
  leaseId: dispatch.lease.id,
265
442
  fencingToken: dispatch.lease.fencingToken,
266
443
  runnerInstanceId: sandbox.runnerInstanceId,
267
444
  expiresAt: dispatch.lease.expiresAt,
268
445
  reservedUntil,
269
446
  sandboxed: sandbox.executionMode === "docker",
270
- resources: sandbox.requestedResources
271
- });
272
- await this.sendOfferDecision(dispatch, true, null, null, reservedUntil);
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
+ };
273
457
  }
274
458
  async handleStart(rawDispatch) {
275
459
  const envelope = parseStartEnvelope(rawDispatch);
@@ -315,6 +499,10 @@ export class ForgeBridgeService {
315
499
  await this.sendResult(dispatch, failureResult("Start rejected because the provider secret grant handle is missing.")).catch(() => undefined);
316
500
  return;
317
501
  }
502
+ dispatch = {
503
+ ...dispatch,
504
+ sandbox: { ...dispatch.sandbox, requestedResources: reservation.resources },
505
+ };
318
506
  this.reservations.delete(envelope.dispatchAttemptId);
319
507
  await this.handleDispatch(dispatch);
320
508
  }
@@ -355,10 +543,15 @@ export class ForgeBridgeService {
355
543
  const active = this.activeSessions.get(request.leaseId);
356
544
  if (active && active.fencingToken === request.fencingToken && active.dispatchAttemptId === request.dispatchAttemptId) {
357
545
  active.leaseExpiresAt = request.expiresAt;
358
- await this.sandboxRuntime.updateLease(request.dispatchAttemptId, request.fencingToken, request.expiresAt);
546
+ if (active.sandboxed) {
547
+ await this.sandboxRuntime.updateLease(request.dispatchAttemptId, request.fencingToken, request.expiresAt);
548
+ }
549
+ else {
550
+ await this.sessions.updateLease?.(request.dispatchAttemptId, request.fencingToken, request.expiresAt);
551
+ }
359
552
  }
360
553
  }
361
- async sendOfferDecision(dispatch, accepted, code = null, message = null, reservedUntil = null) {
554
+ async sendOfferDecision(dispatch, accepted, code = null, message = null, reservedUntil = null, reservedResources = null, sandboxConfigCommitSha = null) {
362
555
  if (!dispatch.sandbox)
363
556
  return;
364
557
  const payload = {
@@ -369,7 +562,9 @@ export class ForgeBridgeService {
369
562
  accepted,
370
563
  code,
371
564
  message,
372
- reservedUntil: accepted ? reservedUntil ?? dispatch.lease.expiresAt : null
565
+ reservedUntil: accepted ? reservedUntil ?? dispatch.lease.expiresAt : null,
566
+ reservedResources: accepted ? reservedResources : null,
567
+ sandboxConfigCommitSha: accepted ? sandboxConfigCommitSha : null,
373
568
  };
374
569
  await this.invokeAck("OfferDecision", payload);
375
570
  }
@@ -402,19 +597,73 @@ export class ForgeBridgeService {
402
597
  active.controller.abort(request.reason ?? "cancelled by server");
403
598
  }
404
599
  async handleReap(request) {
405
- if (!this.config.dockerEnabled
406
- || request.runnerId !== this.config.runnerId
600
+ if (request.runnerId !== this.config.runnerId
407
601
  || request.runnerInstanceId !== this.config.runnerInstanceId)
408
602
  return;
409
603
  await this.runSandboxMaintenance();
410
604
  await this.sendHeartbeat();
411
605
  }
606
+ async handleReadinessProbe(rawRequest) {
607
+ const request = parseReadinessProbe(rawRequest);
608
+ if (!request || request.runnerInstanceId !== this.config.runnerInstanceId)
609
+ return;
610
+ const existing = this.activeReadinessProbes.get(request.requestId);
611
+ if (existing)
612
+ return;
613
+ const controller = new AbortController();
614
+ this.activeReadinessProbes.set(request.requestId, controller);
615
+ const deadlineMs = Date.parse(request.expiresAt) - Date.now();
616
+ const timer = setTimeout(() => controller.abort("readiness probe expired"), Math.max(0, deadlineMs));
617
+ timer.unref?.();
618
+ let result;
619
+ try {
620
+ if (!this.sessions.probeHostRuntime) {
621
+ throw new Error("host_runtime_probe_unavailable: this bridge does not implement runtime readiness probes.");
622
+ }
623
+ const probe = await this.sessions.probeHostRuntime({
624
+ ...request,
625
+ runnerId: this.config.runnerId,
626
+ repoRoot: this.config.defaultRepoRoot,
627
+ cleanupWorktrees: this.config.cleanupWorktrees,
628
+ }, controller.signal);
629
+ result = {
630
+ schemaVersion: 1,
631
+ requestId: request.requestId,
632
+ fencingToken: request.fencingToken,
633
+ runnerInstanceId: this.config.runnerInstanceId,
634
+ ...probe,
635
+ };
636
+ }
637
+ catch (error) {
638
+ result = {
639
+ schemaVersion: 1,
640
+ requestId: request.requestId,
641
+ fencingToken: request.fencingToken,
642
+ runnerInstanceId: this.config.runnerInstanceId,
643
+ status: "failed",
644
+ reasonCode: "host_runtime_probe_failed",
645
+ reasonMessage: errorMessage(error).slice(0, 1000),
646
+ checkedAt: new Date().toISOString(),
647
+ };
648
+ }
649
+ finally {
650
+ clearTimeout(timer);
651
+ this.activeReadinessProbes.delete(request.requestId);
652
+ }
653
+ await this.invokeAck("ReadinessResult", result).catch(() => undefined);
654
+ await this.sendHeartbeat().catch(() => undefined);
655
+ }
412
656
  abortActiveSessions(reason) {
413
657
  for (const active of this.activeSessions.values()) {
414
658
  active.controller.abort(reason);
415
659
  }
416
660
  this.activeSessions.clear();
417
661
  }
662
+ abortActiveReadinessProbes(reason) {
663
+ for (const controller of this.activeReadinessProbes.values())
664
+ controller.abort(reason);
665
+ this.activeReadinessProbes.clear();
666
+ }
418
667
  normalizeDispatch(dispatch) {
419
668
  const mapping = findRepoMapping(dispatch, repoMappingsFor(this.config));
420
669
  const localPath = mapping?.localPath?.trim();
@@ -465,12 +714,41 @@ export class ForgeBridgeService {
465
714
  }
466
715
  startHeartbeat() {
467
716
  this.stopHeartbeat();
717
+ this.consecutiveHeartbeatFailures = 0;
718
+ this.reconnectingFromHeartbeatFailure = false;
468
719
  const intervalMs = positiveFiniteNumber(this.config.heartbeatIntervalMs, 30_000);
469
720
  this.heartbeatTimer = setInterval(() => {
470
- this.sendHeartbeat().catch(() => undefined);
721
+ void this.heartbeatTick();
471
722
  }, intervalMs);
472
723
  this.heartbeatTimer.unref?.();
473
724
  }
725
+ async heartbeatTick() {
726
+ if (this.heartbeatInFlight || this.reconnectingFromHeartbeatFailure)
727
+ return;
728
+ const connection = this.connection;
729
+ if (!connection)
730
+ return;
731
+ this.heartbeatInFlight = true;
732
+ try {
733
+ await this.sendHeartbeat();
734
+ this.consecutiveHeartbeatFailures = 0;
735
+ }
736
+ catch (error) {
737
+ if (connection !== this.connection || this.stopped)
738
+ return;
739
+ this.consecutiveHeartbeatFailures += 1;
740
+ logBridgeStatus(`Heartbeat ${this.consecutiveHeartbeatFailures} failed: ${errorMessage(error)}.`);
741
+ if (this.consecutiveHeartbeatFailures < 2)
742
+ return;
743
+ this.reconnectingFromHeartbeatFailure = true;
744
+ this.stopHeartbeat();
745
+ logBridgeStatus("Heartbeat failed twice. Closing the stale connection so the Bridge can reconnect...");
746
+ await connection.stop().catch(() => undefined);
747
+ }
748
+ finally {
749
+ this.heartbeatInFlight = false;
750
+ }
751
+ }
474
752
  startLeaseWatchdog() {
475
753
  this.stopLeaseWatchdog();
476
754
  this.leaseWatchdogTimer = setInterval(() => this.expireLeases(), 1000);
@@ -484,17 +762,20 @@ export class ForgeBridgeService {
484
762
  }
485
763
  expireLeases() {
486
764
  const now = Date.now();
487
- for (const [attemptId, reservation] of this.reservations) {
488
- if (Date.parse(reservation.expiresAt) <= now || Date.parse(reservation.reservedUntil) <= now) {
489
- this.reservations.delete(attemptId);
490
- }
491
- }
765
+ this.expireReservations(now);
492
766
  for (const active of this.activeSessions.values()) {
493
767
  if (Date.parse(active.leaseExpiresAt) <= now && !active.controller.signal.aborted) {
494
768
  active.controller.abort("dispatch lease expired");
495
769
  }
496
770
  }
497
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
+ }
498
779
  stopHeartbeat() {
499
780
  if (this.heartbeatTimer) {
500
781
  clearInterval(this.heartbeatTimer);
@@ -503,8 +784,6 @@ export class ForgeBridgeService {
503
784
  }
504
785
  startSandboxMaintenance() {
505
786
  this.stopSandboxMaintenance();
506
- if (!this.config.dockerEnabled)
507
- return;
508
787
  this.sandboxMaintenanceTimer = setInterval(() => {
509
788
  this.runSandboxMaintenance().catch(() => undefined);
510
789
  }, 60_000);
@@ -521,11 +800,20 @@ export class ForgeBridgeService {
521
800
  return;
522
801
  this.sandboxMaintenanceRunning = true;
523
802
  try {
524
- const preflight = await this.preflight.refresh();
525
- if (canReapManagedSandboxes(preflight)) {
526
- const attemptSnapshot = await this.readAttemptSnapshot();
527
- await this.sandboxRuntime.reap({ diskLow: preflight.diskLow, attemptSnapshot });
528
- await this.flushCleanupReports();
803
+ const hostReap = await this.sessions.reapHostRuntime?.({
804
+ runnerId: this.config.runnerId,
805
+ runnerInstanceId: this.config.runnerInstanceId,
806
+ });
807
+ if (hostReap && (hostReap.reaped > 0 || hostReap.degraded > 0)) {
808
+ logBridgeStatus(`Managed-host maintenance reaped ${hostReap.reaped} stale attempt(s) and left ${hostReap.degraded} degraded attempt(s) for reconciliation.`);
809
+ }
810
+ if (this.config.dockerEnabled) {
811
+ const preflight = await this.preflight.refresh();
812
+ if (canReapManagedSandboxes(preflight)) {
813
+ const attemptSnapshot = await this.readAttemptSnapshot();
814
+ await this.sandboxRuntime.reap({ diskLow: preflight.diskLow, attemptSnapshot });
815
+ await this.flushCleanupReports();
816
+ }
529
817
  }
530
818
  }
531
819
  finally {
@@ -556,6 +844,11 @@ export class ForgeBridgeService {
556
844
  }
557
845
  }
558
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();
559
852
  await this.invokeAck("Heartbeat", {
560
853
  status: "online",
561
854
  capabilities: this.capabilities(),
@@ -568,7 +861,19 @@ export class ForgeBridgeService {
568
861
  const runtimeState = this.sandboxRuntime.snapshot();
569
862
  const reserved = [...this.reservations.values()].filter(reservation => reservation.sandboxed).length;
570
863
  const used = this.usedSandboxResources(runtimeState.degraded);
571
- const capacity = this.sandboxResourceCapacity(preflight);
864
+ const activeResources = this.activeSandboxResources(runtimeState.degraded);
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;
572
877
  return {
573
878
  schemaVersion: 3,
574
879
  activeSessionCount: this.activeSessions.size,
@@ -580,7 +885,7 @@ export class ForgeBridgeService {
580
885
  sandboxProfiles: this.config.sandboxProfiles,
581
886
  runnerInstanceId: this.config.runnerInstanceId,
582
887
  trustDomainId: this.config.trustDomainId,
583
- maxConcurrentSandboxes: preflight?.maxConcurrentSandboxes ?? 0,
888
+ maxConcurrentSandboxes: capacity.sandboxSlots,
584
889
  reservedSandboxCount: reserved,
585
890
  activeSandboxCount: runtimeState.active,
586
891
  startingSandboxCount: runtimeState.starting,
@@ -588,15 +893,30 @@ export class ForgeBridgeService {
588
893
  sandboxRuntimes: preflight?.healthy ? ["docker"] : [],
589
894
  sandboxRuntimeVersion: preflight?.runtimeVersion ?? null,
590
895
  sandboxFeatures: preflight?.features ?? null,
896
+ allowsOpenPublicSandbox: this.config.allowOpenPublicSandbox,
591
897
  sandboxPublicationModes: [this.config.sandboxPublicationMode ?? "shared_filesystem"],
592
898
  sandboxAuthModes: sandboxAuthModesFor(this.config),
593
- availableMemoryBytes: preflight ? Math.max(0, capacity.memoryBytes - used.memoryBytes) : null,
899
+ availableMemoryBytes: memoryAdmission?.availableForAdmissionBytes ?? null,
594
900
  availableCpuMillis: preflight ? Math.max(0, capacity.cpuMillis - used.cpuMillis) : null,
595
901
  availableWorkspaceBytes: preflight ? Math.max(0, capacity.workspaceBytes - used.workspaceBytes) : null,
596
902
  diskLow: preflight?.diskLow ?? null,
597
903
  preflightGeneration: preflight?.generation ?? 0,
598
904
  sandboxUnavailableCode: this.config.dockerEnabled ? preflight?.unavailableCode ?? null : "docker_disabled",
599
905
  sandboxUnavailableReason: this.config.dockerEnabled ? preflight?.unavailableReason ?? null : "Docker sandboxing is disabled by runner configuration.",
906
+ sandboxMemoryCapacity: preflight ? {
907
+ defaultWorkerMemoryLimitBytes: this.config.defaultSandboxWorkerMemoryLimitBytes,
908
+ defaultServiceMemoryLimitBytes: this.config.defaultSandboxServiceMemoryLimitBytes,
909
+ runtimeOverheadBytes: this.config.sandboxRuntimeOverheadBytes,
910
+ hostReserveBytes: preflight.hostReserveBytes,
911
+ activeReservationBytes: used.memoryBytes,
912
+ daemonTotalBytes: preflight.totalMemoryBytes,
913
+ daemonAvailableBytes: preflight.rawDaemonAvailableMemoryBytes ?? 0,
914
+ hostAvailableBytes: preflight.rawHostAvailableMemoryBytes,
915
+ conservativeAvailableBytes: preflight.rawAvailableMemoryBytes,
916
+ availableForAdmissionBytes: memoryAdmission.availableForAdmissionBytes,
917
+ memoryAdmission: memoryAdmission,
918
+ } : null,
919
+ hostRuntime: this.sessions.runtimeCapacity?.() ?? null,
600
920
  supportsApiSecretGrant: this.config.supportsApiSecretGrant,
601
921
  supportsManagedWorktrees: this.config.supportsManagedWorktrees,
602
922
  supportsManagedForgeArtifacts: this.config.supportsManagedForgeArtifacts,
@@ -618,34 +938,116 @@ export class ForgeBridgeService {
618
938
  ];
619
939
  return sumSandboxResources(resources);
620
940
  }
621
- sandboxResourceCapacity(preflight) {
941
+ activeSandboxResources(degradedReservations) {
942
+ return sumSandboxResources([
943
+ ...[...this.activeSessions.values()]
944
+ .filter(session => session.sandboxed)
945
+ .map(session => session.resources),
946
+ ...Array.from({ length: degradedReservations }, () => defaultSandboxResources())
947
+ ]);
948
+ }
949
+ sandboxResourceCapacity(preflight, activeResources, usedResources) {
622
950
  if (!preflight)
623
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
+ }
624
958
  return {
625
- sandboxSlots: preflight.maxConcurrentSandboxes,
959
+ // Host free memory and disk already reflect active containers. Add their
960
+ // commitments back to form a stable total capacity, then subtract them
961
+ // exactly once in usedSandboxResources.
962
+ sandboxSlots: Math.min(this.config.maxConcurrentSandboxes, preflight.maxConcurrentSandboxes + activeResources.sandboxSlots),
626
963
  cpuMillis: preflight.availableCpuMillis,
627
- memoryBytes: Math.max(0, preflight.availableMemoryBytes - 2 * 1024 ** 3),
628
- workspaceBytes: Math.max(0, preflight.availableWorkspaceBytes - this.config.minimumWorkspaceBytes),
964
+ workerMemoryLimitBytes: 0,
965
+ serviceMemoryLimitBytes: 0,
966
+ runtimeOverheadBytes: 0,
967
+ memoryBytes: sandboxMemoryCommitmentCapacity(preflight, activeResources.memoryBytes, this.sandboxAmbientMemoryCommitmentBytes),
968
+ workspaceBytes: Math.max(0, preflight.availableWorkspaceBytes
969
+ - this.config.minimumWorkspaceBytes
970
+ + activeResources.workspaceBytes),
629
971
  pids: this.config.sandboxPidCapacity,
630
972
  buildSlots: this.config.maxConcurrentSandboxBuilds
631
973
  };
632
974
  }
633
975
  async invokeAck(methodName, payload) {
634
- const connection = this.connection;
635
- if (!connection) {
636
- 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
+ }
637
1008
  }
638
- const ack = await connection.invoke(methodName, payload);
639
- if (!ack.accepted) {
640
- 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;
641
1039
  }
642
- 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;
643
1045
  }
644
1046
  async waitForClose(signal) {
645
1047
  if (!this.closed) {
646
1048
  return;
647
1049
  }
648
- await Promise.race([this.closed, abortPromise(signal)]);
1050
+ await waitForPromiseOrAbort(this.closed, signal);
649
1051
  }
650
1052
  async stopConnection() {
651
1053
  this.stopHeartbeat();
@@ -671,6 +1073,17 @@ function isDispatchNoLongerPending(error) {
671
1073
  }
672
1074
  return error instanceof Error && error.message === "Dispatch is no longer pending.";
673
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
+ }
1081
+ function createDefaultSessionService(sandboxStateRoot) {
1082
+ const rawProcessRunner = new StreamingProviderProcessRunner();
1083
+ const processSupervisors = new AttemptProcessSupervisorRegistry();
1084
+ const coordinatedProcessRunner = new AttemptCoordinatedProviderProcessRunner(processSupervisors, rawProcessRunner);
1085
+ return new BridgeSessionService(new GitWorktreeService(undefined, new RepositoryCoordinatorLock(sandboxStateRoot)), new RoutingProviderCliService(new ProcessBackedProviderCliService(coordinatedProcessRunner)), new HostRuntimeLeaseManager(sandboxStateRoot, undefined, undefined, processSupervisors, rawProcessRunner));
1086
+ }
674
1087
  export class SignalRBridgeConnectionFactory {
675
1088
  create(config) {
676
1089
  return new SignalRBridgeConnection(config);
@@ -693,10 +1106,11 @@ export class SignalRBridgeConnection {
693
1106
  Authorization: `Bearer ${config.deviceToken}`
694
1107
  }
695
1108
  })
1109
+ .withAutomaticReconnect([...config.reconnectBackoffMs])
696
1110
  .configureLogging(LogLevel.Warning)
697
1111
  .build();
698
1112
  this.connection.on("Superseded", () => {
699
- void this.connection.stop();
1113
+ void this.connection.stop().catch(() => undefined);
700
1114
  });
701
1115
  }
702
1116
  start() {
@@ -708,6 +1122,14 @@ export class SignalRBridgeConnection {
708
1122
  onClose(handler) {
709
1123
  this.connection.onclose(handler);
710
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
+ }
711
1133
  onDispatch(handler) {
712
1134
  this.connection.on("Dispatch", (raw) => {
713
1135
  void handler(raw).catch(() => undefined);
@@ -738,6 +1160,11 @@ export class SignalRBridgeConnection {
738
1160
  void handler(request).catch(() => undefined);
739
1161
  });
740
1162
  }
1163
+ onReadinessProbe(handler) {
1164
+ this.connection.on("ReadinessProbe", (request) => {
1165
+ void handler(request).catch(() => undefined);
1166
+ });
1167
+ }
741
1168
  invoke(methodName, payload) {
742
1169
  return this.connection.invoke(methodName, payload);
743
1170
  }
@@ -745,6 +1172,7 @@ export class SignalRBridgeConnection {
745
1172
  export function defaultBridgeServiceConfig(overrides = {}) {
746
1173
  const defaults = defaultForgeBridgeConfig();
747
1174
  const cliProviders = splitCsv(process.env.FORGE_CLI_PROVIDERS ?? "codex");
1175
+ const configuredSandboxHostMemoryReserveBytes = optionalPositiveFiniteNumber(process.env.FORGE_SANDBOX_HOST_MEMORY_RESERVE_BYTES);
748
1176
  const sandboxPublicationMode = process.env.FORGE_SANDBOX_PUBLICATION_MODE === "bridge_push" ? "bridge_push" : "shared_filesystem";
749
1177
  const config = {
750
1178
  serverUrl: process.env.FORGE_URL ?? "http://localhost:5208",
@@ -755,6 +1183,7 @@ export function defaultBridgeServiceConfig(overrides = {}) {
755
1183
  maxConcurrentSandboxBuilds: positiveFiniteNumber(Number(process.env.FORGE_MAX_CONCURRENT_SANDBOX_BUILDS ?? 1), 1),
756
1184
  sandboxPidCapacity: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_PID_CAPACITY ?? 2048), 2048),
757
1185
  dockerEnabled: process.env.FORGE_DOCKER_SANDBOX !== "false",
1186
+ allowOpenPublicSandbox: process.env.FORGE_ALLOW_OPEN_PUBLIC_SANDBOX === "true",
758
1187
  sandboxPublicationMode,
759
1188
  dockerExecutable: process.env.FORGE_DOCKER_EXECUTABLE ?? "docker",
760
1189
  dockerContext: process.env.FORGE_DOCKER_CONTEXT ?? "",
@@ -763,6 +1192,13 @@ export function defaultBridgeServiceConfig(overrides = {}) {
763
1192
  diskHighWatermarkPercent: positiveFiniteNumber(Number(process.env.FORGE_DISK_HIGH_WATERMARK_PERCENT ?? 85), 85),
764
1193
  diskLowWatermarkPercent: positiveFiniteNumber(Number(process.env.FORGE_DISK_LOW_WATERMARK_PERCENT ?? 75), 75),
765
1194
  minimumWorkspaceBytes: positiveFiniteNumber(Number(process.env.FORGE_MINIMUM_WORKSPACE_BYTES ?? 10 * 1024 ** 3), 10 * 1024 ** 3),
1195
+ // Undefined means preflight derives the bounded reserve from the actual
1196
+ // Docker/host capacity domain after sampling it.
1197
+ sandboxHostMemoryReserveBytes: configuredSandboxHostMemoryReserveBytes,
1198
+ defaultSandboxWorkerMemoryLimitBytes: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_DEFAULT_WORKER_MEMORY_LIMIT_BYTES ?? 2 * 1024 ** 3), 2 * 1024 ** 3),
1199
+ defaultSandboxServiceMemoryLimitBytes: nonNegativeFiniteNumber(Number(process.env.FORGE_SANDBOX_DEFAULT_SERVICE_MEMORY_LIMIT_BYTES ?? 0), 0),
1200
+ sandboxRuntimeOverheadBytes: Math.max(128 * 1024 ** 2, nonNegativeFiniteNumber(Number(process.env.FORGE_SANDBOX_RUNTIME_OVERHEAD_BYTES ?? 128 * 1024 ** 2), 128 * 1024 ** 2)),
1201
+ sandboxMemorySampleIntervalMs: Math.min(30_000, Math.max(250, positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_MEMORY_SAMPLE_INTERVAL_MS ?? 1000), 1000))),
766
1202
  imageRetentionCount: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_IMAGE_RETENTION_COUNT ?? 3), 3),
767
1203
  imageRetentionTtlMs: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_IMAGE_RETENTION_TTL_MS ?? 7 * 24 * 60 * 60 * 1000), 7 * 24 * 60 * 60 * 1000),
768
1204
  auditRetentionCount: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_AUDIT_RETENTION_COUNT ?? 200), 200),
@@ -811,6 +1247,15 @@ function normalizeServerUrl(serverUrl) {
811
1247
  function positiveFiniteNumber(value, fallback) {
812
1248
  return Number.isFinite(value) && value > 0 ? value : fallback;
813
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
+ }
1256
+ function nonNegativeFiniteNumber(value, fallback) {
1257
+ return Number.isFinite(value) && value >= 0 ? value : fallback;
1258
+ }
814
1259
  function providerStatusesFor(providers) {
815
1260
  return Object.fromEntries(providers.map((provider) => [provider, { signedIn: true, version: "", models: configuredModelsFor(provider) }]));
816
1261
  }
@@ -932,10 +1377,11 @@ function normalizeQaAppLaunchTarget(target) {
932
1377
  command: cleanString(target.command),
933
1378
  working_directory: cleanString(target.working_directory),
934
1379
  ready_url: cleanString(target.ready_url),
1380
+ managed_runtime_port: cleanString(target.managed_runtime_port),
935
1381
  routes,
936
1382
  notes: cleanString(target.notes)
937
1383
  };
938
- return normalized.url || normalized.command || normalized.ready_url || routes.length > 0
1384
+ return normalized.url || normalized.command || normalized.ready_url || normalized.managed_runtime_port
939
1385
  ? normalized
940
1386
  : null;
941
1387
  }
@@ -980,36 +1426,49 @@ function formatDelay(ms) {
980
1426
  function logBridgeStatus(message) {
981
1427
  process.stderr.write(`[${new Date().toISOString()}] ${message}\n`);
982
1428
  }
983
- function delay(ms, signal) {
1429
+ export function delay(ms, signal) {
984
1430
  if (signal?.aborted) {
985
1431
  return Promise.resolve();
986
1432
  }
987
1433
  return new Promise((resolve) => {
988
- const timeout = setTimeout(resolve, ms);
989
- signal?.addEventListener("abort", () => {
1434
+ const complete = () => {
990
1435
  clearTimeout(timeout);
1436
+ signal?.removeEventListener("abort", complete);
991
1437
  resolve();
992
- }, { once: true });
1438
+ };
1439
+ const timeout = setTimeout(complete, ms);
1440
+ signal?.addEventListener("abort", complete, { once: true });
1441
+ if (signal?.aborted)
1442
+ complete();
993
1443
  });
994
1444
  }
995
- function abortPromise(signal) {
1445
+ export async function waitForPromiseOrAbort(promise, signal) {
996
1446
  if (!signal) {
997
- return new Promise(() => undefined);
1447
+ await promise;
1448
+ return;
998
1449
  }
999
1450
  if (signal.aborted) {
1000
- 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);
1001
1461
  }
1002
- return new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
1003
1462
  }
1004
- function canReapManagedSandboxes(preflight) {
1005
- return preflight.healthy || preflight.unavailableCode === "docker_disk_low";
1463
+ export function canReapManagedSandboxes(preflight) {
1464
+ return canRunDockerMaintenance(preflight);
1006
1465
  }
1007
- function sandboxNetworkPolicyFailure(dispatch) {
1466
+ function sandboxNetworkPolicyFailure(dispatch, config) {
1008
1467
  const sandbox = dispatch.sandbox;
1009
1468
  if (!sandbox)
1010
1469
  return "Protocol-v2 sandbox context is missing.";
1011
1470
  if (sandbox.networkPolicy.mode === "open_public"
1012
- && (sandbox.executionPolicy === "sandbox_required" || process.env.FORGE_ALLOW_OPEN_PUBLIC_SANDBOX !== "true")) {
1471
+ && (sandbox.executionPolicy === "sandbox_required" || !config.allowOpenPublicSandbox)) {
1013
1472
  return "open_public sandbox networking is not permitted by this runner policy.";
1014
1473
  }
1015
1474
  if (sandbox.networkPolicy.mode === "public_allowlist"
@@ -1019,6 +1478,66 @@ function sandboxNetworkPolicyFailure(dispatch) {
1019
1478
  }
1020
1479
  return null;
1021
1480
  }
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;
1485
+ if (summary.startsWith("worktree_prepare_failed:"))
1486
+ return "worktree_prepare_failed";
1487
+ if (summary.startsWith("provider_network_unavailable:"))
1488
+ return "provider_network_unavailable";
1489
+ if (summary.startsWith("provider_start_unavailable:"))
1490
+ return "provider_start_unavailable";
1491
+ if (summary.startsWith("host_port_collision:"))
1492
+ return "host_port_collision";
1493
+ if (summary.startsWith("host_runtime_process_exited:"))
1494
+ return "host_runtime_process_exited";
1495
+ if (summary.startsWith("host_runtime_listener_timeout:"))
1496
+ return "host_runtime_listener_timeout";
1497
+ if (summary.startsWith("host_runtime_http_readiness_timeout:"))
1498
+ return "host_runtime_http_readiness_timeout";
1499
+ if (summary.startsWith("attempt_process_supervisor_start_failed:"))
1500
+ return "attempt_process_supervisor_start_failed";
1501
+ if (summary.startsWith("attempt_process_supervisor_start_timeout:"))
1502
+ return "attempt_process_supervisor_start_timeout";
1503
+ return null;
1504
+ }
1505
+ function parseReadinessProbe(value) {
1506
+ if (!value || typeof value !== "object" || Array.isArray(value))
1507
+ return null;
1508
+ const request = value;
1509
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
1510
+ const bounded = (candidate, maximum) => typeof candidate === "string" && candidate.trim().length > 0 && candidate.length <= maximum;
1511
+ if (request.schemaVersion !== 1
1512
+ || !uuid.test(request.requestId ?? "")
1513
+ || !uuid.test(request.fencingToken ?? "")
1514
+ || !uuid.test(request.userId ?? "")
1515
+ || !uuid.test(request.projectId ?? "")
1516
+ || !uuid.test(request.repoId ?? "")
1517
+ || !uuid.test(request.runnerInstanceId ?? "")
1518
+ || !Number.isInteger(request.attempt) || (request.attempt ?? 0) < 1
1519
+ || !bounded(request.repoName, 160)
1520
+ || typeof request.remoteUrl !== "string" || request.remoteUrl.length > 2048
1521
+ || !bounded(request.defaultBranch, 512)
1522
+ || typeof request.cloneOnDemand !== "boolean"
1523
+ || !bounded(request.runnerRepoLocalPath, 4096)
1524
+ || !bounded(request.revision, 512)
1525
+ || !Number.isFinite(Date.parse(request.expiresAt ?? ""))
1526
+ || Date.parse(request.expiresAt ?? "") <= Date.now()) {
1527
+ return null;
1528
+ }
1529
+ return request;
1530
+ }
1531
+ function sandboxProviderNetworkFailure(dispatch, config) {
1532
+ const sandbox = dispatch.sandbox;
1533
+ if (!sandbox || sandbox.networkPolicy.mode !== "offline")
1534
+ return null;
1535
+ const provider = dispatch.execution.provider.trim().toLowerCase();
1536
+ const isNetworkProvider = config.cliProviders.some(candidate => candidate.trim().toLowerCase() === provider);
1537
+ if (!isNetworkProvider)
1538
+ return null;
1539
+ return `Provider ${dispatch.execution.provider} requires outbound network access, but this Docker attempt uses the offline network policy.`;
1540
+ }
1022
1541
  function sandboxAuthFailure(dispatch, config) {
1023
1542
  const unsupported = sandboxProviderAuthUnavailableMessage(dispatch.execution.provider, dispatch.execution.authKind);
1024
1543
  if (unsupported) {
@@ -1038,6 +1557,15 @@ function sandboxAuthFailure(dispatch, config) {
1038
1557
  const doctor = adapter.doctor();
1039
1558
  return doctor.available ? null : doctor.message;
1040
1559
  }
1560
+ function rejectedOfferReservation(code, message) {
1561
+ return {
1562
+ accepted: false,
1563
+ code,
1564
+ message,
1565
+ reservedUntil: null,
1566
+ resources: null,
1567
+ };
1568
+ }
1041
1569
  export function canReserveSandboxResources(used, requested, capacity) {
1042
1570
  return used.sandboxSlots + requested.sandboxSlots <= capacity.sandboxSlots
1043
1571
  && used.cpuMillis + requested.cpuMillis <= capacity.cpuMillis
@@ -1046,10 +1574,52 @@ export function canReserveSandboxResources(used, requested, capacity) {
1046
1574
  && used.pids + requested.pids <= capacity.pids
1047
1575
  && used.buildSlots + requested.buildSlots <= capacity.buildSlots;
1048
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
+ }
1586
+ export function sandboxCapacityFailureMessage(used, requested, capacity, preflight, config) {
1587
+ const availableSlots = Math.max(0, capacity.sandboxSlots - used.sandboxSlots);
1588
+ if (requested.sandboxSlots > availableSlots) {
1589
+ return `Sandbox slot capacity is insufficient: requested ${requested.sandboxSlots}, available ${availableSlots}, ${used.sandboxSlots} already reserved.`;
1590
+ }
1591
+ const availableMemoryBytes = Math.max(0, capacity.memoryBytes - used.memoryBytes);
1592
+ if (requested.memoryBytes > availableMemoryBytes) {
1593
+ return [
1594
+ `Sandbox memory capacity is insufficient: requested ${formatBytes(requested.memoryBytes)} total`,
1595
+ `(worker ${formatBytes(requested.workerMemoryLimitBytes)}, services ${formatBytes(requested.serviceMemoryLimitBytes)}, runtime overhead ${formatBytes(requested.runtimeOverheadBytes)}),`,
1596
+ `but ${formatBytes(availableMemoryBytes)} is available for admission.`,
1597
+ `Docker reports ${formatBytes(preflight?.availableMemoryBytes ?? 0)} available of ${formatBytes(preflight?.totalMemoryBytes ?? 0)};`,
1598
+ `Forge reserves ${formatBytes(preflight?.hostReserveBytes ?? config.sandboxHostMemoryReserveBytes ?? 0)} for the host and tracks ${formatBytes(used.memoryBytes)} in active or reserved attempts.`,
1599
+ ].join(" ");
1600
+ }
1601
+ const availableCpuMillis = Math.max(0, capacity.cpuMillis - used.cpuMillis);
1602
+ if (requested.cpuMillis > availableCpuMillis) {
1603
+ return `Sandbox CPU capacity is insufficient: requested ${requested.cpuMillis} millis, available ${availableCpuMillis} millis, ${used.cpuMillis} millis already reserved.`;
1604
+ }
1605
+ const availableWorkspaceBytes = Math.max(0, capacity.workspaceBytes - used.workspaceBytes);
1606
+ if (requested.workspaceBytes > availableWorkspaceBytes) {
1607
+ return `Sandbox workspace capacity is insufficient: requested ${formatBytes(requested.workspaceBytes)}, available ${formatBytes(availableWorkspaceBytes)}, ${formatBytes(used.workspaceBytes)} already reserved.`;
1608
+ }
1609
+ const availablePids = Math.max(0, capacity.pids - used.pids);
1610
+ if (requested.pids > availablePids) {
1611
+ return `Sandbox PID capacity is insufficient: requested ${requested.pids}, available ${availablePids}, ${used.pids} already reserved.`;
1612
+ }
1613
+ const availableBuildSlots = Math.max(0, capacity.buildSlots - used.buildSlots);
1614
+ return `Sandbox build capacity is insufficient: requested ${requested.buildSlots}, available ${availableBuildSlots}, ${used.buildSlots} already reserved.`;
1615
+ }
1049
1616
  function sumSandboxResources(resources) {
1050
1617
  return resources.reduce((total, resource) => ({
1051
1618
  sandboxSlots: total.sandboxSlots + resource.sandboxSlots,
1052
1619
  cpuMillis: total.cpuMillis + resource.cpuMillis,
1620
+ workerMemoryLimitBytes: total.workerMemoryLimitBytes + resource.workerMemoryLimitBytes,
1621
+ serviceMemoryLimitBytes: total.serviceMemoryLimitBytes + resource.serviceMemoryLimitBytes,
1622
+ runtimeOverheadBytes: total.runtimeOverheadBytes + resource.runtimeOverheadBytes,
1053
1623
  memoryBytes: total.memoryBytes + resource.memoryBytes,
1054
1624
  workspaceBytes: total.workspaceBytes + resource.workspaceBytes,
1055
1625
  pids: total.pids + resource.pids,
@@ -1057,18 +1627,91 @@ function sumSandboxResources(resources) {
1057
1627
  }), zeroSandboxResources());
1058
1628
  }
1059
1629
  function zeroSandboxResources() {
1060
- return { sandboxSlots: 0, cpuMillis: 0, memoryBytes: 0, workspaceBytes: 0, pids: 0, buildSlots: 0 };
1630
+ return {
1631
+ sandboxSlots: 0,
1632
+ cpuMillis: 0,
1633
+ workerMemoryLimitBytes: 0,
1634
+ serviceMemoryLimitBytes: 0,
1635
+ runtimeOverheadBytes: 0,
1636
+ memoryBytes: 0,
1637
+ workspaceBytes: 0,
1638
+ pids: 0,
1639
+ buildSlots: 0,
1640
+ };
1061
1641
  }
1062
1642
  function defaultSandboxResources() {
1063
1643
  return {
1064
1644
  sandboxSlots: 1,
1065
1645
  cpuMillis: 2000,
1066
- memoryBytes: 2 * 1024 ** 3,
1646
+ workerMemoryLimitBytes: 2 * 1024 ** 3,
1647
+ serviceMemoryLimitBytes: 0,
1648
+ runtimeOverheadBytes: 128 * 1024 ** 2,
1649
+ memoryBytes: 2 * 1024 ** 3 + 128 * 1024 ** 2,
1067
1650
  workspaceBytes: 10 * 1024 ** 3,
1068
1651
  pids: 512,
1069
1652
  buildSlots: 0
1070
1653
  };
1071
1654
  }
1655
+ export function resolveDynamicSandboxResources(requested, serviceCount, networkMode, baseRuntimeOverheadBytes = 128 * 1024 ** 2) {
1656
+ const serviceMemoryLimitBytes = Math.max(0, serviceCount) * 1024 ** 3;
1657
+ const runtimeOverheadBytes = Math.max(128 * 1024 ** 2, baseRuntimeOverheadBytes)
1658
+ + (networkMode === "public_allowlist" ? 256 * 1024 ** 2 : 0);
1659
+ return {
1660
+ ...requested,
1661
+ serviceMemoryLimitBytes,
1662
+ runtimeOverheadBytes,
1663
+ memoryBytes: requested.workerMemoryLimitBytes + serviceMemoryLimitBytes + runtimeOverheadBytes,
1664
+ };
1665
+ }
1666
+ export async function resolveSandboxResourcesFromRepo(dispatch, baseRuntimeOverheadBytes = 128 * 1024 ** 2) {
1667
+ const repoPath = dispatch.repo.serverLocalPath;
1668
+ const sandbox = dispatch.sandbox;
1669
+ if (!repoPath || !sandbox) {
1670
+ throw new Error("Sandbox admission needs a mapped repository.");
1671
+ }
1672
+ const selection = selectSandboxConfigRefs(sandbox.executionPolicy, sandbox.sandboxConfigCommitSha, dispatch.repo.defaultBranch);
1673
+ const configCommitSha = await resolveFirstGitCommit(repoPath, selection.refs);
1674
+ if (!configCommitSha) {
1675
+ throw new Error("Sandbox admission could not resolve the immutable sandbox config commit.");
1676
+ }
1677
+ const contractResult = await runSandboxProcess({
1678
+ command: "git",
1679
+ args: ["-C", repoPath, "show", `${configCommitSha}:.forge/forge.sandbox.yml`],
1680
+ timeoutMs: 30_000,
1681
+ maxOutputBytes: 1024 * 1024,
1682
+ });
1683
+ if (contractResult.exitCode !== 0) {
1684
+ throw new Error("The approved immutable sandbox contract is unavailable in the mapped repository.");
1685
+ }
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
+ });
1692
+ return {
1693
+ resources: resolveDynamicSandboxResources({
1694
+ ...sandbox.requestedResources,
1695
+ workspaceBytes: Math.max(sandbox.requestedResources.workspaceBytes, workspaceBytes),
1696
+ }, contract.contract.services.length, sandbox.networkPolicy.mode, baseRuntimeOverheadBytes),
1697
+ sandboxConfigCommitSha: configCommitSha,
1698
+ };
1699
+ }
1700
+ async function resolveFirstGitCommit(repoPath, refs) {
1701
+ for (const ref of refs) {
1702
+ const result = await runSandboxProcess({
1703
+ command: "git",
1704
+ args: ["-C", repoPath, "rev-parse", "--verify", `${ref}^{commit}`],
1705
+ timeoutMs: 30_000,
1706
+ maxOutputBytes: 1024 * 1024,
1707
+ });
1708
+ const commitSha = result.stdout.trim().toLowerCase();
1709
+ if (result.exitCode === 0 && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u.test(commitSha)) {
1710
+ return commitSha;
1711
+ }
1712
+ }
1713
+ return null;
1714
+ }
1072
1715
  function parseOfferEnvelope(value) {
1073
1716
  return parseDispatchEnvelope(value);
1074
1717
  }