@loucompanion/forge-bridge 0.1.3-dev.d46a8e73d03d → 0.1.3-dev.e7b62d8fdd33

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 (145) hide show
  1. package/README.md +83 -0
  2. package/dist/bridge/bridge.base.d.ts +4 -1
  3. package/dist/bridge/bridge.service.d.ts +55 -3
  4. package/dist/bridge/bridge.service.js +834 -87
  5. package/dist/bridge/bridge.types.d.ts +89 -1
  6. package/dist/bridge/runner-host-capabilities.d.ts +15 -0
  7. package/dist/bridge/runner-host-capabilities.js +93 -0
  8. package/dist/cleanup/cleanup.service.d.ts +14 -2
  9. package/dist/cleanup/cleanup.service.js +496 -3
  10. package/dist/cleanup/cleanup.types.d.ts +10 -0
  11. package/dist/cleanup/index.d.ts +1 -1
  12. package/dist/cleanup/index.js +1 -1
  13. package/dist/cleanup/internals.d.ts +2 -2
  14. package/dist/cleanup/internals.js +1 -1
  15. package/dist/cli/cli.help.js +37 -3
  16. package/dist/cli/cli.service.d.ts +33 -2
  17. package/dist/cli/cli.service.js +209 -27
  18. package/dist/cli/local-config.d.ts +5 -0
  19. package/dist/cli/local-config.js +45 -1
  20. package/dist/runtime/aspnet-runtime-prebuild.d.ts +36 -0
  21. package/dist/runtime/aspnet-runtime-prebuild.js +340 -0
  22. package/dist/runtime/attempt-node-runtime-launcher.d.ts +17 -0
  23. package/dist/runtime/attempt-node-runtime-launcher.js +105 -0
  24. package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
  25. package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
  26. package/dist/runtime/attempt-process-supervisor.d.ts +97 -0
  27. package/dist/runtime/attempt-process-supervisor.js +386 -0
  28. package/dist/runtime/docker-engine-probe.d.ts +83 -0
  29. package/dist/runtime/docker-engine-probe.js +300 -0
  30. package/dist/runtime/generated-output-reset.d.ts +10 -0
  31. package/dist/runtime/generated-output-reset.js +103 -0
  32. package/dist/runtime/host-listener-inspector.d.ts +46 -0
  33. package/dist/runtime/host-listener-inspector.js +349 -0
  34. package/dist/runtime/host-port-broker.d.ts +76 -0
  35. package/dist/runtime/host-port-broker.js +333 -0
  36. package/dist/runtime/host-runtime-contract.d.ts +47 -0
  37. package/dist/runtime/host-runtime-contract.js +700 -0
  38. package/dist/runtime/host-runtime-lease.d.ts +197 -0
  39. package/dist/runtime/host-runtime-lease.js +2203 -0
  40. package/dist/runtime/host-runtime-promotion.d.ts +23 -0
  41. package/dist/runtime/host-runtime-promotion.js +159 -0
  42. package/dist/runtime/memory-capacity.d.ts +68 -0
  43. package/dist/runtime/memory-capacity.js +121 -0
  44. package/dist/runtime/node-host-runtime-adapter.d.ts +54 -0
  45. package/dist/runtime/node-host-runtime-adapter.js +284 -0
  46. package/dist/runtime/playwright-host-runtime-adapter.d.ts +21 -0
  47. package/dist/runtime/playwright-host-runtime-adapter.js +231 -0
  48. package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
  49. package/dist/runtime/runtime-isolation-lease.js +158 -0
  50. package/dist/runtime/workspace-resource-estimator.d.ts +17 -0
  51. package/dist/runtime/workspace-resource-estimator.js +174 -0
  52. package/dist/sandbox/preflight.d.ts +45 -1
  53. package/dist/sandbox/preflight.js +299 -34
  54. package/dist/sandbox/process.js +125 -40
  55. package/dist/sandbox/repository-lock.js +41 -3
  56. package/dist/sandbox/runtime.d.ts +66 -3
  57. package/dist/sandbox/runtime.js +670 -105
  58. package/dist/sandbox/sandbox.types.d.ts +10 -0
  59. package/dist/sandbox/smoke.d.ts +76 -0
  60. package/dist/sandbox/smoke.js +380 -0
  61. package/dist/sandbox/worker-supervisor.js +1 -1
  62. package/dist/sandbox/worker.js +2 -5
  63. package/dist/sandbox/workspace.service.d.ts +1 -0
  64. package/dist/sandbox/workspace.service.js +28 -5
  65. package/dist/session/managed-attempt-tools.d.ts +21 -0
  66. package/dist/session/managed-attempt-tools.js +912 -0
  67. package/dist/session/managed-build-coordinator.d.ts +5 -0
  68. package/dist/session/managed-build-coordinator.js +105 -0
  69. package/dist/session/managed-build-receipt.d.ts +21 -0
  70. package/dist/session/managed-build-receipt.js +335 -0
  71. package/dist/session/phase-completion.artifacts.d.ts +17 -0
  72. package/dist/session/phase-completion.artifacts.js +233 -24
  73. package/dist/session/phase-completion.d.ts +12 -2
  74. package/dist/session/phase-completion.helpers.d.ts +5 -0
  75. package/dist/session/phase-completion.helpers.js +51 -10
  76. package/dist/session/phase-completion.js +14 -3
  77. package/dist/session/phase-completion.prompts.d.ts +3 -3
  78. package/dist/session/phase-completion.prompts.js +24 -8
  79. package/dist/session/phase-completion.runtime.d.ts +13 -1
  80. package/dist/session/phase-completion.runtime.js +189 -8
  81. package/dist/session/phase-completion.schemas.js +117 -4
  82. package/dist/session/phase-completion.validation.d.ts +4 -1
  83. package/dist/session/phase-completion.validation.js +153 -22
  84. package/dist/session/pre-publication-validation.d.ts +46 -0
  85. package/dist/session/pre-publication-validation.js +208 -0
  86. package/dist/session/provider-cli/ci-repair-readiness.d.ts +16 -0
  87. package/dist/session/provider-cli/ci-repair-readiness.js +110 -0
  88. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +3 -2
  89. package/dist/session/provider-cli/provider-cli.artifacts.js +65 -7
  90. package/dist/session/provider-cli/provider-cli.deterministic.js +49 -1
  91. package/dist/session/provider-cli/provider-cli.process.d.ts +25 -0
  92. package/dist/session/provider-cli/provider-cli.process.js +463 -34
  93. package/dist/session/provider-cli/provider-cli.prompts.d.ts +4 -1
  94. package/dist/session/provider-cli/provider-cli.prompts.js +108 -25
  95. package/dist/session/provider-cli/provider-cli.qa-sidecars.js +1 -0
  96. package/dist/session/provider-cli/provider-cli.repair-epoch.d.ts +6 -0
  97. package/dist/session/provider-cli/provider-cli.repair-epoch.js +11 -0
  98. package/dist/session/provider-cli/provider-cli.runtime.d.ts +1 -1
  99. package/dist/session/provider-cli/provider-cli.runtime.js +6 -3
  100. package/dist/session/provider-cli/provider-cli.service.d.ts +22 -2
  101. package/dist/session/provider-cli/provider-cli.service.js +524 -100
  102. package/dist/session/provider-cli/provider-cli.types.d.ts +5 -0
  103. package/dist/session/provider-cli/provider-output-state.d.ts +2 -0
  104. package/dist/session/provider-cli/provider-output-state.js +91 -16
  105. package/dist/session/provider-cli/provider-usage.d.ts +7 -0
  106. package/dist/session/provider-cli/provider-usage.js +28 -5
  107. package/dist/session/session-redaction.d.ts +3 -0
  108. package/dist/session/session-redaction.js +38 -0
  109. package/dist/session/session.base.d.ts +6 -1
  110. package/dist/session/session.service.d.ts +8 -2
  111. package/dist/session/session.service.js +522 -41
  112. package/dist/session/session.types.d.ts +72 -0
  113. package/dist/session/session.types.js +74 -3
  114. package/dist/shared/format/format-bytes.d.ts +1 -0
  115. package/dist/shared/format/format-bytes.js +9 -0
  116. package/dist/shared/path/path.d.ts +1 -0
  117. package/dist/shared/path/path.js +21 -1
  118. package/dist/shared/process/dotnet-toolchain.d.ts +14 -0
  119. package/dist/shared/process/dotnet-toolchain.js +179 -0
  120. package/dist/shared/process/host-process-recovery.d.ts +12 -0
  121. package/dist/shared/process/host-process-recovery.js +78 -0
  122. package/dist/shared/process/host-process-supervisor.d.ts +59 -0
  123. package/dist/shared/process/host-process-supervisor.js +330 -0
  124. package/dist/shared/process/process.d.ts +1 -0
  125. package/dist/shared/process/process.js +1 -0
  126. package/dist/shared/redaction/redaction.js +12 -2
  127. package/dist/version.d.ts +1 -1
  128. package/dist/version.js +1 -1
  129. package/dist/worktree/artifact-injector.d.ts +3 -2
  130. package/dist/worktree/artifact-injector.js +43 -4
  131. package/dist/worktree/dependency-warmer.d.ts +28 -1
  132. package/dist/worktree/dependency-warmer.js +308 -9
  133. package/dist/worktree/index.d.ts +2 -1
  134. package/dist/worktree/index.js +1 -0
  135. package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
  136. package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
  137. package/dist/worktree/worktree-paths.d.ts +4 -0
  138. package/dist/worktree/worktree-paths.js +30 -0
  139. package/dist/worktree/worktree.base.d.ts +3 -0
  140. package/dist/worktree/worktree.service.d.ts +13 -2
  141. package/dist/worktree/worktree.service.js +238 -47
  142. package/dist/worktree/worktree.types.d.ts +5 -1
  143. package/package.json +5 -3
  144. package/runtime/windows-job-supervisor.ps1 +444 -0
  145. package/runtime/windows-listener-owner.ps1 +33 -0
@@ -5,32 +5,71 @@ 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 { DockerEngineProbe } from "../runtime/docker-engine-probe.js";
15
+ import { estimateWorkspaceBytesFromRepo } from "../runtime/workspace-resource-estimator.js";
16
+ import { GitWorktreeService } from "../worktree/worktree.service.js";
17
+ import { RepositoryCoordinatorLock } from "../sandbox/repository-lock.js";
18
+ import { AttemptCoordinatedProviderProcessRunner, AttemptProcessSupervisorRegistry, } from "../runtime/attempt-process-supervisor-registry.js";
10
19
  import { failureResult, parseRunOnceDispatchPacket } from "../session/session.types.js";
11
20
  import { forgeBridgeVersion } from "../version.js";
12
21
  import { canRunDockerMaintenance, DockerPreflight } from "../sandbox/preflight.js";
13
- import { DockerSandboxRuntime } from "../sandbox/runtime.js";
22
+ import { validateSandboxContract } from "../sandbox/contract.js";
23
+ import { runSandboxProcess } from "../sandbox/process.js";
24
+ import { DockerSandboxRuntime, selectSandboxConfigRefs } from "../sandbox/runtime.js";
14
25
  import { codexSandboxProviderAuthAdapter, sandboxProviderAuthAdapterFor, sandboxProviderAuthUnavailableMessage } from "../sandbox/provider-auth/codex.js";
