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

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 (118) hide show
  1. package/README.md +34 -0
  2. package/dist/bridge/bridge.base.d.ts +2 -1
  3. package/dist/bridge/bridge.service.d.ts +22 -3
  4. package/dist/bridge/bridge.service.js +416 -40
  5. package/dist/bridge/bridge.types.d.ts +59 -1
  6. package/dist/cleanup/cleanup.service.d.ts +13 -2
  7. package/dist/cleanup/cleanup.service.js +435 -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 +182 -23
  16. package/dist/cli/local-config.d.ts +4 -0
  17. package/dist/cli/local-config.js +41 -1
  18. package/dist/runtime/attempt-node-runtime-launcher.d.ts +17 -0
  19. package/dist/runtime/attempt-node-runtime-launcher.js +101 -0
  20. package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
  21. package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
  22. package/dist/runtime/attempt-process-supervisor.d.ts +86 -0
  23. package/dist/runtime/attempt-process-supervisor.js +343 -0
  24. package/dist/runtime/generated-output-reset.d.ts +10 -0
  25. package/dist/runtime/generated-output-reset.js +103 -0
  26. package/dist/runtime/host-listener-inspector.d.ts +44 -0
  27. package/dist/runtime/host-listener-inspector.js +324 -0
  28. package/dist/runtime/host-port-broker.d.ts +76 -0
  29. package/dist/runtime/host-port-broker.js +333 -0
  30. package/dist/runtime/host-runtime-contract.d.ts +47 -0
  31. package/dist/runtime/host-runtime-contract.js +700 -0
  32. package/dist/runtime/host-runtime-lease.d.ts +133 -0
  33. package/dist/runtime/host-runtime-lease.js +1460 -0
  34. package/dist/runtime/host-runtime-promotion.d.ts +23 -0
  35. package/dist/runtime/host-runtime-promotion.js +159 -0
  36. package/dist/runtime/node-host-runtime-adapter.d.ts +51 -0
  37. package/dist/runtime/node-host-runtime-adapter.js +268 -0
  38. package/dist/runtime/playwright-host-runtime-adapter.d.ts +20 -0
  39. package/dist/runtime/playwright-host-runtime-adapter.js +220 -0
  40. package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
  41. package/dist/runtime/runtime-isolation-lease.js +158 -0
  42. package/dist/sandbox/preflight.d.ts +14 -0
  43. package/dist/sandbox/preflight.js +91 -12
  44. package/dist/sandbox/process.js +125 -40
  45. package/dist/sandbox/repository-lock.js +37 -1
  46. package/dist/sandbox/runtime.d.ts +66 -3
  47. package/dist/sandbox/runtime.js +670 -105
  48. package/dist/sandbox/sandbox.types.d.ts +3 -0
  49. package/dist/sandbox/smoke.d.ts +76 -0
  50. package/dist/sandbox/smoke.js +380 -0
  51. package/dist/sandbox/worker-supervisor.js +1 -1
  52. package/dist/sandbox/worker.js +2 -5
  53. package/dist/sandbox/workspace.service.d.ts +1 -0
  54. package/dist/sandbox/workspace.service.js +28 -5
  55. package/dist/session/managed-attempt-tools.d.ts +12 -0
  56. package/dist/session/managed-attempt-tools.js +350 -0
  57. package/dist/session/phase-completion.artifacts.js +9 -2
  58. package/dist/session/phase-completion.d.ts +4 -2
  59. package/dist/session/phase-completion.helpers.js +27 -3
  60. package/dist/session/phase-completion.js +12 -3
  61. package/dist/session/phase-completion.prompts.d.ts +3 -3
  62. package/dist/session/phase-completion.prompts.js +19 -5
  63. package/dist/session/phase-completion.runtime.d.ts +12 -1
  64. package/dist/session/phase-completion.runtime.js +127 -7
  65. package/dist/session/phase-completion.schemas.js +108 -3
  66. package/dist/session/phase-completion.validation.d.ts +4 -1
  67. package/dist/session/phase-completion.validation.js +106 -2
  68. package/dist/session/provider-cli/ci-repair-readiness.d.ts +8 -0
  69. package/dist/session/provider-cli/ci-repair-readiness.js +90 -0
  70. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +1 -1
  71. package/dist/session/provider-cli/provider-cli.artifacts.js +28 -4
  72. package/dist/session/provider-cli/provider-cli.deterministic.js +47 -1
  73. package/dist/session/provider-cli/provider-cli.process.d.ts +16 -0
  74. package/dist/session/provider-cli/provider-cli.process.js +212 -25
  75. package/dist/session/provider-cli/provider-cli.prompts.d.ts +4 -1
  76. package/dist/session/provider-cli/provider-cli.prompts.js +84 -17
  77. package/dist/session/provider-cli/provider-cli.qa-sidecars.js +1 -0
  78. package/dist/session/provider-cli/provider-cli.runtime.d.ts +1 -1
  79. package/dist/session/provider-cli/provider-cli.runtime.js +3 -2
  80. package/dist/session/provider-cli/provider-cli.service.d.ts +3 -2
  81. package/dist/session/provider-cli/provider-cli.service.js +254 -71
  82. package/dist/session/provider-cli/provider-cli.types.d.ts +5 -0
  83. package/dist/session/provider-cli/provider-output-state.d.ts +2 -0
  84. package/dist/session/provider-cli/provider-output-state.js +30 -2
  85. package/dist/session/session-redaction.d.ts +3 -0
  86. package/dist/session/session-redaction.js +23 -0
  87. package/dist/session/session.base.d.ts +6 -1
  88. package/dist/session/session.service.d.ts +8 -2
  89. package/dist/session/session.service.js +436 -41
  90. package/dist/session/session.types.d.ts +55 -0
  91. package/dist/session/session.types.js +69 -2
  92. package/dist/shared/path/path.js +3 -1
  93. package/dist/shared/process/host-process-recovery.d.ts +12 -0
  94. package/dist/shared/process/host-process-recovery.js +78 -0
  95. package/dist/shared/process/host-process-supervisor.d.ts +57 -0
  96. package/dist/shared/process/host-process-supervisor.js +282 -0
  97. package/dist/shared/process/process.d.ts +1 -0
  98. package/dist/shared/process/process.js +1 -0
  99. package/dist/shared/redaction/redaction.js +6 -1
  100. package/dist/version.d.ts +1 -1
  101. package/dist/version.js +1 -1
  102. package/dist/worktree/artifact-injector.d.ts +3 -2
  103. package/dist/worktree/artifact-injector.js +43 -4
  104. package/dist/worktree/dependency-warmer.d.ts +10 -1
  105. package/dist/worktree/dependency-warmer.js +94 -5
  106. package/dist/worktree/index.d.ts +1 -0
  107. package/dist/worktree/index.js +1 -0
  108. package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
  109. package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
  110. package/dist/worktree/worktree-paths.d.ts +4 -0
  111. package/dist/worktree/worktree-paths.js +30 -0
  112. package/dist/worktree/worktree.base.d.ts +3 -0
  113. package/dist/worktree/worktree.service.d.ts +7 -2
  114. package/dist/worktree/worktree.service.js +72 -44
  115. package/dist/worktree/worktree.types.d.ts +3 -1
  116. package/package.json +3 -2
  117. package/runtime/windows-job-supervisor.ps1 +356 -0
  118. package/runtime/windows-listener-owner.ps1 +33 -0
