@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.
- package/README.md +34 -0
- package/dist/bridge/bridge.base.d.ts +2 -1
- package/dist/bridge/bridge.service.d.ts +22 -3
- package/dist/bridge/bridge.service.js +416 -40
- package/dist/bridge/bridge.types.d.ts +59 -1
- package/dist/cleanup/cleanup.service.d.ts +13 -2
- package/dist/cleanup/cleanup.service.js +435 -3
- package/dist/cleanup/cleanup.types.d.ts +10 -0
- package/dist/cleanup/index.d.ts +1 -1
- package/dist/cleanup/index.js +1 -1
- package/dist/cleanup/internals.d.ts +2 -2
- package/dist/cleanup/internals.js +1 -1
- package/dist/cli/cli.help.js +37 -3
- package/dist/cli/cli.service.d.ts +30 -2
- package/dist/cli/cli.service.js +182 -23
- package/dist/cli/local-config.d.ts +4 -0
- package/dist/cli/local-config.js +41 -1
- package/dist/runtime/attempt-node-runtime-launcher.d.ts +17 -0
- package/dist/runtime/attempt-node-runtime-launcher.js +101 -0
- package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
- package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
- package/dist/runtime/attempt-process-supervisor.d.ts +86 -0
- package/dist/runtime/attempt-process-supervisor.js +343 -0
- package/dist/runtime/generated-output-reset.d.ts +10 -0
- package/dist/runtime/generated-output-reset.js +103 -0
- package/dist/runtime/host-listener-inspector.d.ts +44 -0
- package/dist/runtime/host-listener-inspector.js +324 -0
- package/dist/runtime/host-port-broker.d.ts +76 -0
- package/dist/runtime/host-port-broker.js +333 -0
- package/dist/runtime/host-runtime-contract.d.ts +47 -0
- package/dist/runtime/host-runtime-contract.js +700 -0
- package/dist/runtime/host-runtime-lease.d.ts +133 -0
- package/dist/runtime/host-runtime-lease.js +1460 -0
- package/dist/runtime/host-runtime-promotion.d.ts +23 -0
- package/dist/runtime/host-runtime-promotion.js +159 -0
- package/dist/runtime/node-host-runtime-adapter.d.ts +51 -0
- package/dist/runtime/node-host-runtime-adapter.js +268 -0
- package/dist/runtime/playwright-host-runtime-adapter.d.ts +20 -0
- package/dist/runtime/playwright-host-runtime-adapter.js +220 -0
- package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
- package/dist/runtime/runtime-isolation-lease.js +158 -0
- package/dist/sandbox/preflight.d.ts +14 -0
- package/dist/sandbox/preflight.js +91 -12
- package/dist/sandbox/process.js +125 -40
- package/dist/sandbox/repository-lock.js +37 -1
- package/dist/sandbox/runtime.d.ts +66 -3
- package/dist/sandbox/runtime.js +670 -105
- package/dist/sandbox/sandbox.types.d.ts +3 -0
- package/dist/sandbox/smoke.d.ts +76 -0
- package/dist/sandbox/smoke.js +380 -0
- package/dist/sandbox/worker-supervisor.js +1 -1
- package/dist/sandbox/worker.js +2 -5
- package/dist/sandbox/workspace.service.d.ts +1 -0
- package/dist/sandbox/workspace.service.js +28 -5
- package/dist/session/managed-attempt-tools.d.ts +12 -0
- package/dist/session/managed-attempt-tools.js +350 -0
- package/dist/session/phase-completion.artifacts.js +9 -2
- package/dist/session/phase-completion.d.ts +4 -2
- package/dist/session/phase-completion.helpers.js +27 -3
- package/dist/session/phase-completion.js +12 -3
- package/dist/session/phase-completion.prompts.d.ts +3 -3
- package/dist/session/phase-completion.prompts.js +19 -5
- package/dist/session/phase-completion.runtime.d.ts +12 -1
- package/dist/session/phase-completion.runtime.js +127 -7
- package/dist/session/phase-completion.schemas.js +108 -3
- package/dist/session/phase-completion.validation.d.ts +4 -1
- package/dist/session/phase-completion.validation.js +106 -2
- package/dist/session/provider-cli/ci-repair-readiness.d.ts +8 -0
- package/dist/session/provider-cli/ci-repair-readiness.js +90 -0
- package/dist/session/provider-cli/provider-cli.artifacts.d.ts +1 -1
- package/dist/session/provider-cli/provider-cli.artifacts.js +28 -4
- package/dist/session/provider-cli/provider-cli.deterministic.js +47 -1
- package/dist/session/provider-cli/provider-cli.process.d.ts +16 -0
- package/dist/session/provider-cli/provider-cli.process.js +212 -25
- package/dist/session/provider-cli/provider-cli.prompts.d.ts +4 -1
- package/dist/session/provider-cli/provider-cli.prompts.js +84 -17
- package/dist/session/provider-cli/provider-cli.qa-sidecars.js +1 -0
- package/dist/session/provider-cli/provider-cli.runtime.d.ts +1 -1
- package/dist/session/provider-cli/provider-cli.runtime.js +3 -2
- package/dist/session/provider-cli/provider-cli.service.d.ts +3 -2
- package/dist/session/provider-cli/provider-cli.service.js +254 -71
- package/dist/session/provider-cli/provider-cli.types.d.ts +5 -0
- package/dist/session/provider-cli/provider-output-state.d.ts +2 -0
- package/dist/session/provider-cli/provider-output-state.js +30 -2
- package/dist/session/session-redaction.d.ts +3 -0
- package/dist/session/session-redaction.js +23 -0
- package/dist/session/session.base.d.ts +6 -1
- package/dist/session/session.service.d.ts +8 -2
- package/dist/session/session.service.js +436 -41
- package/dist/session/session.types.d.ts +55 -0
- package/dist/session/session.types.js +69 -2
- package/dist/shared/path/path.js +3 -1
- package/dist/shared/process/host-process-recovery.d.ts +12 -0
- package/dist/shared/process/host-process-recovery.js +78 -0
- package/dist/shared/process/host-process-supervisor.d.ts +57 -0
- package/dist/shared/process/host-process-supervisor.js +282 -0
- package/dist/shared/process/process.d.ts +1 -0
- package/dist/shared/process/process.js +1 -0
- package/dist/shared/redaction/redaction.js +6 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/artifact-injector.d.ts +3 -2
- package/dist/worktree/artifact-injector.js +43 -4
- package/dist/worktree/dependency-warmer.d.ts +10 -1
- package/dist/worktree/dependency-warmer.js +94 -5
- package/dist/worktree/index.d.ts +1 -0
- package/dist/worktree/index.js +1 -0
- package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
- package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
- package/dist/worktree/worktree-paths.d.ts +4 -0
- package/dist/worktree/worktree-paths.js +30 -0
- package/dist/worktree/worktree.base.d.ts +3 -0
- package/dist/worktree/worktree.service.d.ts +7 -2
- package/dist/worktree/worktree.service.js +72 -44
- package/dist/worktree/worktree.types.d.ts +3 -1
- package/package.json +3 -2
- package/runtime/windows-job-supervisor.ps1 +356 -0
- 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 {
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
|
|
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,
|
|
259
|
-
await this.sendOfferDecision(dispatch, false, "capacity_unavailable",
|
|
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:
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
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
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
628
|
-
|
|
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 ||
|
|
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" ||
|
|
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 {
|
|
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
|
-
|
|
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
|
}
|