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