@@ -5,19 +5,29 @@ 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 { GitWorktreeService } from "../worktree/worktree.service.js";
14
+ import { RepositoryCoordinatorLock } from "../sandbox/repository-lock.js";
15
+ import { AttemptCoordinatedProviderProcessRunner, AttemptProcessSupervisorRegistry, } from "../runtime/attempt-process-supervisor-registry.js";
10
16
  import { failureResult, parseRunOnceDispatchPacket } from "../session/session.types.js";
11
17
  import { forgeBridgeVersion } from "../version.js";
12
18
  import { canRunDockerMaintenance, DockerPreflight } from "../sandbox/preflight.js";
13
- import { DockerSandboxRuntime } from "../sandbox/runtime.js";
19
+ import { validateSandboxContract } from "../sandbox/contract.js";
20
+ import { runSandboxProcess } from "../sandbox/process.js";
21
+ import { DockerSandboxRuntime, selectSandboxConfigRefs } from "../sandbox/runtime.js";
14
22
  import { codexSandboxProviderAuthAdapter, sandboxProviderAuthAdapterFor, sandboxProviderAuthUnavailableMessage } from "../sandbox/provider-auth/codex.js";
15
23
  export class ForgeBridgeService {
16
24
  config;
17
- sessions;
18
25
  connections;
19
26
  connection = null;
20
27
  heartbeatTimer = null;
28
+ heartbeatInFlight = false;
29
+ consecutiveHeartbeatFailures = 0;
30
+ reconnectingFromHeartbeatFailure = false;
21
31
  leaseWatchdogTimer = null;
22
32
  sandboxMaintenanceTimer = null;
23
33
  sandboxMaintenanceRunning = false;
@@ -25,12 +35,15 @@ export class ForgeBridgeService {
25
35
  stopped = false;
26
36
  activeSessions = new Map();
27
37
  reservations = new Map();
38
+ activeReadinessProbes = new Map();
28
39
  preflight;
29
40
  sandboxRuntime;
30
- constructor(config, sessions = new BridgeSessionService(), connections = new SignalRBridgeConnectionFactory(), preflight, sandboxRuntime) {
41
+ sandboxResourceResolver;
42
+ sessions;
43
+ constructor(config, sessions = undefined, connections = new SignalRBridgeConnectionFactory(), preflight, sandboxRuntime, sandboxResourceResolver) {
31
44
  this.config = config;
32
- this.sessions = sessions;
33
45
  this.connections = connections;
46
+ this.sessions = sessions ?? createDefaultSessionService(config.sandboxStateRoot);
34
47
  this.preflight = preflight ?? new DockerPreflight({
35
48
  dockerExecutable: config.dockerExecutable,
36
49
  dockerContext: config.dockerContext,
@@ -40,17 +53,31 @@ export class ForgeBridgeService {
40
53
  diskHighWatermarkPercent: config.diskHighWatermarkPercent,
41
54
  diskLowWatermarkPercent: config.diskLowWatermarkPercent,
42
55
  minimumWorkspaceBytes: config.minimumWorkspaceBytes,
56
+ hostMemoryReserveBytes: config.sandboxHostMemoryReserveBytes,
57
+ defaultWorkerMemoryLimitBytes: config.defaultSandboxWorkerMemoryLimitBytes,
58
+ defaultServiceMemoryLimitBytes: config.defaultSandboxServiceMemoryLimitBytes,
59
+ runtimeOverheadBytes: config.sandboxRuntimeOverheadBytes,
43
60
  requireRootless: config.requireRootlessDocker
44
61
  });
45
62
  this.sandboxRuntime = sandboxRuntime ?? new DockerSandboxRuntime(config, (request, signal) => this.redeemPublishGrant(request, signal));
63
+ this.sandboxResourceResolver = sandboxResourceResolver
64
+ ?? (dispatch => resolveSandboxResourcesFromRepo(dispatch, this.config.sandboxRuntimeOverheadBytes));
46
65
  }
47
66
  async connectOnce() {
48
67
  await this.stopConnection();
49
68
  this.stopped = false;
69
+ const hostReap = await this.sessions.reapHostRuntime?.({
70
+ runnerId: this.config.runnerId,
71
+ runnerInstanceId: this.config.runnerInstanceId,
72
+ });
73
+ if (hostReap && (hostReap.reaped > 0 || hostReap.degraded > 0)) {
74
+ 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.`);
75
+ }
50
76
  if (this.config.dockerEnabled) {
51
77
  const preflight = await this.preflight.refresh();
52
78
  if (canReapManagedSandboxes(preflight)) {
53
79
  await this.sandboxRuntime.reap({ diskLow: preflight.diskLow });
80
+ await this.preflight.refresh();
54
81
  }
55
82
  }
56
83
  const connection = this.connections.create(this.config);
@@ -59,6 +86,7 @@ export class ForgeBridgeService {
59
86
  connection.onClose(() => {
60
87
  this.stopHeartbeat();
61
88
  this.abortActiveSessions("bridge connection closed");
89
+ this.abortActiveReadinessProbes("bridge connection closed");
62
90
  resolve();
63
91
  });
64
92
  });
@@ -68,6 +96,7 @@ export class ForgeBridgeService {
68
96
  connection.onLeaseUpdate?.((request) => this.handleLeaseUpdate(request));
69
97
  connection.onCancel((request) => this.handleCancel(request));
70
98
  connection.onReap?.((request) => this.handleReap(request));
99
+ connection.onReadinessProbe?.((request) => this.handleReadinessProbe(request));
71
100
  await connection.start();
72
101
  await this.invokeAck("Hello", {
73
102
  version: this.config.version,
@@ -110,6 +139,7 @@ export class ForgeBridgeService {
110
139
  finally {
111
140
  if (signal?.aborted) {
112
141
  this.abortActiveSessions(String(signal.reason ?? "bridge stopping"));
142
+ this.abortActiveReadinessProbes(String(signal.reason ?? "bridge stopping"));
113
143
  }
114
144
  await this.stopConnection();
115
145
  }
@@ -117,6 +147,7 @@ export class ForgeBridgeService {
117
147
  async stop() {
118
148
  this.stopped = true;
119
149
  this.abortActiveSessions("bridge stopping");
150
+ this.abortActiveReadinessProbes("bridge stopping");
120
151
  await this.stopConnection();
121
152
  await this.preflight.release();
122
153
  }
@@ -174,7 +205,7 @@ export class ForgeBridgeService {
174
205
  dispatchAttemptId: dispatch.sandbox.dispatchAttemptId,
175
206
  runnerInstanceId: dispatch.sandbox.runnerInstanceId
176
207
  } : {}),
177
- event: redactSecrets(event, redactionSecrets)
208
+ event: redactRunnerDispatchEvent(event, redactionSecrets)
178
209
  });
179
210
  }
180
211
  catch (error) {
@@ -192,13 +223,20 @@ export class ForgeBridgeService {
192
223
  if (normalized.sandbox?.executionMode === "docker") {
193
224
  await this.flushCleanupReports().catch(() => undefined);
194
225
  }
195
- await this.sendResult(dispatch, redactSecrets(result, redactionSecrets));
226
+ await this.sendResult(dispatch, redactRunOnceResultPacket(result, redactionSecrets));
196
227
  }
197
228
  catch (error) {
198
229
  const summary = controller.signal.aborted
199
230
  ? `Dispatch cancelled: ${String(controller.signal.reason ?? "cancelled")}`
200
231
  : redactText(error instanceof Error ? error.message : String(error), redactionSecrets);
201
- await this.sendResult(dispatch, failureResult(summary)).catch(() => undefined);
232
+ const failureEventType = infrastructureFailureEventType(summary);
233
+ const failureEvents = failureEventType ? [{
234
+ role: "runner",
235
+ kind: "error",
236
+ content: summary,
237
+ eventType: failureEventType,
238
+ }] : [];
239
+ await this.sendResult(dispatch, failureResult(summary, failureEvents)).catch(() => undefined);
202
240
  }
203
241
  finally {
204
242
  this.activeSessions.delete(leaseId);
@@ -223,7 +261,7 @@ export class ForgeBridgeService {
223
261
  }
224
262
  if (existing) {
225
263
  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);
264
+ 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
265
  return;
228
266
  }
229
267
  if (sandbox.runnerInstanceId !== this.config.runnerInstanceId) {
@@ -234,17 +272,24 @@ export class ForgeBridgeService {
234
272
  await this.sendOfferDecision(dispatch, false, "lease_expired", "Offer lease is already expired.");
235
273
  return;
236
274
  }
237
- const preflight = this.preflight.current();
275
+ const preflight = sandbox.executionMode === "docker" && this.config.dockerEnabled
276
+ ? await this.preflight.run()
277
+ : this.preflight.current();
238
278
  if (sandbox.executionMode === "docker") {
239
279
  if (!this.config.dockerEnabled || !preflight?.healthy) {
240
280
  await this.sendOfferDecision(dispatch, false, preflight?.unavailableCode ?? "docker_unavailable", preflight?.unavailableReason ?? "Docker sandbox preflight is unavailable.");
241
281
  return;
242
282
  }
243
- const networkFailure = sandboxNetworkPolicyFailure(dispatch);
283
+ const networkFailure = sandboxNetworkPolicyFailure(dispatch, this.config);
244
284
  if (networkFailure) {
245
285
  await this.sendOfferDecision(dispatch, false, "policy_mismatch", networkFailure);
246
286
  return;
247
287
  }
288
+ const providerNetworkFailure = sandboxProviderNetworkFailure(dispatch, this.config);
289
+ if (providerNetworkFailure) {
290
+ await this.sendOfferDecision(dispatch, false, "provider_network_unavailable", providerNetworkFailure);
291
+ return;
292
+ }
248
293
  const authFailure = sandboxAuthFailure(dispatch, this.config);
249
294
  if (authFailure) {
250
295
  await this.sendOfferDecision(dispatch, false, "sandbox_auth_unavailable", authFailure);
@@ -253,10 +298,24 @@ export class ForgeBridgeService {
253
298
  }
254
299
  const runtimeState = this.sandboxRuntime.snapshot();
255
300
  const usedResources = this.usedSandboxResources(runtimeState.degraded);
256
- const capacity = this.sandboxResourceCapacity(preflight);
301
+ const activeResources = this.activeSandboxResources(runtimeState.degraded);
302
+ const capacity = this.sandboxResourceCapacity(preflight, activeResources);
303
+ let requestedResources = sandbox.requestedResources;
304
+ let sandboxConfigCommitSha = sandbox.sandboxConfigCommitSha ?? null;
305
+ if (sandbox.executionMode === "docker") {
306
+ try {
307
+ const profile = await this.sandboxResourceResolver(this.normalizeDispatch(dispatch));
308
+ requestedResources = profile.resources;
309
+ sandboxConfigCommitSha = profile.sandboxConfigCommitSha;
310
+ }
311
+ catch (error) {
312
+ await this.sendOfferDecision(dispatch, false, "sandbox_capacity_profile_unavailable", error instanceof Error ? error.message : "The immutable sandbox contract could not be profiled for admission.");
313
+ return;
314
+ }
315
+ }
257
316
  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.");
317
+ && !canReserveSandboxResources(usedResources, requestedResources, capacity)) {
318
+ await this.sendOfferDecision(dispatch, false, "capacity_unavailable", sandboxCapacityFailureMessage(usedResources, requestedResources, capacity, preflight, this.config));
260
319
  return;
261
320
  }
262
321
  const reservedUntil = new Date(Math.min(Date.parse(dispatch.lease.expiresAt), Date.now() + 30_000)).toISOString();
@@ -267,9 +326,9 @@ export class ForgeBridgeService {
267
326
  expiresAt: dispatch.lease.expiresAt,
268
327
  reservedUntil,
269
328
  sandboxed: sandbox.executionMode === "docker",
270
- resources: sandbox.requestedResources
329
+ resources: requestedResources
271
330
  });
272
- await this.sendOfferDecision(dispatch, true, null, null, reservedUntil);
331
+ await this.sendOfferDecision(dispatch, true, null, null, reservedUntil, requestedResources, sandboxConfigCommitSha);
273
332
  }
274
333
  async handleStart(rawDispatch) {
275
334
  const envelope = parseStartEnvelope(rawDispatch);
@@ -315,6 +374,10 @@ export class ForgeBridgeService {
315
374
  await this.sendResult(dispatch, failureResult("Start rejected because the provider secret grant handle is missing.")).catch(() => undefined);
316
375
  return;
317
376
  }
377
+ dispatch = {
378
+ ...dispatch,
379
+ sandbox: { ...dispatch.sandbox, requestedResources: reservation.resources },
380
+ };
318
381
  this.reservations.delete(envelope.dispatchAttemptId);
319
382
  await this.handleDispatch(dispatch);
320
383
  }
@@ -355,10 +418,15 @@ export class ForgeBridgeService {
355
418
  const active = this.activeSessions.get(request.leaseId);
356
419
  if (active && active.fencingToken === request.fencingToken && active.dispatchAttemptId === request.dispatchAttemptId) {
357
420
  active.leaseExpiresAt = request.expiresAt;
358
- await this.sandboxRuntime.updateLease(request.dispatchAttemptId, request.fencingToken, request.expiresAt);
421
+ if (active.sandboxed) {
422
+ await this.sandboxRuntime.updateLease(request.dispatchAttemptId, request.fencingToken, request.expiresAt);
423
+ }
424
+ else {
425
+ await this.sessions.updateLease?.(request.dispatchAttemptId, request.fencingToken, request.expiresAt);
426
+ }
359
427
  }
360
428
  }
361
- async sendOfferDecision(dispatch, accepted, code = null, message = null, reservedUntil = null) {
429
+ async sendOfferDecision(dispatch, accepted, code = null, message = null, reservedUntil = null, reservedResources = null, sandboxConfigCommitSha = null) {
362
430
  if (!dispatch.sandbox)
363
431
  return;
364
432
  const payload = {
@@ -369,7 +437,9 @@ export class ForgeBridgeService {
369
437
  accepted,
370
438
  code,
371
439
  message,
372
- reservedUntil: accepted ? reservedUntil ?? dispatch.lease.expiresAt : null
440
+ reservedUntil: accepted ? reservedUntil ?? dispatch.lease.expiresAt : null,
441
+ reservedResources: accepted ? reservedResources : null,
442
+ sandboxConfigCommitSha: accepted ? sandboxConfigCommitSha : null,
373
443
  };
374
444
  await this.invokeAck("OfferDecision", payload);
375
445
  }
@@ -402,19 +472,73 @@ export class ForgeBridgeService {
402
472
  active.controller.abort(request.reason ?? "cancelled by server");
403
473
  }
404
474
  async handleReap(request) {
405
- if (!this.config.dockerEnabled
406
- || request.runnerId !== this.config.runnerId
475
+ if (request.runnerId !== this.config.runnerId
407
476
  || request.runnerInstanceId !== this.config.runnerInstanceId)
408
477
  return;
409
478
  await this.runSandboxMaintenance();
410
479
  await this.sendHeartbeat();
411
480
  }
481
+ async handleReadinessProbe(rawRequest) {
482
+ const request = parseReadinessProbe(rawRequest);
483
+ if (!request || request.runnerInstanceId !== this.config.runnerInstanceId)
484
+ return;
485
+ const existing = this.activeReadinessProbes.get(request.requestId);
486
+ if (existing)
487
+ return;
488
+ const controller = new AbortController();
489
+ this.activeReadinessProbes.set(request.requestId, controller);
490
+ const deadlineMs = Date.parse(request.expiresAt) - Date.now();
491
+ const timer = setTimeout(() => controller.abort("readiness probe expired"), Math.max(0, deadlineMs));
492
+ timer.unref?.();
493
+ let result;
494
+ try {
495
+ if (!this.sessions.probeHostRuntime) {
496
+ throw new Error("host_runtime_probe_unavailable: this bridge does not implement runtime readiness probes.");
497
+ }
498
+ const probe = await this.sessions.probeHostRuntime({
499
+ ...request,
500
+ runnerId: this.config.runnerId,
501
+ repoRoot: this.config.defaultRepoRoot,
502
+ cleanupWorktrees: this.config.cleanupWorktrees,
503
+ }, controller.signal);
504
+ result = {
505
+ schemaVersion: 1,
506
+ requestId: request.requestId,
507
+ fencingToken: request.fencingToken,
508
+ runnerInstanceId: this.config.runnerInstanceId,
509
+ ...probe,
510
+ };
511
+ }
512
+ catch (error) {
513
+ result = {
514
+ schemaVersion: 1,
515
+ requestId: request.requestId,
516
+ fencingToken: request.fencingToken,
517
+ runnerInstanceId: this.config.runnerInstanceId,
518
+ status: "failed",
519
+ reasonCode: "host_runtime_probe_failed",
520
+ reasonMessage: errorMessage(error).slice(0, 1000),
521
+ checkedAt: new Date().toISOString(),
522
+ };
523
+ }
524
+ finally {
525
+ clearTimeout(timer);
526
+ this.activeReadinessProbes.delete(request.requestId);
527
+ }
528
+ await this.invokeAck("ReadinessResult", result).catch(() => undefined);
529
+ await this.sendHeartbeat().catch(() => undefined);
530
+ }
412
531
  abortActiveSessions(reason) {
413
532
  for (const active of this.activeSessions.values()) {
414
533
  active.controller.abort(reason);
415
534
  }
416
535
  this.activeSessions.clear();
417
536
  }
537
+ abortActiveReadinessProbes(reason) {
538
+ for (const controller of this.activeReadinessProbes.values())
539
+ controller.abort(reason);
540
+ this.activeReadinessProbes.clear();
541
+ }
418
542
  normalizeDispatch(dispatch) {
419
543
  const mapping = findRepoMapping(dispatch, repoMappingsFor(this.config));
420
544
  const localPath = mapping?.localPath?.trim();
@@ -465,12 +589,41 @@ export class ForgeBridgeService {
465
589
  }
466
590
  startHeartbeat() {
467
591
  this.stopHeartbeat();
592
+ this.consecutiveHeartbeatFailures = 0;
593
+ this.reconnectingFromHeartbeatFailure = false;
468
594
  const intervalMs = positiveFiniteNumber(this.config.heartbeatIntervalMs, 30_000);
469
595
  this.heartbeatTimer = setInterval(() => {
470
- this.sendHeartbeat().catch(() => undefined);
596
+ void this.heartbeatTick();
471
597
  }, intervalMs);
472
598
  this.heartbeatTimer.unref?.();
473
599
  }
600
+ async heartbeatTick() {
601
+ if (this.heartbeatInFlight || this.reconnectingFromHeartbeatFailure)
602
+ return;
603
+ const connection = this.connection;
604
+ if (!connection)
605
+ return;
606
+ this.heartbeatInFlight = true;
607
+ try {
608
+ await this.sendHeartbeat();
609
+ this.consecutiveHeartbeatFailures = 0;
610
+ }
611
+ catch (error) {
612
+ if (connection !== this.connection || this.stopped)
613
+ return;
614
+ this.consecutiveHeartbeatFailures += 1;
615
+ logBridgeStatus(`Heartbeat ${this.consecutiveHeartbeatFailures} failed: ${errorMessage(error)}.`);
616
+ if (this.consecutiveHeartbeatFailures < 2)
617
+ return;
618
+ this.reconnectingFromHeartbeatFailure = true;
619
+ this.stopHeartbeat();
620
+ logBridgeStatus("Heartbeat failed twice. Closing the stale connection so the Bridge can reconnect...");
621
+ await connection.stop().catch(() => undefined);
622
+ }
623
+ finally {
624
+ this.heartbeatInFlight = false;
625
+ }
626
+ }
474
627
  startLeaseWatchdog() {
475
628
  this.stopLeaseWatchdog();
476
629
  this.leaseWatchdogTimer = setInterval(() => this.expireLeases(), 1000);
@@ -503,8 +656,6 @@ export class ForgeBridgeService {
503
656
  }
504
657
  startSandboxMaintenance() {
505
658
  this.stopSandboxMaintenance();
506
- if (!this.config.dockerEnabled)
507
- return;
508
659
  this.sandboxMaintenanceTimer = setInterval(() => {
509
660
  this.runSandboxMaintenance().catch(() => undefined);
510
661
  }, 60_000);
@@ -521,11 +672,20 @@ export class ForgeBridgeService {
521
672
  return;
522
673
  this.sandboxMaintenanceRunning = true;
523
674
  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();
675
+ const hostReap = await this.sessions.reapHostRuntime?.({
676
+ runnerId: this.config.runnerId,
677
+ runnerInstanceId: this.config.runnerInstanceId,
678
+ });
679
+ if (hostReap && (hostReap.reaped > 0 || hostReap.degraded > 0)) {
680
+ logBridgeStatus(`Managed-host maintenance reaped ${hostReap.reaped} stale attempt(s) and left ${hostReap.degraded} degraded attempt(s) for reconciliation.`);
681
+ }
682
+ if (this.config.dockerEnabled) {
683
+ const preflight = await this.preflight.refresh();
684
+ if (canReapManagedSandboxes(preflight)) {
685
+ const attemptSnapshot = await this.readAttemptSnapshot();
686
+ await this.sandboxRuntime.reap({ diskLow: preflight.diskLow, attemptSnapshot });
687
+ await this.flushCleanupReports();
688
+ }
529
689
  }
530
690
  }
531
691
  finally {
@@ -568,7 +728,8 @@ export class ForgeBridgeService {
568
728
  const runtimeState = this.sandboxRuntime.snapshot();
569
729
  const reserved = [...this.reservations.values()].filter(reservation => reservation.sandboxed).length;
570
730
  const used = this.usedSandboxResources(runtimeState.degraded);
571
- const capacity = this.sandboxResourceCapacity(preflight);
731
+ const activeResources = this.activeSandboxResources(runtimeState.degraded);
732
+ const capacity = this.sandboxResourceCapacity(preflight, activeResources);
572
733
  return {
573
734
  schemaVersion: 3,
574
735
  activeSessionCount: this.activeSessions.size,
@@ -580,7 +741,7 @@ export class ForgeBridgeService {
580
741
  sandboxProfiles: this.config.sandboxProfiles,
581
742
  runnerInstanceId: this.config.runnerInstanceId,
582
743
  trustDomainId: this.config.trustDomainId,
583
- maxConcurrentSandboxes: preflight?.maxConcurrentSandboxes ?? 0,
744
+ maxConcurrentSandboxes: capacity.sandboxSlots,
584
745
  reservedSandboxCount: reserved,
585
746
  activeSandboxCount: runtimeState.active,
586
747
  startingSandboxCount: runtimeState.starting,
@@ -588,6 +749,7 @@ export class ForgeBridgeService {
588
749
  sandboxRuntimes: preflight?.healthy ? ["docker"] : [],
589
750
  sandboxRuntimeVersion: preflight?.runtimeVersion ?? null,
590
751
  sandboxFeatures: preflight?.features ?? null,
752
+ allowsOpenPublicSandbox: this.config.allowOpenPublicSandbox,
591
753
  sandboxPublicationModes: [this.config.sandboxPublicationMode ?? "shared_filesystem"],
592
754
  sandboxAuthModes: sandboxAuthModesFor(this.config),
593
755
  availableMemoryBytes: preflight ? Math.max(0, capacity.memoryBytes - used.memoryBytes) : null,
@@ -597,6 +759,17 @@ export class ForgeBridgeService {
597
759
  preflightGeneration: preflight?.generation ?? 0,
598
760
  sandboxUnavailableCode: this.config.dockerEnabled ? preflight?.unavailableCode ?? null : "docker_disabled",
599
761
  sandboxUnavailableReason: this.config.dockerEnabled ? preflight?.unavailableReason ?? null : "Docker sandboxing is disabled by runner configuration.",
762
+ sandboxMemoryCapacity: preflight ? {
763
+ defaultWorkerMemoryLimitBytes: this.config.defaultSandboxWorkerMemoryLimitBytes,
764
+ defaultServiceMemoryLimitBytes: this.config.defaultSandboxServiceMemoryLimitBytes,
765
+ runtimeOverheadBytes: this.config.sandboxRuntimeOverheadBytes,
766
+ hostReserveBytes: preflight.hostReserveBytes,
767
+ activeReservationBytes: used.memoryBytes,
768
+ daemonTotalBytes: preflight.totalMemoryBytes,
769
+ daemonAvailableBytes: preflight.availableMemoryBytes,
770
+ availableForAdmissionBytes: Math.max(0, capacity.memoryBytes - used.memoryBytes),
771
+ } : null,
772
+ hostRuntime: this.sessions.runtimeCapacity?.() ?? null,
600
773
  supportsApiSecretGrant: this.config.supportsApiSecretGrant,
601
774
  supportsManagedWorktrees: this.config.supportsManagedWorktrees,
602
775
  supportsManagedForgeArtifacts: this.config.supportsManagedForgeArtifacts,
@@ -618,14 +791,32 @@ export class ForgeBridgeService {
618
791
  ];
619
792
  return sumSandboxResources(resources);
620
793
  }
621
- sandboxResourceCapacity(preflight) {
794
+ activeSandboxResources(degradedReservations) {
795
+ return sumSandboxResources([
796
+ ...[...this.activeSessions.values()]
797
+ .filter(session => session.sandboxed)
798
+ .map(session => session.resources),
799
+ ...Array.from({ length: degradedReservations }, () => defaultSandboxResources())
800
+ ]);
801
+ }
802
+ sandboxResourceCapacity(preflight, activeResources) {
622
803
  if (!preflight)
623
804
  return zeroSandboxResources();
624
805
  return {
625
- sandboxSlots: preflight.maxConcurrentSandboxes,
806
+ // Host free memory and disk already reflect active containers. Add their
807
+ // commitments back to form a stable total capacity, then subtract them
808
+ // exactly once in usedSandboxResources.
809
+ sandboxSlots: Math.min(this.config.maxConcurrentSandboxes, preflight.maxConcurrentSandboxes + activeResources.sandboxSlots),
626
810
  cpuMillis: preflight.availableCpuMillis,
627
- memoryBytes: Math.max(0, preflight.availableMemoryBytes - 2 * 1024 ** 3),
628
- workspaceBytes: Math.max(0, preflight.availableWorkspaceBytes - this.config.minimumWorkspaceBytes),
811
+ workerMemoryLimitBytes: 0,
812
+ serviceMemoryLimitBytes: 0,
813
+ runtimeOverheadBytes: 0,
814
+ memoryBytes: Math.max(0, preflight.availableMemoryBytes
815
+ - (this.config.sandboxHostMemoryReserveBytes ?? 2 * 1024 ** 3)
816
+ + activeResources.memoryBytes),
817
+ workspaceBytes: Math.max(0, preflight.availableWorkspaceBytes
818
+ - this.config.minimumWorkspaceBytes
819
+ + activeResources.workspaceBytes),
629
820
  pids: this.config.sandboxPidCapacity,
630
821
  buildSlots: this.config.maxConcurrentSandboxBuilds
631
822
  };
@@ -671,6 +862,12 @@ function isDispatchNoLongerPending(error) {
671
862
  }
672
863
  return error instanceof Error && error.message === "Dispatch is no longer pending.";
673
864
  }
865
+ function createDefaultSessionService(sandboxStateRoot) {
866
+ const rawProcessRunner = new StreamingProviderProcessRunner();
867
+ const processSupervisors = new AttemptProcessSupervisorRegistry();
868
+ const coordinatedProcessRunner = new AttemptCoordinatedProviderProcessRunner(processSupervisors, rawProcessRunner);
869
+ return new BridgeSessionService(new GitWorktreeService(undefined, new RepositoryCoordinatorLock(sandboxStateRoot)), new RoutingProviderCliService(new ProcessBackedProviderCliService(coordinatedProcessRunner)), new HostRuntimeLeaseManager(sandboxStateRoot, undefined, undefined, processSupervisors, rawProcessRunner));
870
+ }
674
871
  export class SignalRBridgeConnectionFactory {
675
872
  create(config) {
676
873
  return new SignalRBridgeConnection(config);
@@ -738,6 +935,11 @@ export class SignalRBridgeConnection {
738
935
  void handler(request).catch(() => undefined);
739
936
  });
740
937
  }
938
+ onReadinessProbe(handler) {
939
+ this.connection.on("ReadinessProbe", (request) => {
940
+ void handler(request).catch(() => undefined);
941
+ });
942
+ }
741
943
  invoke(methodName, payload) {
742
944
  return this.connection.invoke(methodName, payload);
743
945
  }
@@ -745,6 +947,9 @@ export class SignalRBridgeConnection {
745
947
  export function defaultBridgeServiceConfig(overrides = {}) {
746
948
  const defaults = defaultForgeBridgeConfig();
747
949
  const cliProviders = splitCsv(process.env.FORGE_CLI_PROVIDERS ?? "codex");
950
+ const sandboxHostMemoryReserveDefault = process.platform === "linux"
951
+ ? 2 * 1024 ** 3
952
+ : 1024 ** 3;
748
953
  const sandboxPublicationMode = process.env.FORGE_SANDBOX_PUBLICATION_MODE === "bridge_push" ? "bridge_push" : "shared_filesystem";
749
954
  const config = {
750
955
  serverUrl: process.env.FORGE_URL ?? "http://localhost:5208",
@@ -755,6 +960,7 @@ export function defaultBridgeServiceConfig(overrides = {}) {
755
960
  maxConcurrentSandboxBuilds: positiveFiniteNumber(Number(process.env.FORGE_MAX_CONCURRENT_SANDBOX_BUILDS ?? 1), 1),
756
961
  sandboxPidCapacity: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_PID_CAPACITY ?? 2048), 2048),
757
962
  dockerEnabled: process.env.FORGE_DOCKER_SANDBOX !== "false",
963
+ allowOpenPublicSandbox: process.env.FORGE_ALLOW_OPEN_PUBLIC_SANDBOX === "true",
758
964
  sandboxPublicationMode,
759
965
  dockerExecutable: process.env.FORGE_DOCKER_EXECUTABLE ?? "docker",
760
966
  dockerContext: process.env.FORGE_DOCKER_CONTEXT ?? "",
@@ -763,6 +969,11 @@ export function defaultBridgeServiceConfig(overrides = {}) {
763
969
  diskHighWatermarkPercent: positiveFiniteNumber(Number(process.env.FORGE_DISK_HIGH_WATERMARK_PERCENT ?? 85), 85),
764
970
  diskLowWatermarkPercent: positiveFiniteNumber(Number(process.env.FORGE_DISK_LOW_WATERMARK_PERCENT ?? 75), 75),
765
971
  minimumWorkspaceBytes: positiveFiniteNumber(Number(process.env.FORGE_MINIMUM_WORKSPACE_BYTES ?? 10 * 1024 ** 3), 10 * 1024 ** 3),
972
+ sandboxHostMemoryReserveBytes: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_HOST_MEMORY_RESERVE_BYTES ?? sandboxHostMemoryReserveDefault), sandboxHostMemoryReserveDefault),
973
+ defaultSandboxWorkerMemoryLimitBytes: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_DEFAULT_WORKER_MEMORY_LIMIT_BYTES ?? 2 * 1024 ** 3), 2 * 1024 ** 3),
974
+ defaultSandboxServiceMemoryLimitBytes: nonNegativeFiniteNumber(Number(process.env.FORGE_SANDBOX_DEFAULT_SERVICE_MEMORY_LIMIT_BYTES ?? 0), 0),
975
+ sandboxRuntimeOverheadBytes: Math.max(128 * 1024 ** 2, nonNegativeFiniteNumber(Number(process.env.FORGE_SANDBOX_RUNTIME_OVERHEAD_BYTES ?? 128 * 1024 ** 2), 128 * 1024 ** 2)),
976
+ sandboxMemorySampleIntervalMs: Math.min(30_000, Math.max(250, positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_MEMORY_SAMPLE_INTERVAL_MS ?? 1000), 1000))),
766
977
  imageRetentionCount: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_IMAGE_RETENTION_COUNT ?? 3), 3),
767
978
  imageRetentionTtlMs: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_IMAGE_RETENTION_TTL_MS ?? 7 * 24 * 60 * 60 * 1000), 7 * 24 * 60 * 60 * 1000),
768
979
  auditRetentionCount: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_AUDIT_RETENTION_COUNT ?? 200), 200),
@@ -811,6 +1022,9 @@ function normalizeServerUrl(serverUrl) {
811
1022
  function positiveFiniteNumber(value, fallback) {
812
1023
  return Number.isFinite(value) && value > 0 ? value : fallback;
813
1024
  }
1025
+ function nonNegativeFiniteNumber(value, fallback) {
1026
+ return Number.isFinite(value) && value >= 0 ? value : fallback;
1027
+ }
814
1028
  function providerStatusesFor(providers) {
815
1029
  return Object.fromEntries(providers.map((provider) => [provider, { signedIn: true, version: "", models: configuredModelsFor(provider) }]));
816
1030
  }
@@ -932,10 +1146,11 @@ function normalizeQaAppLaunchTarget(target) {
932
1146
  command: cleanString(target.command),
933
1147
  working_directory: cleanString(target.working_directory),
934
1148
  ready_url: cleanString(target.ready_url),
1149
+ managed_runtime_port: cleanString(target.managed_runtime_port),
935
1150
  routes,
936
1151
  notes: cleanString(target.notes)
937
1152
  };
938
- return normalized.url || normalized.command || normalized.ready_url || routes.length > 0
1153
+ return normalized.url || normalized.command || normalized.ready_url || normalized.managed_runtime_port
939
1154
  ? normalized
940
1155
  : null;
941
1156
  }
@@ -1004,12 +1219,12 @@ function abortPromise(signal) {
1004
1219
  export function canReapManagedSandboxes(preflight) {
1005
1220
  return canRunDockerMaintenance(preflight);
1006
1221
  }
1007
- function sandboxNetworkPolicyFailure(dispatch) {
1222
+ function sandboxNetworkPolicyFailure(dispatch, config) {
1008
1223
  const sandbox = dispatch.sandbox;
1009
1224
  if (!sandbox)
1010
1225
  return "Protocol-v2 sandbox context is missing.";
1011
1226
  if (sandbox.networkPolicy.mode === "open_public"
1012
- && (sandbox.executionPolicy === "sandbox_required" || process.env.FORGE_ALLOW_OPEN_PUBLIC_SANDBOX !== "true")) {
1227
+ && (sandbox.executionPolicy === "sandbox_required" || !config.allowOpenPublicSandbox)) {
1013
1228
  return "open_public sandbox networking is not permitted by this runner policy.";
1014
1229
  }
1015
1230
  if (sandbox.networkPolicy.mode === "public_allowlist"
@@ -1019,6 +1234,61 @@ function sandboxNetworkPolicyFailure(dispatch) {
1019
1234
  }
1020
1235
  return null;
1021
1236
  }
1237
+ function infrastructureFailureEventType(summary) {
1238
+ if (summary.startsWith("worktree_prepare_failed:"))
1239
+ return "worktree_prepare_failed";
1240
+ if (summary.startsWith("provider_start_unavailable:"))
1241
+ return "provider_start_unavailable";
1242
+ if (summary.startsWith("host_port_collision:"))
1243
+ return "host_port_collision";
1244
+ if (summary.startsWith("host_runtime_process_exited:"))
1245
+ return "host_runtime_process_exited";
1246
+ if (summary.startsWith("host_runtime_listener_timeout:"))
1247
+ return "host_runtime_listener_timeout";
1248
+ if (summary.startsWith("host_runtime_http_readiness_timeout:"))
1249
+ return "host_runtime_http_readiness_timeout";
1250
+ if (summary.startsWith("attempt_process_supervisor_start_failed:"))
1251
+ return "attempt_process_supervisor_start_failed";
1252
+ if (summary.startsWith("attempt_process_supervisor_start_timeout:"))
1253
+ return "attempt_process_supervisor_start_timeout";
1254
+ return null;
1255
+ }
1256
+ function parseReadinessProbe(value) {
1257
+ if (!value || typeof value !== "object" || Array.isArray(value))
1258
+ return null;
1259
+ const request = value;
1260
+ 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;
1261
+ const bounded = (candidate, maximum) => typeof candidate === "string" && candidate.trim().length > 0 && candidate.length <= maximum;
1262
+ if (request.schemaVersion !== 1
1263
+ || !uuid.test(request.requestId ?? "")
1264
+ || !uuid.test(request.fencingToken ?? "")
1265
+ || !uuid.test(request.userId ?? "")
1266
+ || !uuid.test(request.projectId ?? "")
1267
+ || !uuid.test(request.repoId ?? "")
1268
+ || !uuid.test(request.runnerInstanceId ?? "")
1269
+ || !Number.isInteger(request.attempt) || (request.attempt ?? 0) < 1
1270
+ || !bounded(request.repoName, 160)
1271
+ || typeof request.remoteUrl !== "string" || request.remoteUrl.length > 2048
1272
+ || !bounded(request.defaultBranch, 512)
1273
+ || typeof request.cloneOnDemand !== "boolean"
1274
+ || !bounded(request.runnerRepoLocalPath, 4096)
1275
+ || !bounded(request.revision, 512)
1276
+ || !Number.isFinite(Date.parse(request.expiresAt ?? ""))
1277
+ || Date.parse(request.expiresAt ?? "") <= Date.now()) {
1278
+ return null;
1279
+ }
1280
+ return request;
1281
+ }
1282
+ function sandboxProviderNetworkFailure(dispatch, config) {
1283
+ const sandbox = dispatch.sandbox;
1284
+ if (!sandbox || sandbox.networkPolicy.mode !== "offline")
1285
+ return null;
1286
+ const provider = dispatch.execution.provider.trim().toLowerCase();
1287
+ const isNetworkProvider = config.cliProviders.some(candidate => candidate.trim().toLowerCase() === provider);
1288
+ if (!isNetworkProvider)
1289
+ return null;
1290
+ return `Provider ${dispatch.execution.provider} requires outbound network access, but this Docker attempt uses the offline network policy.`;
1291
+ }
1022
1292
  function sandboxAuthFailure(dispatch, config) {
1023
1293
  const unsupported = sandboxProviderAuthUnavailableMessage(dispatch.execution.provider, dispatch.execution.authKind);
1024
1294
  if (unsupported) {
@@ -1046,10 +1316,51 @@ export function canReserveSandboxResources(used, requested, capacity) {
1046
1316
  && used.pids + requested.pids <= capacity.pids
1047
1317
  && used.buildSlots + requested.buildSlots <= capacity.buildSlots;
1048
1318
  }
1319
+ export function sandboxCapacityFailureMessage(used, requested, capacity, preflight, config) {
1320
+ const availableSlots = Math.max(0, capacity.sandboxSlots - used.sandboxSlots);
1321
+ if (requested.sandboxSlots > availableSlots) {
1322
+ return `Sandbox slot capacity is insufficient: requested ${requested.sandboxSlots}, available ${availableSlots}, ${used.sandboxSlots} already reserved.`;
1323
+ }
1324
+ const availableMemoryBytes = Math.max(0, capacity.memoryBytes - used.memoryBytes);
1325
+ if (requested.memoryBytes > availableMemoryBytes) {
1326
+ return [
1327
+ `Sandbox memory capacity is insufficient: requested ${formatBytes(requested.memoryBytes)} total`,
1328
+ `(worker ${formatBytes(requested.workerMemoryLimitBytes)}, services ${formatBytes(requested.serviceMemoryLimitBytes)}, runtime overhead ${formatBytes(requested.runtimeOverheadBytes)}),`,
1329
+ `but ${formatBytes(availableMemoryBytes)} is available for admission.`,
1330
+ `Docker reports ${formatBytes(preflight?.availableMemoryBytes ?? 0)} available of ${formatBytes(preflight?.totalMemoryBytes ?? 0)};`,
1331
+ `Forge reserves ${formatBytes(preflight?.hostReserveBytes ?? config.sandboxHostMemoryReserveBytes ?? 0)} for the host and tracks ${formatBytes(used.memoryBytes)} in active or reserved attempts.`,
1332
+ ].join(" ");
1333
+ }
1334
+ const availableCpuMillis = Math.max(0, capacity.cpuMillis - used.cpuMillis);
1335
+ if (requested.cpuMillis > availableCpuMillis) {
1336
+ return `Sandbox CPU capacity is insufficient: requested ${requested.cpuMillis} millis, available ${availableCpuMillis} millis, ${used.cpuMillis} millis already reserved.`;
1337
+ }
1338
+ const availableWorkspaceBytes = Math.max(0, capacity.workspaceBytes - used.workspaceBytes);
1339
+ if (requested.workspaceBytes > availableWorkspaceBytes) {
1340
+ return `Sandbox workspace capacity is insufficient: requested ${formatBytes(requested.workspaceBytes)}, available ${formatBytes(availableWorkspaceBytes)}, ${formatBytes(used.workspaceBytes)} already reserved.`;
1341
+ }
1342
+ const availablePids = Math.max(0, capacity.pids - used.pids);
1343
+ if (requested.pids > availablePids) {
1344
+ return `Sandbox PID capacity is insufficient: requested ${requested.pids}, available ${availablePids}, ${used.pids} already reserved.`;
1345
+ }
1346
+ const availableBuildSlots = Math.max(0, capacity.buildSlots - used.buildSlots);
1347
+ return `Sandbox build capacity is insufficient: requested ${requested.buildSlots}, available ${availableBuildSlots}, ${used.buildSlots} already reserved.`;
1348
+ }
1349
+ function formatBytes(bytes) {
1350
+ if (!Number.isFinite(bytes) || bytes <= 0)
1351
+ return "0 B";
1352
+ const units = ["B", "KiB", "MiB", "GiB", "TiB"];
1353
+ const power = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
1354
+ const value = bytes / 1024 ** power;
1355
+ return `${value >= 10 || power === 0 ? value.toFixed(0) : value.toFixed(2)} ${units[power]}`;
1356
+ }
1049
1357
  function sumSandboxResources(resources) {
1050
1358
  return resources.reduce((total, resource) => ({
1051
1359
  sandboxSlots: total.sandboxSlots + resource.sandboxSlots,
1052
1360
  cpuMillis: total.cpuMillis + resource.cpuMillis,
1361
+ workerMemoryLimitBytes: total.workerMemoryLimitBytes + resource.workerMemoryLimitBytes,
1362
+ serviceMemoryLimitBytes: total.serviceMemoryLimitBytes + resource.serviceMemoryLimitBytes,
1363
+ runtimeOverheadBytes: total.runtimeOverheadBytes + resource.runtimeOverheadBytes,
1053
1364
  memoryBytes: total.memoryBytes + resource.memoryBytes,
1054
1365
  workspaceBytes: total.workspaceBytes + resource.workspaceBytes,
1055
1366
  pids: total.pids + resource.pids,
@@ -1057,18 +1368,83 @@ function sumSandboxResources(resources) {
1057
1368
  }), zeroSandboxResources());
1058
1369
  }
1059
1370
  function zeroSandboxResources() {
1060
- return { sandboxSlots: 0, cpuMillis: 0, memoryBytes: 0, workspaceBytes: 0, pids: 0, buildSlots: 0 };
1371
+ return {
1372
+ sandboxSlots: 0,
1373
+ cpuMillis: 0,
1374
+ workerMemoryLimitBytes: 0,
1375
+ serviceMemoryLimitBytes: 0,
1376
+ runtimeOverheadBytes: 0,
1377
+ memoryBytes: 0,
1378
+ workspaceBytes: 0,
1379
+ pids: 0,
1380
+ buildSlots: 0,
1381
+ };
1061
1382
  }
1062
1383
  function defaultSandboxResources() {
1063
1384
  return {
1064
1385
  sandboxSlots: 1,
1065
1386
  cpuMillis: 2000,
1066
- memoryBytes: 2 * 1024 ** 3,
1387
+ workerMemoryLimitBytes: 2 * 1024 ** 3,
1388
+ serviceMemoryLimitBytes: 0,
1389
+ runtimeOverheadBytes: 128 * 1024 ** 2,
1390
+ memoryBytes: 2 * 1024 ** 3 + 128 * 1024 ** 2,
1067
1391
  workspaceBytes: 10 * 1024 ** 3,
1068
1392
  pids: 512,
1069
1393
  buildSlots: 0
1070
1394
  };
1071
1395
  }
1396
+ export function resolveDynamicSandboxResources(requested, serviceCount, networkMode, baseRuntimeOverheadBytes = 128 * 1024 ** 2) {
1397
+ const serviceMemoryLimitBytes = Math.max(0, serviceCount) * 1024 ** 3;
1398
+ const runtimeOverheadBytes = Math.max(128 * 1024 ** 2, baseRuntimeOverheadBytes)
1399
+ + (networkMode === "public_allowlist" ? 256 * 1024 ** 2 : 0);
1400
+ return {
1401
+ ...requested,
1402
+ serviceMemoryLimitBytes,
1403
+ runtimeOverheadBytes,
1404
+ memoryBytes: requested.workerMemoryLimitBytes + serviceMemoryLimitBytes + runtimeOverheadBytes,
1405
+ };
1406
+ }
1407
+ export async function resolveSandboxResourcesFromRepo(dispatch, baseRuntimeOverheadBytes = 128 * 1024 ** 2) {
1408
+ const repoPath = dispatch.repo.serverLocalPath;
1409
+ const sandbox = dispatch.sandbox;
1410
+ if (!repoPath || !sandbox) {
1411
+ throw new Error("Sandbox admission needs a mapped repository.");
1412
+ }
1413
+ const selection = selectSandboxConfigRefs(sandbox.executionPolicy, sandbox.sandboxConfigCommitSha, dispatch.repo.defaultBranch);
1414
+ const configCommitSha = await resolveFirstGitCommit(repoPath, selection.refs);
1415
+ if (!configCommitSha) {
1416
+ throw new Error("Sandbox admission could not resolve the immutable sandbox config commit.");
1417
+ }
1418
+ const contractResult = await runSandboxProcess({
1419
+ command: "git",
1420
+ args: ["-C", repoPath, "show", `${configCommitSha}:.forge/forge.sandbox.yml`],
1421
+ timeoutMs: 30_000,
1422
+ maxOutputBytes: 1024 * 1024,
1423
+ });
1424
+ if (contractResult.exitCode !== 0) {
1425
+ throw new Error("The approved immutable sandbox contract is unavailable in the mapped repository.");
1426
+ }
1427
+ const contract = validateSandboxContract(contractResult.stdout);
1428
+ return {
1429
+ resources: resolveDynamicSandboxResources(sandbox.requestedResources, contract.contract.services.length, sandbox.networkPolicy.mode, baseRuntimeOverheadBytes),
1430
+ sandboxConfigCommitSha: configCommitSha,
1431
+ };
1432
+ }
1433
+ async function resolveFirstGitCommit(repoPath, refs) {
1434
+ for (const ref of refs) {
1435
+ const result = await runSandboxProcess({
1436
+ command: "git",
1437
+ args: ["-C", repoPath, "rev-parse", "--verify", `${ref}^{commit}`],
1438
+ timeoutMs: 30_000,
1439
+ maxOutputBytes: 1024 * 1024,
1440
+ });
1441
+ const commitSha = result.stdout.trim().toLowerCase();
1442
+ if (result.exitCode === 0 && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u.test(commitSha)) {
1443
+ return commitSha;
1444
+ }
1445
+ }
1446
+ return null;
1447
+ }
1072
1448
  function parseOfferEnvelope(value) {
1073
1449
  return parseDispatchEnvelope(value);
1074
1450
  }