26
+ import { formatBytes } from "../shared/format/format-bytes.js";
27
+ import { emptyRunnerHostCapabilitySnapshot, ProcessRunnerHostCapabilityProbe, } from "./runner-host-capabilities.js";
28
+ /**
29
+ * Protocol-v2 events are acknowledged one at a time before runOnce returns.
30
+ * Sending the complete transcript again in DispatchResult made long QA and CI
31
+ * sessions exceed SignalR's receive limit, disconnecting the bridge after the
32
+ * provider had already completed. The API combines its acknowledged streamed
33
+ * events with the terminal result, so the callback only needs the outcome and
34
+ * artifacts. Protocol v1 retains the transcript for legacy servers.
35
+ */
36
+ export function compactTerminalResult(dispatch, result) {
37
+ return dispatch.schemaVersion >= 2 && result.events.length > 0
38
+ ? { ...result, events: [] }
39
+ : result;
40
+ }
15
41
  export class ForgeBridgeService {
16
42
  config;
17
- sessions;
18
43
  connections;
44
+ hostCapabilityProbe;
19
45
  connection = null;
20
46
  heartbeatTimer = null;
47
+ heartbeatInFlight = false;
48
+ consecutiveHeartbeatFailures = 0;
49
+ reconnectingFromHeartbeatFailure = false;
21
50
  leaseWatchdogTimer = null;
22
51
  sandboxMaintenanceTimer = null;
23
52
  sandboxMaintenanceRunning = false;
24
53
  closed = null;
54
+ transportRecovery = null;
55
+ transportRecoveryGeneration = 0;
25
56
  stopped = false;
26
57
  activeSessions = new Map();
27
58
  reservations = new Map();
59
+ activeReadinessProbes = new Map();
28
60
  preflight;
61
+ dockerEngineProbe;
29
62
  sandboxRuntime;
30
- constructor(config, sessions = new BridgeSessionService(), connections = new SignalRBridgeConnectionFactory(), preflight, sandboxRuntime) {
63
+ sandboxResourceResolver;
64
+ sessions;
65
+ sandboxAmbientMemoryCommitmentBytes = null;
66
+ hostCapabilities = emptyRunnerHostCapabilitySnapshot();
67
+ hostCapabilitiesDetected = false;
68
+ constructor(config, sessions = undefined, connections = new SignalRBridgeConnectionFactory(), preflight, sandboxRuntime, sandboxResourceResolver, dockerEngineProbe, hostCapabilityProbe = new ProcessRunnerHostCapabilityProbe()) {
31
69
  this.config = config;
32
- this.sessions = sessions;
33
70
  this.connections = connections;
71
+ this.hostCapabilityProbe = hostCapabilityProbe;
72
+ this.sessions = sessions ?? createDefaultSessionService(config.sandboxStateRoot);
34
73
  this.preflight = preflight ?? new DockerPreflight({
35
74
  dockerExecutable: config.dockerExecutable,
36
75
  dockerContext: config.dockerContext,
@@ -40,47 +79,126 @@ export class ForgeBridgeService {
40
79
  diskHighWatermarkPercent: config.diskHighWatermarkPercent,
41
80
  diskLowWatermarkPercent: config.diskLowWatermarkPercent,
42
81
  minimumWorkspaceBytes: config.minimumWorkspaceBytes,
82
+ hostMemoryReserveBytes: config.sandboxHostMemoryReserveBytes,
83
+ defaultWorkerMemoryLimitBytes: config.defaultSandboxWorkerMemoryLimitBytes,
84
+ defaultServiceMemoryLimitBytes: config.defaultSandboxServiceMemoryLimitBytes,
85
+ runtimeOverheadBytes: config.sandboxRuntimeOverheadBytes,
43
86
  requireRootless: config.requireRootlessDocker
44
87
  });
88
+ this.dockerEngineProbe = dockerEngineProbe ?? new DockerEngineProbe({
89
+ dockerExecutable: config.dockerExecutable,
90
+ dockerContext: config.dockerContext,
91
+ recovery: {
92
+ enabled: config.dockerDesktopRecoveryEnabled,
93
+ failureThreshold: config.dockerRecoveryFailureThreshold,
94
+ cooldownMs: config.dockerRecoveryCooldownMs,
95
+ startupTimeoutMs: config.dockerRecoveryStartupTimeoutMs,
96
+ canRecover: () => this.activeSessions.size === 0 && this.reservations.size === 0,
97
+ },
98
+ onStatus: logBridgeStatus,
99
+ });
45
100
  this.sandboxRuntime = sandboxRuntime ?? new DockerSandboxRuntime(config, (request, signal) => this.redeemPublishGrant(request, signal));
101
+ this.sandboxResourceResolver = sandboxResourceResolver
102
+ ?? (dispatch => resolveSandboxResourcesFromRepo(dispatch, this.config.sandboxRuntimeOverheadBytes));
46
103
  }
47
104
  async connectOnce() {
48
105
  await this.stopConnection();
49
106
  this.stopped = false;
107
+ const hostReap = await this.sessions.reapHostRuntime?.({
108
+ runnerId: this.config.runnerId,
109
+ runnerInstanceId: this.config.runnerInstanceId,
110
+ });
111
+ if (hostReap && (hostReap.reaped > 0 || hostReap.degraded > 0)) {
112
+ 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.`);
113
+ }
50
114
  if (this.config.dockerEnabled) {
51
115
  const preflight = await this.preflight.refresh();
52
116
  if (canReapManagedSandboxes(preflight)) {
53
117
  await this.sandboxRuntime.reap({ diskLow: preflight.diskLow });
118
+ await this.preflight.refresh();
54
119
  }
55
120
  }
121
+ await this.dockerEngineProbe.refresh(true);
122
+ if (!this.hostCapabilitiesDetected) {
123
+ this.hostCapabilities = await this.hostCapabilityProbe.detect(this.config);
124
+ this.hostCapabilitiesDetected = true;
125
+ }
56
126
  const connection = this.connections.create(this.config);
57
127
  this.connection = connection;
128
+ this.transportRecovery = null;
58
129
  this.closed = new Promise((resolve) => {
59
- connection.onClose(() => {
130
+ connection.onClose((error) => {
60
131
  this.stopHeartbeat();
132
+ this.completeTransportRecovery(error ?? new Error("bridge connection closed"));
61
133
  this.abortActiveSessions("bridge connection closed");
134
+ this.abortActiveReadinessProbes("bridge connection closed");
62
135
  resolve();
63
136
  });
64
137
  });
138
+ connection.onReconnecting?.((error) => {
139
+ if (connection !== this.connection || this.stopped)
140
+ return;
141
+ this.stopHeartbeat();
142
+ this.beginTransportRecovery();
143
+ logBridgeStatus(`Forge connection interrupted${error ? `: ${errorMessage(error)}` : ""}. Reconnecting in place; active dispatches remain supervised...`);
144
+ });
145
+ connection.onReconnected?.(async () => {
146
+ if (connection !== this.connection || this.stopped)
147
+ return;
148
+ try {
149
+ await this.registerConnection(connection);
150
+ this.completeTransportRecovery();
151
+ this.startHeartbeat();
152
+ await this.flushCleanupReports().catch(() => undefined);
153
+ logBridgeStatus("Forge connection restored; active dispatches remain attached to this runner process.");
154
+ }
155
+ catch (error) {
156
+ this.completeTransportRecovery(error);
157
+ logBridgeStatus(`Forge reconnect registration failed: ${errorMessage(error)}. Closing the connection for a clean retry...`);
158
+ await connection.stop().catch(() => undefined);
159
+ }
160
+ });
65
161
  connection.onDispatch((dispatch) => this.handleDispatch(dispatch));
66
162
  connection.onOffer?.((dispatch) => this.handleOffer(dispatch));
67
163
  connection.onStart?.((dispatch) => this.handleStart(dispatch));
68
164
  connection.onLeaseUpdate?.((request) => this.handleLeaseUpdate(request));
69
165
  connection.onCancel((request) => this.handleCancel(request));
70
166
  connection.onReap?.((request) => this.handleReap(request));
167
+ connection.onReadinessProbe?.((request) => this.handleReadinessProbe(request));
71
168
  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
- });
169
+ await this.registerConnection(connection);
79
170
  await this.flushCleanupReports();
80
171
  this.startHeartbeat();
81
172
  this.startLeaseWatchdog();
82
173
  this.startSandboxMaintenance();
83
174
  }
175
+ async registerConnection(connection) {
176
+ let storageRecoveryAttempt = 0;
177
+ while (true) {
178
+ try {
179
+ const ack = await connection.invoke("Hello", {
180
+ version: this.config.version,
181
+ protocolVersion: this.config.protocolVersion,
182
+ minServerVersion: this.config.minServerVersion ?? null,
183
+ runnerInstanceId: this.config.runnerInstanceId,
184
+ capabilities: this.capabilities()
185
+ });
186
+ if (!ack.accepted) {
187
+ throw new BridgeCallbackRejectedError(ack.code ?? null, ack.message ?? ack.code ?? "Hello callback was rejected.");
188
+ }
189
+ return;
190
+ }
191
+ catch (error) {
192
+ if (!isProviderStorageUnavailable(error) || storageRecoveryAttempt >= this.config.reconnectBackoffMs.length) {
193
+ throw error;
194
+ }
195
+ const backoff = this.config.reconnectBackoffMs[storageRecoveryAttempt] ?? 5000;
196
+ storageRecoveryAttempt += 1;
197
+ logBridgeStatus(`Hello reached Forge while storage was recovering. Retrying registration ${storageRecoveryAttempt} in ${formatDelay(backoff)}...`);
198
+ await delay(backoff);
199
+ }
200
+ }
201
+ }
84
202
  async run(signal) {
85
203
  let attempt = 0;
86
204
  try {
@@ -110,6 +228,7 @@ export class ForgeBridgeService {
110
228
  finally {
111
229
  if (signal?.aborted) {
112
230
  this.abortActiveSessions(String(signal.reason ?? "bridge stopping"));
231
+ this.abortActiveReadinessProbes(String(signal.reason ?? "bridge stopping"));
113
232
  }
114
233
  await this.stopConnection();
115
234
  }
@@ -117,6 +236,7 @@ export class ForgeBridgeService {
117
236
  async stop() {
118
237
  this.stopped = true;
119
238
  this.abortActiveSessions("bridge stopping");
239
+ this.abortActiveReadinessProbes("bridge stopping");
120
240
  await this.stopConnection();
121
241
  await this.preflight.release();
122
242
  }
@@ -125,6 +245,16 @@ export class ForgeBridgeService {
125
245
  if (!dispatch) {
126
246
  return;
127
247
  }
248
+ const capabilityFailure = await this.requiredCapabilityFailure(dispatch);
249
+ if (capabilityFailure) {
250
+ await this.sendResult(dispatch, failureResult(capabilityFailure, [{
251
+ role: "runner",
252
+ kind: "error",
253
+ content: capabilityFailure,
254
+ eventType: "docker_engine_unavailable",
255
+ }])).catch(() => undefined);
256
+ return;
257
+ }
128
258
  const leaseId = dispatch.lease.id;
129
259
  const fencingToken = dispatch.lease.fencingToken;
130
260
  const redactionSecrets = exactSecrets(dispatch, this.config);
@@ -174,7 +304,7 @@ export class ForgeBridgeService {
174
304
  dispatchAttemptId: dispatch.sandbox.dispatchAttemptId,
175
305
  runnerInstanceId: dispatch.sandbox.runnerInstanceId
176
306
  } : {}),
177
- event: redactSecrets(event, redactionSecrets)
307
+ event: redactRunnerDispatchEvent(event, redactionSecrets)
178
308
  });
179
309
  }
180
310
  catch (error) {
@@ -192,18 +322,36 @@ export class ForgeBridgeService {
192
322
  if (normalized.sandbox?.executionMode === "docker") {
193
323
  await this.flushCleanupReports().catch(() => undefined);
194
324
  }
195
- await this.sendResult(dispatch, redactSecrets(result, redactionSecrets));
325
+ const redactedResult = redactRunOnceResultPacket(result, redactionSecrets);
326
+ await this.releaseActiveSessionAndRefreshCapacity(leaseId);
327
+ await this.sendResult(dispatch, compactTerminalResult(dispatch, redactedResult));
196
328
  }
197
329
  catch (error) {
198
330
  const summary = controller.signal.aborted
199
331
  ? `Dispatch cancelled: ${String(controller.signal.reason ?? "cancelled")}`
200
332
  : redactText(error instanceof Error ? error.message : String(error), redactionSecrets);
201
- await this.sendResult(dispatch, failureResult(summary)).catch(() => undefined);
333
+ const failureEventType = infrastructureFailureEventType(summary);
334
+ const failureEvents = failureEventType ? [{
335
+ role: "runner",
336
+ kind: "error",
337
+ content: summary,
338
+ eventType: failureEventType,
339
+ }] : [];
340
+ await this.releaseActiveSessionAndRefreshCapacity(leaseId);
341
+ await this.sendResult(dispatch, failureResult(summary, failureEvents)).catch(() => undefined);
202
342
  }
203
343
  finally {
204
- this.activeSessions.delete(leaseId);
344
+ await this.releaseActiveSessionAndRefreshCapacity(leaseId);
205
345
  }
206
346
  }
347
+ async releaseActiveSessionAndRefreshCapacity(leaseId) {
348
+ if (!this.activeSessions.delete(leaseId))
349
+ return;
350
+ // Publish released host/runtime capacity before the terminal result can
351
+ // enqueue the next pipeline phase. Otherwise the server may reject that
352
+ // handoff from the previous periodic heartbeat's active reservation.
353
+ await this.sendHeartbeat().catch(() => undefined);
354
+ }
207
355
  async handleOffer(rawDispatch) {
208
356
  const envelope = parseOfferEnvelope(rawDispatch);
209
357
  if (!envelope) {
@@ -216,14 +364,13 @@ export class ForgeBridgeService {
216
364
  || dispatch.lease.fencingToken !== envelope.fencingToken)
217
365
  return;
218
366
  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
- }
367
+ this.expireReservations(Date.now());
368
+ const existing = this.reservations.get(sandbox.dispatchAttemptId);
224
369
  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);
370
+ const sameFence = existing.leaseId === dispatch.lease.id
371
+ && existing.fencingToken === dispatch.lease.fencingToken
372
+ && existing.runnerInstanceId === sandbox.runnerInstanceId;
373
+ 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
374
  return;
228
375
  }
229
376
  if (sandbox.runnerInstanceId !== this.config.runnerInstanceId) {
@@ -234,42 +381,123 @@ export class ForgeBridgeService {
234
381
  await this.sendOfferDecision(dispatch, false, "lease_expired", "Offer lease is already expired.");
235
382
  return;
236
383
  }
237
- const preflight = this.preflight.current();
384
+ const preflight = sandbox.executionMode === "docker" && this.config.dockerEnabled
385
+ ? await this.preflight.run()
386
+ : this.preflight.current();
238
387
  if (sandbox.executionMode === "docker") {
239
388
  if (!this.config.dockerEnabled || !preflight?.healthy) {
240
389
  await this.sendOfferDecision(dispatch, false, preflight?.unavailableCode ?? "docker_unavailable", preflight?.unavailableReason ?? "Docker sandbox preflight is unavailable.");
241
390
  return;
242
391
  }
243
- const networkFailure = sandboxNetworkPolicyFailure(dispatch);
392
+ const networkFailure = sandboxNetworkPolicyFailure(dispatch, this.config);
244
393
  if (networkFailure) {
245
394
  await this.sendOfferDecision(dispatch, false, "policy_mismatch", networkFailure);
246
395
  return;
247
396
  }
397
+ const providerNetworkFailure = sandboxProviderNetworkFailure(dispatch, this.config);
398
+ if (providerNetworkFailure) {
399
+ await this.sendOfferDecision(dispatch, false, "provider_network_policy_denied", providerNetworkFailure);
400
+ return;
401
+ }
248
402
  const authFailure = sandboxAuthFailure(dispatch, this.config);
249
403
  if (authFailure) {
250
404
  await this.sendOfferDecision(dispatch, false, "sandbox_auth_unavailable", authFailure);
251
405
  return;
252
406
  }
253
407
  }
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;
408
+ let requestedResources = sandbox.requestedResources;
409
+ let sandboxConfigCommitSha = sandbox.sandboxConfigCommitSha ?? null;
410
+ const normalizedDispatch = this.normalizeDispatch(dispatch);
411
+ if (sandbox.executionMode === "docker") {
412
+ try {
413
+ const profile = await this.sandboxResourceResolver(normalizedDispatch);
414
+ requestedResources = profile.resources;
415
+ sandboxConfigCommitSha = profile.sandboxConfigCommitSha;
416
+ }
417
+ catch (error) {
418
+ await this.sendOfferDecision(dispatch, false, "sandbox_capacity_profile_unavailable", error instanceof Error ? error.message : "The immutable sandbox contract could not be profiled for admission.");
419
+ return;
420
+ }
421
+ }
422
+ else if (normalizedDispatch.repo.serverLocalPath) {
423
+ // Host execution uses the same project-derived disk reservation as Docker.
424
+ // The API supplies a placement-time estimate; the runner can safely raise it
425
+ // from its authoritative checkout before admitting the attempt.
426
+ const measuredWorkspaceBytes = await estimateWorkspaceBytesFromRepo(normalizedDispatch.repo.serverLocalPath, { role: normalizedDispatch.execution.role }).catch(() => null);
427
+ if (measuredWorkspaceBytes !== null) {
428
+ requestedResources = {
429
+ ...requestedResources,
430
+ workspaceBytes: Math.max(requestedResources.workspaceBytes, measuredWorkspaceBytes),
431
+ };
432
+ }
261
433
  }
262
- const reservedUntil = new Date(Math.min(Date.parse(dispatch.lease.expiresAt), Date.now() + 30_000)).toISOString();
263
- this.reservations.set(sandbox.dispatchAttemptId, {
434
+ // Resource profiling can await filesystem and Docker work. Admission needs
435
+ // a dynamic host/daemon sample that began after profiling completed, so an
436
+ // older in-flight heartbeat inspection cannot supply the commit decision.
437
+ const admissionPreflight = sandbox.executionMode === "docker"
438
+ ? await this.preflight.refreshCapacityForAdmission()
439
+ : preflight;
440
+ // Resource profiling and preflight may await slow filesystem or Docker I/O.
441
+ // Re-read every mutable capacity input only after that work, then decide and
442
+ // commit synchronously. JavaScript cannot interleave another Offer handler
443
+ // inside this non-async method, so the ledger check and insertion are one
444
+ // atomic event-loop turn without holding a lock across external I/O.
445
+ const decision = this.commitOfferReservation(dispatch, requestedResources, admissionPreflight);
446
+ await this.sendOfferDecision(dispatch, decision.accepted, decision.code, decision.message, decision.reservedUntil, decision.resources, sandboxConfigCommitSha);
447
+ }
448
+ commitOfferReservation(dispatch, requestedResources, preparedPreflight) {
449
+ const sandbox = dispatch.sandbox;
450
+ const now = Date.now();
451
+ this.expireReservations(now);
452
+ const existing = this.reservations.get(sandbox.dispatchAttemptId);
453
+ if (existing) {
454
+ const sameFence = existing.leaseId === dispatch.lease.id
455
+ && existing.fencingToken === dispatch.lease.fencingToken
456
+ && existing.runnerInstanceId === sandbox.runnerInstanceId;
457
+ return {
458
+ accepted: sameFence,
459
+ code: sameFence ? null : "stale_fence",
460
+ message: sameFence ? null : "Offer fence conflicts with the existing reservation.",
461
+ reservedUntil: sameFence ? existing.reservedUntil : null,
462
+ resources: sameFence ? existing.resources : null,
463
+ };
464
+ }
465
+ if (Date.parse(dispatch.lease.expiresAt) <= now) {
466
+ return rejectedOfferReservation("lease_expired", "Offer lease is already expired.");
467
+ }
468
+ if (sandbox.executionMode === "docker") {
469
+ // A concurrent heartbeat/preflight refresh may have produced a newer
470
+ // conservative dual-domain sample while this offer was being profiled.
471
+ const admissionPreflight = this.preflight.current() ?? preparedPreflight;
472
+ if (!this.config.dockerEnabled || !admissionPreflight?.healthy) {
473
+ return rejectedOfferReservation(admissionPreflight?.unavailableCode ?? "docker_unavailable", admissionPreflight?.unavailableReason ?? "Docker sandbox preflight is unavailable.");
474
+ }
475
+ const runtimeState = this.sandboxRuntime.snapshot();
476
+ const usedResources = this.usedSandboxResources(runtimeState.degraded);
477
+ const activeResources = this.activeSandboxResources(runtimeState.degraded);
478
+ const capacity = this.sandboxResourceCapacity(admissionPreflight, activeResources, usedResources);
479
+ if (!canReserveSandboxResources(usedResources, requestedResources, capacity)) {
480
+ return rejectedOfferReservation("capacity_unavailable", sandboxCapacityFailureMessage(usedResources, requestedResources, capacity, admissionPreflight, this.config));
481
+ }
482
+ }
483
+ const reservedUntil = new Date(Math.min(Date.parse(dispatch.lease.expiresAt), now + 30_000)).toISOString();
484
+ const reservation = {
264
485
  leaseId: dispatch.lease.id,
265
486
  fencingToken: dispatch.lease.fencingToken,
266
487
  runnerInstanceId: sandbox.runnerInstanceId,
267
488
  expiresAt: dispatch.lease.expiresAt,
268
489
  reservedUntil,
269
490
  sandboxed: sandbox.executionMode === "docker",
270
- resources: sandbox.requestedResources
271
- });
272
- await this.sendOfferDecision(dispatch, true, null, null, reservedUntil);
491
+ resources: requestedResources,
492
+ };
493
+ this.reservations.set(sandbox.dispatchAttemptId, reservation);
494
+ return {
495
+ accepted: true,
496
+ code: null,
497
+ message: null,
498
+ reservedUntil,
499
+ resources: requestedResources,
500
+ };
273
501
  }
274
502
  async handleStart(rawDispatch) {
275
503
  const envelope = parseStartEnvelope(rawDispatch);
@@ -315,6 +543,10 @@ export class ForgeBridgeService {
315
543
  await this.sendResult(dispatch, failureResult("Start rejected because the provider secret grant handle is missing.")).catch(() => undefined);
316
544
  return;
317
545
  }
546
+ dispatch = {
547
+ ...dispatch,
548
+ sandbox: { ...dispatch.sandbox, requestedResources: reservation.resources },
549
+ };
318
550
  this.reservations.delete(envelope.dispatchAttemptId);
319
551
  await this.handleDispatch(dispatch);
320
552
  }
@@ -355,10 +587,15 @@ export class ForgeBridgeService {
355
587
  const active = this.activeSessions.get(request.leaseId);
356
588
  if (active && active.fencingToken === request.fencingToken && active.dispatchAttemptId === request.dispatchAttemptId) {
357
589
  active.leaseExpiresAt = request.expiresAt;
358
- await this.sandboxRuntime.updateLease(request.dispatchAttemptId, request.fencingToken, request.expiresAt);
590
+ if (active.sandboxed) {
591
+ await this.sandboxRuntime.updateLease(request.dispatchAttemptId, request.fencingToken, request.expiresAt);
592
+ }
593
+ else {
594
+ await this.sessions.updateLease?.(request.dispatchAttemptId, request.fencingToken, request.expiresAt);
595
+ }
359
596
  }
360
597
  }
361
- async sendOfferDecision(dispatch, accepted, code = null, message = null, reservedUntil = null) {
598
+ async sendOfferDecision(dispatch, accepted, code = null, message = null, reservedUntil = null, reservedResources = null, sandboxConfigCommitSha = null) {
362
599
  if (!dispatch.sandbox)
363
600
  return;
364
601
  const payload = {
@@ -369,7 +606,9 @@ export class ForgeBridgeService {
369
606
  accepted,
370
607
  code,
371
608
  message,
372
- reservedUntil: accepted ? reservedUntil ?? dispatch.lease.expiresAt : null
609
+ reservedUntil: accepted ? reservedUntil ?? dispatch.lease.expiresAt : null,
610
+ reservedResources: accepted ? reservedResources : null,
611
+ sandboxConfigCommitSha: accepted ? sandboxConfigCommitSha : null,
373
612
  };
374
613
  await this.invokeAck("OfferDecision", payload);
375
614
  }
@@ -402,19 +641,73 @@ export class ForgeBridgeService {
402
641
  active.controller.abort(request.reason ?? "cancelled by server");
403
642
  }
404
643
  async handleReap(request) {
405
- if (!this.config.dockerEnabled
406
- || request.runnerId !== this.config.runnerId
644
+ if (request.runnerId !== this.config.runnerId
407
645
  || request.runnerInstanceId !== this.config.runnerInstanceId)
408
646
  return;
409
647
  await this.runSandboxMaintenance();
410
648
  await this.sendHeartbeat();
411
649
  }
650
+ async handleReadinessProbe(rawRequest) {
651
+ const request = parseReadinessProbe(rawRequest);
652
+ if (!request || request.runnerInstanceId !== this.config.runnerInstanceId)
653
+ return;
654
+ const existing = this.activeReadinessProbes.get(request.requestId);
655
+ if (existing)
656
+ return;
657
+ const controller = new AbortController();
658
+ this.activeReadinessProbes.set(request.requestId, controller);
659
+ const deadlineMs = Date.parse(request.expiresAt) - Date.now();
660
+ const timer = setTimeout(() => controller.abort("readiness probe expired"), Math.max(0, deadlineMs));
661
+ timer.unref?.();
662
+ let result;
663
+ try {
664
+ if (!this.sessions.probeHostRuntime) {
665
+ throw new Error("host_runtime_probe_unavailable: this bridge does not implement runtime readiness probes.");
666
+ }
667
+ const probe = await this.sessions.probeHostRuntime({
668
+ ...request,
669
+ runnerId: this.config.runnerId,
670
+ repoRoot: this.config.defaultRepoRoot,
671
+ cleanupWorktrees: this.config.cleanupWorktrees,
672
+ }, controller.signal);
673
+ result = {
674
+ schemaVersion: 1,
675
+ requestId: request.requestId,
676
+ fencingToken: request.fencingToken,
677
+ runnerInstanceId: this.config.runnerInstanceId,
678
+ ...probe,
679
+ };
680
+ }
681
+ catch (error) {
682
+ result = {
683
+ schemaVersion: 1,
684
+ requestId: request.requestId,
685
+ fencingToken: request.fencingToken,
686
+ runnerInstanceId: this.config.runnerInstanceId,
687
+ status: "failed",
688
+ reasonCode: "host_runtime_probe_failed",
689
+ reasonMessage: errorMessage(error).slice(0, 1000),
690
+ checkedAt: new Date().toISOString(),
691
+ };
692
+ }
693
+ finally {
694
+ clearTimeout(timer);
695
+ this.activeReadinessProbes.delete(request.requestId);
696
+ }
697
+ await this.invokeAck("ReadinessResult", result).catch(() => undefined);
698
+ await this.sendHeartbeat().catch(() => undefined);
699
+ }
412
700
  abortActiveSessions(reason) {
413
701
  for (const active of this.activeSessions.values()) {
414
702
  active.controller.abort(reason);
415
703
  }
416
704
  this.activeSessions.clear();
417
705
  }
706
+ abortActiveReadinessProbes(reason) {
707
+ for (const controller of this.activeReadinessProbes.values())
708
+ controller.abort(reason);
709
+ this.activeReadinessProbes.clear();
710
+ }
418
711
  normalizeDispatch(dispatch) {
419
712
  const mapping = findRepoMapping(dispatch, repoMappingsFor(this.config));
420
713
  const localPath = mapping?.localPath?.trim();
@@ -465,12 +758,41 @@ export class ForgeBridgeService {
465
758
  }
466
759
  startHeartbeat() {
467
760
  this.stopHeartbeat();
761
+ this.consecutiveHeartbeatFailures = 0;
762
+ this.reconnectingFromHeartbeatFailure = false;
468
763
  const intervalMs = positiveFiniteNumber(this.config.heartbeatIntervalMs, 30_000);
469
764
  this.heartbeatTimer = setInterval(() => {
470
- this.sendHeartbeat().catch(() => undefined);
765
+ void this.heartbeatTick();
471
766
  }, intervalMs);
472
767
  this.heartbeatTimer.unref?.();
473
768
  }
769
+ async heartbeatTick() {
770
+ if (this.heartbeatInFlight || this.reconnectingFromHeartbeatFailure)
771
+ return;
772
+ const connection = this.connection;
773
+ if (!connection)
774
+ return;
775
+ this.heartbeatInFlight = true;
776
+ try {
777
+ await this.sendHeartbeat();
778
+ this.consecutiveHeartbeatFailures = 0;
779
+ }
780
+ catch (error) {
781
+ if (connection !== this.connection || this.stopped)
782
+ return;
783
+ this.consecutiveHeartbeatFailures += 1;
784
+ logBridgeStatus(`Heartbeat ${this.consecutiveHeartbeatFailures} failed: ${errorMessage(error)}.`);
785
+ if (this.consecutiveHeartbeatFailures < 2)
786
+ return;
787
+ this.reconnectingFromHeartbeatFailure = true;
788
+ this.stopHeartbeat();
789
+ logBridgeStatus("Heartbeat failed twice. Closing the stale connection so the Bridge can reconnect...");
790
+ await connection.stop().catch(() => undefined);
791
+ }
792
+ finally {
793
+ this.heartbeatInFlight = false;
794
+ }
795
+ }
474
796
  startLeaseWatchdog() {
475
797
  this.stopLeaseWatchdog();
476
798
  this.leaseWatchdogTimer = setInterval(() => this.expireLeases(), 1000);
@@ -484,17 +806,20 @@ export class ForgeBridgeService {
484
806
  }
485
807
  expireLeases() {
486
808
  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
- }
809
+ this.expireReservations(now);
492
810
  for (const active of this.activeSessions.values()) {
493
811
  if (Date.parse(active.leaseExpiresAt) <= now && !active.controller.signal.aborted) {
494
812
  active.controller.abort("dispatch lease expired");
495
813
  }
496
814
  }
497
815
  }
816
+ expireReservations(now) {
817
+ for (const [attemptId, reservation] of this.reservations) {
818
+ if (Date.parse(reservation.expiresAt) <= now || Date.parse(reservation.reservedUntil) <= now) {
819
+ this.reservations.delete(attemptId);
820
+ }
821
+ }
822
+ }
498
823
  stopHeartbeat() {
499
824
  if (this.heartbeatTimer) {
500
825
  clearInterval(this.heartbeatTimer);
@@ -503,8 +828,6 @@ export class ForgeBridgeService {
503
828
  }
504
829
  startSandboxMaintenance() {
505
830
  this.stopSandboxMaintenance();
506
- if (!this.config.dockerEnabled)
507
- return;
508
831
  this.sandboxMaintenanceTimer = setInterval(() => {
509
832
  this.runSandboxMaintenance().catch(() => undefined);
510
833
  }, 60_000);
@@ -521,11 +844,20 @@ export class ForgeBridgeService {
521
844
  return;
522
845
  this.sandboxMaintenanceRunning = true;
523
846
  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();
847
+ const hostReap = await this.sessions.reapHostRuntime?.({
848
+ runnerId: this.config.runnerId,
849
+ runnerInstanceId: this.config.runnerInstanceId,
850
+ });
851
+ if (hostReap && (hostReap.reaped > 0 || hostReap.degraded > 0)) {
852
+ logBridgeStatus(`Managed-host maintenance reaped ${hostReap.reaped} stale attempt(s) and left ${hostReap.degraded} degraded attempt(s) for reconciliation.`);
853
+ }
854
+ if (this.config.dockerEnabled) {
855
+ const preflight = await this.preflight.refresh();
856
+ if (canReapManagedSandboxes(preflight)) {
857
+ const attemptSnapshot = await this.readAttemptSnapshot();
858
+ await this.sandboxRuntime.reap({ diskLow: preflight.diskLow, attemptSnapshot });
859
+ await this.flushCleanupReports();
860
+ }
529
861
  }
530
862
  }
531
863
  finally {
@@ -556,6 +888,12 @@ export class ForgeBridgeService {
556
888
  }
557
889
  }
558
890
  async sendHeartbeat() {
891
+ // Heartbeats are the recovery path for capacity-blocked work. Refresh the
892
+ // dynamic sample before advertising it, while reusing the static Docker
893
+ // capability snapshot established at startup or by an offer.
894
+ if (this.config.dockerEnabled)
895
+ await this.preflight.refreshCapacity();
896
+ await this.dockerEngineProbe.refresh();
559
897
  await this.invokeAck("Heartbeat", {
560
898
  status: "online",
561
899
  capabilities: this.capabilities(),
@@ -565,12 +903,25 @@ export class ForgeBridgeService {
565
903
  }
566
904
  capabilities() {
567
905
  const preflight = this.preflight.current();
906
+ const dockerEngine = this.dockerEngineProbe.current();
568
907
  const runtimeState = this.sandboxRuntime.snapshot();
569
908
  const reserved = [...this.reservations.values()].filter(reservation => reservation.sandboxed).length;
570
909
  const used = this.usedSandboxResources(runtimeState.degraded);
571
- const capacity = this.sandboxResourceCapacity(preflight);
910
+ const activeResources = this.activeSandboxResources(runtimeState.degraded);
911
+ const capacity = this.sandboxResourceCapacity(preflight, activeResources, used);
912
+ const ambientMemoryCommitmentBytes = this.sandboxAmbientMemoryCommitmentBytes ?? 0;
913
+ const memoryAdmission = preflight ? buildMemoryAdmissionDiagnostics({
914
+ sampledAt: preflight.sampledAt,
915
+ totalMemoryBytes: preflight.totalMemoryBytes,
916
+ rawAvailableMemoryBytes: preflight.rawAvailableMemoryBytes,
917
+ stabilizedAvailableMemoryBytes: preflight.availableMemoryBytes,
918
+ controlPlaneMemoryBytes: process.memoryUsage().rss,
919
+ reserveMemoryBytes: preflight.hostReserveBytes,
920
+ committedMemoryBytes: ambientMemoryCommitmentBytes + used.memoryBytes,
921
+ pendingCommitmentMemoryBytes: Math.max(0, used.memoryBytes - activeResources.memoryBytes),
922
+ }) : null;
572
923
  return {
573
- schemaVersion: 3,
924
+ schemaVersion: 5,
574
925
  activeSessionCount: this.activeSessions.size,
575
926
  cliProviders: this.config.cliProviders,
576
927
  providerStatuses: this.config.providerStatuses,
@@ -580,7 +931,20 @@ export class ForgeBridgeService {
580
931
  sandboxProfiles: this.config.sandboxProfiles,
581
932
  runnerInstanceId: this.config.runnerInstanceId,
582
933
  trustDomainId: this.config.trustDomainId,
583
- maxConcurrentSandboxes: preflight?.maxConcurrentSandboxes ?? 0,
934
+ executionFacilities: dockerEngine?.available ? ["docker_engine"] : [],
935
+ dockerEngine,
936
+ toolchains: {
937
+ ...this.hostCapabilities.toolchains,
938
+ docker: {
939
+ available: dockerEngine?.available === true,
940
+ executable: this.config.dockerExecutable,
941
+ version: dockerEngine?.runtimeVersion ?? null,
942
+ },
943
+ },
944
+ playwrightProjects: this.hostCapabilities.playwrightProjects,
945
+ browserLaunch: this.hostCapabilities.browserLaunch,
946
+ providerConnectivity: this.hostCapabilities.providerConnectivity,
947
+ maxConcurrentSandboxes: capacity.sandboxSlots,
584
948
  reservedSandboxCount: reserved,
585
949
  activeSandboxCount: runtimeState.active,
586
950
  startingSandboxCount: runtimeState.starting,
@@ -588,15 +952,30 @@ export class ForgeBridgeService {
588
952
  sandboxRuntimes: preflight?.healthy ? ["docker"] : [],
589
953
  sandboxRuntimeVersion: preflight?.runtimeVersion ?? null,
590
954
  sandboxFeatures: preflight?.features ?? null,
955
+ allowsOpenPublicSandbox: this.config.allowOpenPublicSandbox,
591
956
  sandboxPublicationModes: [this.config.sandboxPublicationMode ?? "shared_filesystem"],
592
957
  sandboxAuthModes: sandboxAuthModesFor(this.config),
593
- availableMemoryBytes: preflight ? Math.max(0, capacity.memoryBytes - used.memoryBytes) : null,
958
+ availableMemoryBytes: memoryAdmission?.availableForAdmissionBytes ?? null,
594
959
  availableCpuMillis: preflight ? Math.max(0, capacity.cpuMillis - used.cpuMillis) : null,
595
960
  availableWorkspaceBytes: preflight ? Math.max(0, capacity.workspaceBytes - used.workspaceBytes) : null,
596
961
  diskLow: preflight?.diskLow ?? null,
597
962
  preflightGeneration: preflight?.generation ?? 0,
598
963
  sandboxUnavailableCode: this.config.dockerEnabled ? preflight?.unavailableCode ?? null : "docker_disabled",
599
964
  sandboxUnavailableReason: this.config.dockerEnabled ? preflight?.unavailableReason ?? null : "Docker sandboxing is disabled by runner configuration.",
965
+ sandboxMemoryCapacity: preflight ? {
966
+ defaultWorkerMemoryLimitBytes: this.config.defaultSandboxWorkerMemoryLimitBytes,
967
+ defaultServiceMemoryLimitBytes: this.config.defaultSandboxServiceMemoryLimitBytes,
968
+ runtimeOverheadBytes: this.config.sandboxRuntimeOverheadBytes,
969
+ hostReserveBytes: preflight.hostReserveBytes,
970
+ activeReservationBytes: used.memoryBytes,
971
+ daemonTotalBytes: preflight.totalMemoryBytes,
972
+ daemonAvailableBytes: preflight.rawDaemonAvailableMemoryBytes ?? 0,
973
+ hostAvailableBytes: preflight.rawHostAvailableMemoryBytes,
974
+ conservativeAvailableBytes: preflight.rawAvailableMemoryBytes,
975
+ availableForAdmissionBytes: memoryAdmission.availableForAdmissionBytes,
976
+ memoryAdmission: memoryAdmission,
977
+ } : null,
978
+ hostRuntime: this.sessions.runtimeCapacity?.() ?? null,
600
979
  supportsApiSecretGrant: this.config.supportsApiSecretGrant,
601
980
  supportsManagedWorktrees: this.config.supportsManagedWorktrees,
602
981
  supportsManagedForgeArtifacts: this.config.supportsManagedForgeArtifacts,
@@ -606,6 +985,16 @@ export class ForgeBridgeService {
606
985
  supportsDraftImageAttachments: this.config.supportsDraftImageAttachments
607
986
  };
608
987
  }
988
+ async requiredCapabilityFailure(dispatch) {
989
+ const requiredCapabilities = dispatch.qaScope?.required_capabilities ?? [];
990
+ if (!requiredCapabilities.includes("docker_engine"))
991
+ return null;
992
+ const executionMode = dispatch.sandbox?.executionMode;
993
+ if (executionMode === "docker") {
994
+ return dockerEngineRequirementFailure(requiredCapabilities, executionMode, null);
995
+ }
996
+ return dockerEngineRequirementFailure(requiredCapabilities, executionMode, await this.dockerEngineProbe.refresh(true));
997
+ }
609
998
  usedSandboxResources(degradedReservations) {
610
999
  const resources = [
611
1000
  ...[...this.reservations.values()]
@@ -618,34 +1007,116 @@ export class ForgeBridgeService {
618
1007
  ];
619
1008
  return sumSandboxResources(resources);
620
1009
  }
621
- sandboxResourceCapacity(preflight) {
1010
+ activeSandboxResources(degradedReservations) {
1011
+ return sumSandboxResources([
1012
+ ...[...this.activeSessions.values()]
1013
+ .filter(session => session.sandboxed)
1014
+ .map(session => session.resources),
1015
+ ...Array.from({ length: degradedReservations }, () => defaultSandboxResources())
1016
+ ]);
1017
+ }
1018
+ sandboxResourceCapacity(preflight, activeResources, usedResources) {
622
1019
  if (!preflight)
623
1020
  return zeroSandboxResources();
1021
+ if (usedResources.memoryBytes === 0 || this.sandboxAmbientMemoryCommitmentBytes === null) {
1022
+ // Freeze non-Forge daemon use while reservations exist. If the bridge
1023
+ // reconnects with managed containers already present, the initial value
1024
+ // is deliberately conservative until their ledger drains.
1025
+ this.sandboxAmbientMemoryCommitmentBytes = Math.max(0, preflight.totalMemoryBytes - preflight.rawAvailableMemoryBytes);
1026
+ }
624
1027
  return {
625
- sandboxSlots: preflight.maxConcurrentSandboxes,
1028
+ // Host free memory and disk already reflect active containers. Add their
1029
+ // commitments back to form a stable total capacity, then subtract them
1030
+ // exactly once in usedSandboxResources.
1031
+ sandboxSlots: Math.min(this.config.maxConcurrentSandboxes, preflight.maxConcurrentSandboxes + activeResources.sandboxSlots),
626
1032
  cpuMillis: preflight.availableCpuMillis,
627
- memoryBytes: Math.max(0, preflight.availableMemoryBytes - 2 * 1024 ** 3),
628
- workspaceBytes: Math.max(0, preflight.availableWorkspaceBytes - this.config.minimumWorkspaceBytes),
1033
+ workerMemoryLimitBytes: 0,
1034
+ serviceMemoryLimitBytes: 0,
1035
+ runtimeOverheadBytes: 0,
1036
+ memoryBytes: sandboxMemoryCommitmentCapacity(preflight, activeResources.memoryBytes, this.sandboxAmbientMemoryCommitmentBytes),
1037
+ workspaceBytes: Math.max(0, preflight.availableWorkspaceBytes
1038
+ - this.config.minimumWorkspaceBytes
1039
+ + activeResources.workspaceBytes),
629
1040
  pids: this.config.sandboxPidCapacity,
630
1041
  buildSlots: this.config.maxConcurrentSandboxBuilds
631
1042
  };
632
1043
  }
633
1044
  async invokeAck(methodName, payload) {
634
- const connection = this.connection;
635
- if (!connection) {
636
- throw new Error("Bridge is not connected.");
1045
+ let storageRecoveryAttempt = 0;
1046
+ while (true) {
1047
+ const connection = this.connection;
1048
+ if (!connection) {
1049
+ throw new Error("Bridge is not connected.");
1050
+ }
1051
+ await this.waitForTransportRecovery(connection, this.transportRecovery);
1052
+ const recoveryGeneration = this.transportRecoveryGeneration;
1053
+ try {
1054
+ const ack = await waitForCallbackAck(connection.invoke(methodName, payload), methodName, positiveFiniteNumber(this.config.callbackAckTimeoutMs ?? 30_000, 30_000));
1055
+ if (!ack.accepted) {
1056
+ throw new BridgeCallbackRejectedError(ack.code ?? null, ack.message ?? ack.code ?? `${methodName} callback was rejected.`);
1057
+ }
1058
+ return ack;
1059
+ }
1060
+ catch (error) {
1061
+ if (connection !== this.connection || this.stopped)
1062
+ throw error;
1063
+ const recovery = this.transportRecovery;
1064
+ if (recovery && recovery.generation > recoveryGeneration) {
1065
+ await this.waitForTransportRecovery(connection, recovery);
1066
+ continue;
1067
+ }
1068
+ if (isProviderStorageUnavailable(error) && storageRecoveryAttempt < this.config.reconnectBackoffMs.length) {
1069
+ const backoff = this.config.reconnectBackoffMs[storageRecoveryAttempt] ?? 5000;
1070
+ storageRecoveryAttempt += 1;
1071
+ logBridgeStatus(`${methodName} reached Forge while storage was recovering. Retrying callback ${storageRecoveryAttempt} in ${formatDelay(backoff)}...`);
1072
+ await delay(backoff);
1073
+ continue;
1074
+ }
1075
+ throw error;
1076
+ }
637
1077
  }
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.`);
1078
+ }
1079
+ beginTransportRecovery() {
1080
+ if (this.transportRecovery && !this.transportRecovery.completed)
1081
+ return;
1082
+ let resolve = () => undefined;
1083
+ const promise = new Promise((complete) => { resolve = complete; });
1084
+ this.transportRecoveryGeneration += 1;
1085
+ this.transportRecovery = {
1086
+ generation: this.transportRecoveryGeneration,
1087
+ promise,
1088
+ resolve,
1089
+ completed: false,
1090
+ error: null,
1091
+ };
1092
+ }
1093
+ completeTransportRecovery(error = null) {
1094
+ const recovery = this.transportRecovery;
1095
+ if (!recovery || recovery.completed)
1096
+ return;
1097
+ recovery.completed = true;
1098
+ recovery.error = error;
1099
+ recovery.resolve();
1100
+ }
1101
+ async waitForTransportRecovery(connection, recovery) {
1102
+ if (!recovery)
1103
+ return;
1104
+ if (recovery.completed) {
1105
+ if (recovery.error)
1106
+ throw recovery.error;
1107
+ return;
641
1108
  }
642
- return ack;
1109
+ await recovery.promise;
1110
+ if (connection !== this.connection)
1111
+ throw new Error("Bridge connection changed while recovering.");
1112
+ if (recovery.error)
1113
+ throw recovery.error;
643
1114
  }
644
1115
  async waitForClose(signal) {
645
1116
  if (!this.closed) {
646
1117
  return;
647
1118
  }
648
- await Promise.race([this.closed, abortPromise(signal)]);
1119
+ await waitForPromiseOrAbort(this.closed, signal);
649
1120
  }
650
1121
  async stopConnection() {
651
1122
  this.stopHeartbeat();
@@ -671,6 +1142,17 @@ function isDispatchNoLongerPending(error) {
671
1142
  }
672
1143
  return error instanceof Error && error.message === "Dispatch is no longer pending.";
673
1144
  }
1145
+ function isProviderStorageUnavailable(error) {
1146
+ const message = errorMessage(error).toLowerCase();
1147
+ return message.includes("provider_storage_unavailable")
1148
+ || (message.includes("storage is recovering") && message.includes("503"));
1149
+ }
1150
+ function createDefaultSessionService(sandboxStateRoot) {
1151
+ const rawProcessRunner = new StreamingProviderProcessRunner();
1152
+ const processSupervisors = new AttemptProcessSupervisorRegistry();
1153
+ const coordinatedProcessRunner = new AttemptCoordinatedProviderProcessRunner(processSupervisors, rawProcessRunner);
1154
+ return new BridgeSessionService(new GitWorktreeService(undefined, new RepositoryCoordinatorLock(sandboxStateRoot)), new RoutingProviderCliService(new ProcessBackedProviderCliService(coordinatedProcessRunner)), new HostRuntimeLeaseManager(sandboxStateRoot, undefined, undefined, processSupervisors, rawProcessRunner));
1155
+ }
674
1156
  export class SignalRBridgeConnectionFactory {
675
1157
  create(config) {
676
1158
  return new SignalRBridgeConnection(config);
@@ -693,10 +1175,11 @@ export class SignalRBridgeConnection {
693
1175
  Authorization: `Bearer ${config.deviceToken}`
694
1176
  }
695
1177
  })
1178
+ .withAutomaticReconnect([...config.reconnectBackoffMs])
696
1179
  .configureLogging(LogLevel.Warning)
697
1180
  .build();
698
1181
  this.connection.on("Superseded", () => {
699
- void this.connection.stop();
1182
+ void this.connection.stop().catch(() => undefined);
700
1183
  });
701
1184
  }
702
1185
  start() {
@@ -708,6 +1191,14 @@ export class SignalRBridgeConnection {
708
1191
  onClose(handler) {
709
1192
  this.connection.onclose(handler);
710
1193
  }
1194
+ onReconnecting(handler) {
1195
+ this.connection.onreconnecting(handler);
1196
+ }
1197
+ onReconnected(handler) {
1198
+ this.connection.onreconnected((connectionId) => {
1199
+ void Promise.resolve(handler(connectionId)).catch(() => undefined);
1200
+ });
1201
+ }
711
1202
  onDispatch(handler) {
712
1203
  this.connection.on("Dispatch", (raw) => {
713
1204
  void handler(raw).catch(() => undefined);
@@ -738,6 +1229,11 @@ export class SignalRBridgeConnection {
738
1229
  void handler(request).catch(() => undefined);
739
1230
  });
740
1231
  }
1232
+ onReadinessProbe(handler) {
1233
+ this.connection.on("ReadinessProbe", (request) => {
1234
+ void handler(request).catch(() => undefined);
1235
+ });
1236
+ }
741
1237
  invoke(methodName, payload) {
742
1238
  return this.connection.invoke(methodName, payload);
743
1239
  }
@@ -745,6 +1241,7 @@ export class SignalRBridgeConnection {
745
1241
  export function defaultBridgeServiceConfig(overrides = {}) {
746
1242
  const defaults = defaultForgeBridgeConfig();
747
1243
  const cliProviders = splitCsv(process.env.FORGE_CLI_PROVIDERS ?? "codex");
1244
+ const configuredSandboxHostMemoryReserveBytes = optionalPositiveFiniteNumber(process.env.FORGE_SANDBOX_HOST_MEMORY_RESERVE_BYTES);
748
1245
  const sandboxPublicationMode = process.env.FORGE_SANDBOX_PUBLICATION_MODE === "bridge_push" ? "bridge_push" : "shared_filesystem";
749
1246
  const config = {
750
1247
  serverUrl: process.env.FORGE_URL ?? "http://localhost:5208",
@@ -755,14 +1252,26 @@ export function defaultBridgeServiceConfig(overrides = {}) {
755
1252
  maxConcurrentSandboxBuilds: positiveFiniteNumber(Number(process.env.FORGE_MAX_CONCURRENT_SANDBOX_BUILDS ?? 1), 1),
756
1253
  sandboxPidCapacity: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_PID_CAPACITY ?? 2048), 2048),
757
1254
  dockerEnabled: process.env.FORGE_DOCKER_SANDBOX !== "false",
1255
+ allowOpenPublicSandbox: process.env.FORGE_ALLOW_OPEN_PUBLIC_SANDBOX === "true",
758
1256
  sandboxPublicationMode,
759
1257
  dockerExecutable: process.env.FORGE_DOCKER_EXECUTABLE ?? "docker",
760
1258
  dockerContext: process.env.FORGE_DOCKER_CONTEXT ?? "",
1259
+ dockerDesktopRecoveryEnabled: process.env.FORGE_DOCKER_DESKTOP_AUTO_RECOVERY === "true",
1260
+ dockerRecoveryFailureThreshold: positiveFiniteNumber(Number(process.env.FORGE_DOCKER_RECOVERY_FAILURE_THRESHOLD ?? 3), 3),
1261
+ dockerRecoveryCooldownMs: positiveFiniteNumber(Number(process.env.FORGE_DOCKER_RECOVERY_COOLDOWN_MS ?? 10 * 60_000), 10 * 60_000),
1262
+ dockerRecoveryStartupTimeoutMs: positiveFiniteNumber(Number(process.env.FORGE_DOCKER_RECOVERY_STARTUP_TIMEOUT_MS ?? 2 * 60_000), 2 * 60_000),
761
1263
  sandboxStateRoot: process.env.FORGE_SANDBOX_STATE_ROOT ?? path.join(os.homedir(), ".forge", "bridge", "sandboxes"),
762
1264
  requireRootlessDocker: process.env.FORGE_REQUIRE_ROOTLESS_DOCKER === "true" || process.platform === "linux",
763
1265
  diskHighWatermarkPercent: positiveFiniteNumber(Number(process.env.FORGE_DISK_HIGH_WATERMARK_PERCENT ?? 85), 85),
764
1266
  diskLowWatermarkPercent: positiveFiniteNumber(Number(process.env.FORGE_DISK_LOW_WATERMARK_PERCENT ?? 75), 75),
765
1267
  minimumWorkspaceBytes: positiveFiniteNumber(Number(process.env.FORGE_MINIMUM_WORKSPACE_BYTES ?? 10 * 1024 ** 3), 10 * 1024 ** 3),
1268
+ // Undefined means preflight derives the bounded reserve from the actual
1269
+ // Docker/host capacity domain after sampling it.
1270
+ sandboxHostMemoryReserveBytes: configuredSandboxHostMemoryReserveBytes,
1271
+ defaultSandboxWorkerMemoryLimitBytes: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_DEFAULT_WORKER_MEMORY_LIMIT_BYTES ?? 2 * 1024 ** 3), 2 * 1024 ** 3),
1272
+ defaultSandboxServiceMemoryLimitBytes: nonNegativeFiniteNumber(Number(process.env.FORGE_SANDBOX_DEFAULT_SERVICE_MEMORY_LIMIT_BYTES ?? 0), 0),
1273
+ sandboxRuntimeOverheadBytes: Math.max(128 * 1024 ** 2, nonNegativeFiniteNumber(Number(process.env.FORGE_SANDBOX_RUNTIME_OVERHEAD_BYTES ?? 128 * 1024 ** 2), 128 * 1024 ** 2)),
1274
+ sandboxMemorySampleIntervalMs: Math.min(30_000, Math.max(250, positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_MEMORY_SAMPLE_INTERVAL_MS ?? 1000), 1000))),
766
1275
  imageRetentionCount: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_IMAGE_RETENTION_COUNT ?? 3), 3),
767
1276
  imageRetentionTtlMs: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_IMAGE_RETENTION_TTL_MS ?? 7 * 24 * 60 * 60 * 1000), 7 * 24 * 60 * 60 * 1000),
768
1277
  auditRetentionCount: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_AUDIT_RETENTION_COUNT ?? 200), 200),
@@ -788,6 +1297,7 @@ export function defaultBridgeServiceConfig(overrides = {}) {
788
1297
  protocolVersion: 2,
789
1298
  minServerVersion: null,
790
1299
  heartbeatIntervalMs: positiveFiniteNumber(Number(process.env.FORGE_HEARTBEAT_INTERVAL_MS ?? 30_000), 30_000),
1300
+ callbackAckTimeoutMs: positiveFiniteNumber(Number(process.env.FORGE_CALLBACK_ACK_TIMEOUT_MS ?? 30_000), 30_000),
791
1301
  reconnectBackoffMs: [1000, 2000, 5000, 10000, 30000],
792
1302
  ...overrides
793
1303
  };
@@ -796,7 +1306,8 @@ export function defaultBridgeServiceConfig(overrides = {}) {
796
1306
  providerStatuses: overrides.providerStatuses ?? providerStatusesFor(config.cliProviders),
797
1307
  sandboxProfiles: config.sandboxProfiles ?? defaultSandboxProfiles(config.cliProviders),
798
1308
  supportsDraftImageAttachments: (overrides.supportsDraftImageAttachments ?? true) && supportsDraftImageAttachmentsFor(config.cliProviders),
799
- heartbeatIntervalMs: positiveFiniteNumber(config.heartbeatIntervalMs, 30_000)
1309
+ heartbeatIntervalMs: positiveFiniteNumber(config.heartbeatIntervalMs, 30_000),
1310
+ callbackAckTimeoutMs: positiveFiniteNumber(config.callbackAckTimeoutMs ?? 30_000, 30_000),
800
1311
  };
801
1312
  }
802
1313
  export function resolveBridgeRunnerHubUrl(serverUrl) {
@@ -811,6 +1322,15 @@ function normalizeServerUrl(serverUrl) {
811
1322
  function positiveFiniteNumber(value, fallback) {
812
1323
  return Number.isFinite(value) && value > 0 ? value : fallback;
813
1324
  }
1325
+ function optionalPositiveFiniteNumber(value) {
1326
+ if (value === undefined || value.trim().length === 0)
1327
+ return undefined;
1328
+ const parsed = Number(value);
1329
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
1330
+ }
1331
+ function nonNegativeFiniteNumber(value, fallback) {
1332
+ return Number.isFinite(value) && value >= 0 ? value : fallback;
1333
+ }
814
1334
  function providerStatusesFor(providers) {
815
1335
  return Object.fromEntries(providers.map((provider) => [provider, { signedIn: true, version: "", models: configuredModelsFor(provider) }]));
816
1336
  }
@@ -932,10 +1452,11 @@ function normalizeQaAppLaunchTarget(target) {
932
1452
  command: cleanString(target.command),
933
1453
  working_directory: cleanString(target.working_directory),
934
1454
  ready_url: cleanString(target.ready_url),
1455
+ managed_runtime_port: cleanString(target.managed_runtime_port),
935
1456
  routes,
936
1457
  notes: cleanString(target.notes)
937
1458
  };
938
- return normalized.url || normalized.command || normalized.ready_url || routes.length > 0
1459
+ return normalized.url || normalized.command || normalized.ready_url || normalized.managed_runtime_port
939
1460
  ? normalized
940
1461
  : null;
941
1462
  }
@@ -977,39 +1498,67 @@ function formatDelay(ms) {
977
1498
  }
978
1499
  return `${ms}ms`;
979
1500
  }
1501
+ async function waitForCallbackAck(callback, methodName, timeoutMs) {
1502
+ let timeout = null;
1503
+ const expired = new Promise((_resolve, reject) => {
1504
+ timeout = setTimeout(() => {
1505
+ reject(new Error(`bridge_callback_timeout: ${methodName} callback was not acknowledged within ${formatDelay(timeoutMs)}.`));
1506
+ }, timeoutMs);
1507
+ });
1508
+ try {
1509
+ return await Promise.race([callback, expired]);
1510
+ }
1511
+ finally {
1512
+ if (timeout)
1513
+ clearTimeout(timeout);
1514
+ }
1515
+ }
980
1516
  function logBridgeStatus(message) {
981
1517
  process.stderr.write(`[${new Date().toISOString()}] ${message}\n`);
982
1518
  }
983
- function delay(ms, signal) {
1519
+ export function delay(ms, signal) {
984
1520
  if (signal?.aborted) {
985
1521
  return Promise.resolve();
986
1522
  }
987
1523
  return new Promise((resolve) => {
988
- const timeout = setTimeout(resolve, ms);
989
- signal?.addEventListener("abort", () => {
1524
+ const complete = () => {
990
1525
  clearTimeout(timeout);
1526
+ signal?.removeEventListener("abort", complete);
991
1527
  resolve();
992
- }, { once: true });
1528
+ };
1529
+ const timeout = setTimeout(complete, ms);
1530
+ signal?.addEventListener("abort", complete, { once: true });
1531
+ if (signal?.aborted)
1532
+ complete();
993
1533
  });
994
1534
  }
995
- function abortPromise(signal) {
1535
+ export async function waitForPromiseOrAbort(promise, signal) {
996
1536
  if (!signal) {
997
- return new Promise(() => undefined);
1537
+ await promise;
1538
+ return;
998
1539
  }
999
1540
  if (signal.aborted) {
1000
- return Promise.resolve();
1541
+ return;
1542
+ }
1543
+ let resolveAbort;
1544
+ const abort = new Promise(resolve => { resolveAbort = resolve; });
1545
+ signal.addEventListener("abort", resolveAbort, { once: true });
1546
+ try {
1547
+ await Promise.race([promise, abort]);
1548
+ }
1549
+ finally {
1550
+ signal.removeEventListener("abort", resolveAbort);
1001
1551
  }
1002
- return new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
1003
1552
  }
1004
1553
  export function canReapManagedSandboxes(preflight) {
1005
1554
  return canRunDockerMaintenance(preflight);
1006
1555
  }
1007
- function sandboxNetworkPolicyFailure(dispatch) {
1556
+ function sandboxNetworkPolicyFailure(dispatch, config) {
1008
1557
  const sandbox = dispatch.sandbox;
1009
1558
  if (!sandbox)
1010
1559
  return "Protocol-v2 sandbox context is missing.";
1011
1560
  if (sandbox.networkPolicy.mode === "open_public"
1012
- && (sandbox.executionPolicy === "sandbox_required" || process.env.FORGE_ALLOW_OPEN_PUBLIC_SANDBOX !== "true")) {
1561
+ && (sandbox.executionPolicy === "sandbox_required" || !config.allowOpenPublicSandbox)) {
1013
1562
  return "open_public sandbox networking is not permitted by this runner policy.";
1014
1563
  }
1015
1564
  if (sandbox.networkPolicy.mode === "public_allowlist"
@@ -1019,6 +1568,80 @@ function sandboxNetworkPolicyFailure(dispatch) {
1019
1568
  }
1020
1569
  return null;
1021
1570
  }
1571
+ export function infrastructureFailureEventType(summary) {
1572
+ const sandboxFailure = /^(sandbox_[a-z0-9_.-]+|image_[a-z0-9_.-]+|contract_[a-z0-9_.-]+|teardown_[a-z0-9_.-]+):/u.exec(summary)?.[1];
1573
+ if (sandboxFailure)
1574
+ return sandboxFailure;
1575
+ if (summary.startsWith("worktree_prepare_failed:"))
1576
+ return "worktree_prepare_failed";
1577
+ if (summary.startsWith("provider_network_unavailable:"))
1578
+ return "provider_network_unavailable";
1579
+ if (summary.startsWith("provider_start_unavailable:"))
1580
+ return "provider_start_unavailable";
1581
+ if (summary.startsWith("host_port_collision:"))
1582
+ return "host_port_collision";
1583
+ if (summary.startsWith("host_runtime_process_exited:"))
1584
+ return "host_runtime_process_exited";
1585
+ if (summary.startsWith("host_runtime_listener_timeout:"))
1586
+ return "host_runtime_listener_timeout";
1587
+ if (summary.startsWith("host_runtime_http_readiness_timeout:"))
1588
+ return "host_runtime_http_readiness_timeout";
1589
+ if (summary.startsWith("docker_engine_unavailable:"))
1590
+ return "docker_engine_unavailable";
1591
+ if (summary.startsWith("attempt_process_supervisor_start_failed:"))
1592
+ return "attempt_process_supervisor_start_failed";
1593
+ if (summary.startsWith("attempt_process_supervisor_start_timeout:"))
1594
+ return "attempt_process_supervisor_start_timeout";
1595
+ if (summary.startsWith("bridge_callback_timeout:"))
1596
+ return "bridge_callback_timeout";
1597
+ return null;
1598
+ }
1599
+ export function dockerEngineRequirementFailure(requiredCapabilities, executionMode, capability) {
1600
+ if (!requiredCapabilities.includes("docker_engine"))
1601
+ return null;
1602
+ if (executionMode === "docker") {
1603
+ return "docker_engine_unavailable: docker_engine is not exposed inside Forge agent sandboxes; use a managed host runner with Docker-daemon access.";
1604
+ }
1605
+ return capability?.available
1606
+ ? null
1607
+ : `docker_engine_unavailable: ${capability?.unavailableReason ?? "Docker daemon is unreachable from the bridge process."}`;
1608
+ }
1609
+ function parseReadinessProbe(value) {
1610
+ if (!value || typeof value !== "object" || Array.isArray(value))
1611
+ return null;
1612
+ const request = value;
1613
+ 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;
1614
+ const bounded = (candidate, maximum) => typeof candidate === "string" && candidate.trim().length > 0 && candidate.length <= maximum;
1615
+ if (request.schemaVersion !== 1
1616
+ || !uuid.test(request.requestId ?? "")
1617
+ || !uuid.test(request.fencingToken ?? "")
1618
+ || !uuid.test(request.userId ?? "")
1619
+ || !uuid.test(request.projectId ?? "")
1620
+ || !uuid.test(request.repoId ?? "")
1621
+ || !uuid.test(request.runnerInstanceId ?? "")
1622
+ || !Number.isInteger(request.attempt) || (request.attempt ?? 0) < 1
1623
+ || !bounded(request.repoName, 160)
1624
+ || typeof request.remoteUrl !== "string" || request.remoteUrl.length > 2048
1625
+ || !bounded(request.defaultBranch, 512)
1626
+ || typeof request.cloneOnDemand !== "boolean"
1627
+ || !bounded(request.runnerRepoLocalPath, 4096)
1628
+ || !bounded(request.revision, 512)
1629
+ || !Number.isFinite(Date.parse(request.expiresAt ?? ""))
1630
+ || Date.parse(request.expiresAt ?? "") <= Date.now()) {
1631
+ return null;
1632
+ }
1633
+ return request;
1634
+ }
1635
+ function sandboxProviderNetworkFailure(dispatch, config) {
1636
+ const sandbox = dispatch.sandbox;
1637
+ if (!sandbox || sandbox.networkPolicy.mode !== "offline")
1638
+ return null;
1639
+ const provider = dispatch.execution.provider.trim().toLowerCase();
1640
+ const isNetworkProvider = config.cliProviders.some(candidate => candidate.trim().toLowerCase() === provider);
1641
+ if (!isNetworkProvider)
1642
+ return null;
1643
+ return `Provider ${dispatch.execution.provider} requires outbound network access, but this Docker attempt uses the offline network policy.`;
1644
+ }
1022
1645
  function sandboxAuthFailure(dispatch, config) {
1023
1646
  const unsupported = sandboxProviderAuthUnavailableMessage(dispatch.execution.provider, dispatch.execution.authKind);
1024
1647
  if (unsupported) {
@@ -1038,6 +1661,15 @@ function sandboxAuthFailure(dispatch, config) {
1038
1661
  const doctor = adapter.doctor();
1039
1662
  return doctor.available ? null : doctor.message;
1040
1663
  }
1664
+ function rejectedOfferReservation(code, message) {
1665
+ return {
1666
+ accepted: false,
1667
+ code,
1668
+ message,
1669
+ reservedUntil: null,
1670
+ resources: null,
1671
+ };
1672
+ }
1041
1673
  export function canReserveSandboxResources(used, requested, capacity) {
1042
1674
  return used.sandboxSlots + requested.sandboxSlots <= capacity.sandboxSlots
1043
1675
  && used.cpuMillis + requested.cpuMillis <= capacity.cpuMillis
@@ -1046,10 +1678,52 @@ export function canReserveSandboxResources(used, requested, capacity) {
1046
1678
  && used.pids + requested.pids <= capacity.pids
1047
1679
  && used.buildSlots + requested.buildSlots <= capacity.buildSlots;
1048
1680
  }
1681
+ export function sandboxMemoryCommitmentCapacity(preflight, activeMemoryBytes, ambientMemoryCommitmentBytes = 0) {
1682
+ const ambientCapacity = preflight.availableMemoryBytes
1683
+ - preflight.hostReserveBytes
1684
+ + activeMemoryBytes;
1685
+ const commitmentCapacity = preflight.totalMemoryBytes
1686
+ - preflight.hostReserveBytes
1687
+ - Math.max(0, ambientMemoryCommitmentBytes);
1688
+ return Math.max(0, Math.min(ambientCapacity, commitmentCapacity));
1689
+ }
1690
+ export function sandboxCapacityFailureMessage(used, requested, capacity, preflight, config) {
1691
+ const availableSlots = Math.max(0, capacity.sandboxSlots - used.sandboxSlots);
1692
+ if (requested.sandboxSlots > availableSlots) {
1693
+ return `Sandbox slot capacity is insufficient: requested ${requested.sandboxSlots}, available ${availableSlots}, ${used.sandboxSlots} already reserved.`;
1694
+ }
1695
+ const availableMemoryBytes = Math.max(0, capacity.memoryBytes - used.memoryBytes);
1696
+ if (requested.memoryBytes > availableMemoryBytes) {
1697
+ return [
1698
+ `Sandbox memory capacity is insufficient: requested ${formatBytes(requested.memoryBytes)} total`,
1699
+ `(worker ${formatBytes(requested.workerMemoryLimitBytes)}, services ${formatBytes(requested.serviceMemoryLimitBytes)}, runtime overhead ${formatBytes(requested.runtimeOverheadBytes)}),`,
1700
+ `but ${formatBytes(availableMemoryBytes)} is available for admission.`,
1701
+ `Docker reports ${formatBytes(preflight?.availableMemoryBytes ?? 0)} available of ${formatBytes(preflight?.totalMemoryBytes ?? 0)};`,
1702
+ `Forge reserves ${formatBytes(preflight?.hostReserveBytes ?? config.sandboxHostMemoryReserveBytes ?? 0)} for the host and tracks ${formatBytes(used.memoryBytes)} in active or reserved attempts.`,
1703
+ ].join(" ");
1704
+ }
1705
+ const availableCpuMillis = Math.max(0, capacity.cpuMillis - used.cpuMillis);
1706
+ if (requested.cpuMillis > availableCpuMillis) {
1707
+ return `Sandbox CPU capacity is insufficient: requested ${requested.cpuMillis} millis, available ${availableCpuMillis} millis, ${used.cpuMillis} millis already reserved.`;
1708
+ }
1709
+ const availableWorkspaceBytes = Math.max(0, capacity.workspaceBytes - used.workspaceBytes);
1710
+ if (requested.workspaceBytes > availableWorkspaceBytes) {
1711
+ return `Sandbox workspace capacity is insufficient: requested ${formatBytes(requested.workspaceBytes)}, available ${formatBytes(availableWorkspaceBytes)}, ${formatBytes(used.workspaceBytes)} already reserved.`;
1712
+ }
1713
+ const availablePids = Math.max(0, capacity.pids - used.pids);
1714
+ if (requested.pids > availablePids) {
1715
+ return `Sandbox PID capacity is insufficient: requested ${requested.pids}, available ${availablePids}, ${used.pids} already reserved.`;
1716
+ }
1717
+ const availableBuildSlots = Math.max(0, capacity.buildSlots - used.buildSlots);
1718
+ return `Sandbox build capacity is insufficient: requested ${requested.buildSlots}, available ${availableBuildSlots}, ${used.buildSlots} already reserved.`;
1719
+ }
1049
1720
  function sumSandboxResources(resources) {
1050
1721
  return resources.reduce((total, resource) => ({
1051
1722
  sandboxSlots: total.sandboxSlots + resource.sandboxSlots,
1052
1723
  cpuMillis: total.cpuMillis + resource.cpuMillis,
1724
+ workerMemoryLimitBytes: total.workerMemoryLimitBytes + resource.workerMemoryLimitBytes,
1725
+ serviceMemoryLimitBytes: total.serviceMemoryLimitBytes + resource.serviceMemoryLimitBytes,
1726
+ runtimeOverheadBytes: total.runtimeOverheadBytes + resource.runtimeOverheadBytes,
1053
1727
  memoryBytes: total.memoryBytes + resource.memoryBytes,
1054
1728
  workspaceBytes: total.workspaceBytes + resource.workspaceBytes,
1055
1729
  pids: total.pids + resource.pids,
@@ -1057,18 +1731,91 @@ function sumSandboxResources(resources) {
1057
1731
  }), zeroSandboxResources());
1058
1732
  }
1059
1733
  function zeroSandboxResources() {
1060
- return { sandboxSlots: 0, cpuMillis: 0, memoryBytes: 0, workspaceBytes: 0, pids: 0, buildSlots: 0 };
1734
+ return {
1735
+ sandboxSlots: 0,
1736
+ cpuMillis: 0,
1737
+ workerMemoryLimitBytes: 0,
1738
+ serviceMemoryLimitBytes: 0,
1739
+ runtimeOverheadBytes: 0,
1740
+ memoryBytes: 0,
1741
+ workspaceBytes: 0,
1742
+ pids: 0,
1743
+ buildSlots: 0,
1744
+ };
1061
1745
  }
1062
1746
  function defaultSandboxResources() {
1063
1747
  return {
1064
1748
  sandboxSlots: 1,
1065
1749
  cpuMillis: 2000,
1066
- memoryBytes: 2 * 1024 ** 3,
1750
+ workerMemoryLimitBytes: 2 * 1024 ** 3,
1751
+ serviceMemoryLimitBytes: 0,
1752
+ runtimeOverheadBytes: 128 * 1024 ** 2,
1753
+ memoryBytes: 2 * 1024 ** 3 + 128 * 1024 ** 2,
1067
1754
  workspaceBytes: 10 * 1024 ** 3,
1068
1755
  pids: 512,
1069
1756
  buildSlots: 0
1070
1757
  };
1071
1758
  }
1759
+ export function resolveDynamicSandboxResources(requested, serviceCount, networkMode, baseRuntimeOverheadBytes = 128 * 1024 ** 2) {
1760
+ const serviceMemoryLimitBytes = Math.max(0, serviceCount) * 1024 ** 3;
1761
+ const runtimeOverheadBytes = Math.max(128 * 1024 ** 2, baseRuntimeOverheadBytes)
1762
+ + (networkMode === "public_allowlist" ? 256 * 1024 ** 2 : 0);
1763
+ return {
1764
+ ...requested,
1765
+ serviceMemoryLimitBytes,
1766
+ runtimeOverheadBytes,
1767
+ memoryBytes: requested.workerMemoryLimitBytes + serviceMemoryLimitBytes + runtimeOverheadBytes,
1768
+ };
1769
+ }
1770
+ export async function resolveSandboxResourcesFromRepo(dispatch, baseRuntimeOverheadBytes = 128 * 1024 ** 2) {
1771
+ const repoPath = dispatch.repo.serverLocalPath;
1772
+ const sandbox = dispatch.sandbox;
1773
+ if (!repoPath || !sandbox) {
1774
+ throw new Error("Sandbox admission needs a mapped repository.");
1775
+ }
1776
+ const selection = selectSandboxConfigRefs(sandbox.executionPolicy, sandbox.sandboxConfigCommitSha, dispatch.repo.defaultBranch);
1777
+ const configCommitSha = await resolveFirstGitCommit(repoPath, selection.refs);
1778
+ if (!configCommitSha) {
1779
+ throw new Error("Sandbox admission could not resolve the immutable sandbox config commit.");
1780
+ }
1781
+ const contractResult = await runSandboxProcess({
1782
+ command: "git",
1783
+ args: ["-C", repoPath, "show", `${configCommitSha}:.forge/forge.sandbox.yml`],
1784
+ timeoutMs: 30_000,
1785
+ maxOutputBytes: 1024 * 1024,
1786
+ });
1787
+ if (contractResult.exitCode !== 0) {
1788
+ throw new Error("The approved immutable sandbox contract is unavailable in the mapped repository.");
1789
+ }
1790
+ const contract = validateSandboxContract(contractResult.stdout);
1791
+ const workspaceBytes = await estimateWorkspaceBytesFromRepo(repoPath, {
1792
+ role: dispatch.execution.role,
1793
+ serviceCount: contract.contract.services.length,
1794
+ browser: contract.contract.browser,
1795
+ });
1796
+ return {
1797
+ resources: resolveDynamicSandboxResources({
1798
+ ...sandbox.requestedResources,
1799
+ workspaceBytes: Math.max(sandbox.requestedResources.workspaceBytes, workspaceBytes),
1800
+ }, contract.contract.services.length, sandbox.networkPolicy.mode, baseRuntimeOverheadBytes),
1801
+ sandboxConfigCommitSha: configCommitSha,
1802
+ };
1803
+ }
1804
+ async function resolveFirstGitCommit(repoPath, refs) {
1805
+ for (const ref of refs) {
1806
+ const result = await runSandboxProcess({
1807
+ command: "git",
1808
+ args: ["-C", repoPath, "rev-parse", "--verify", `${ref}^{commit}`],
1809
+ timeoutMs: 30_000,
1810
+ maxOutputBytes: 1024 * 1024,
1811
+ });
1812
+ const commitSha = result.stdout.trim().toLowerCase();
1813
+ if (result.exitCode === 0 && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u.test(commitSha)) {
1814
+ return commitSha;
1815
+ }
1816
+ }
1817
+ return null;
1818
+ }
1072
1819
  function parseOfferEnvelope(value) {
1073
1820
  return parseDispatchEnvelope(value);
1074
1821
  }