@loucompanion/forge-bridge 0.1.3-dev.8687c5099dce → 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 +18 -1
- package/dist/bridge/bridge.base.d.ts +2 -0
- package/dist/bridge/bridge.service.d.ts +25 -1
- package/dist/bridge/bridge.service.js +341 -74
- package/dist/bridge/bridge.types.d.ts +4 -0
- package/dist/cleanup/cleanup.service.d.ts +1 -0
- package/dist/cleanup/cleanup.service.js +70 -9
- package/dist/cli/cli.service.js +9 -4
- 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.js +5 -1
- package/dist/runtime/attempt-process-supervisor.d.ts +11 -0
- package/dist/runtime/attempt-process-supervisor.js +36 -2
- package/dist/runtime/host-listener-inspector.d.ts +2 -0
- package/dist/runtime/host-listener-inspector.js +25 -0
- package/dist/runtime/host-runtime-lease.d.ts +66 -5
- package/dist/runtime/host-runtime-lease.js +714 -38
- 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 +3 -0
- package/dist/runtime/node-host-runtime-adapter.js +24 -8
- package/dist/runtime/playwright-host-runtime-adapter.d.ts +1 -0
- package/dist/runtime/playwright-host-runtime-adapter.js +5 -0
- package/dist/runtime/workspace-resource-estimator.d.ts +17 -0
- package/dist/runtime/workspace-resource-estimator.js +174 -0
- package/dist/sandbox/preflight.d.ts +31 -1
- package/dist/sandbox/preflight.js +237 -51
- package/dist/sandbox/repository-lock.js +4 -2
- package/dist/sandbox/sandbox.types.d.ts +7 -0
- package/dist/session/managed-attempt-tools.d.ts +7 -1
- package/dist/session/managed-attempt-tools.js +257 -13
- 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 +224 -22
- package/dist/session/phase-completion.prompts.js +2 -2
- package/dist/session/phase-completion.runtime.js +10 -3
- package/dist/session/phase-completion.schemas.js +1 -1
- package/dist/session/phase-completion.validation.js +7 -3
- package/dist/session/provider-cli/ci-repair-readiness.d.ts +6 -0
- package/dist/session/provider-cli/ci-repair-readiness.js +12 -2
- package/dist/session/provider-cli/provider-cli.artifacts.d.ts +2 -1
- package/dist/session/provider-cli/provider-cli.artifacts.js +37 -3
- package/dist/session/provider-cli/provider-cli.process.d.ts +7 -0
- package/dist/session/provider-cli/provider-cli.process.js +218 -17
- package/dist/session/provider-cli/provider-cli.prompts.js +17 -4
- package/dist/session/provider-cli/provider-cli.runtime.js +3 -1
- package/dist/session/provider-cli/provider-cli.service.d.ts +5 -2
- package/dist/session/provider-cli/provider-cli.service.js +83 -34
- package/dist/session/provider-cli/provider-output-state.js +61 -14
- 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.js +16 -1
- package/dist/session/session.service.js +81 -9
- package/dist/session/session.types.d.ts +15 -0
- package/dist/session/session.types.js +3 -1
- 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 +18 -0
- package/dist/shared/process/host-process-supervisor.d.ts +2 -0
- package/dist/shared/process/host-process-supervisor.js +57 -9
- package/dist/shared/redaction/redaction.js +6 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/dependency-warmer.d.ts +18 -0
- package/dist/worktree/dependency-warmer.js +218 -8
- package/dist/worktree/index.d.ts +1 -1
- package/dist/worktree/worktree.service.d.ts +2 -0
- package/dist/worktree/worktree.service.js +158 -17
- package/dist/worktree/worktree.types.d.ts +2 -0
- package/package.json +3 -2
- package/runtime/windows-job-supervisor.ps1 +89 -1
|
@@ -10,6 +10,8 @@ import { BridgeSessionService } from "../session/session.service.js";
|
|
|
10
10
|
import { ProcessBackedProviderCliService, RoutingProviderCliService, StreamingProviderProcessRunner, } from "../session/provider-cli/provider-cli.service.js";
|
|
11
11
|
import { redactRunnerDispatchEvent, redactRunOnceResultPacket } from "../session/session-redaction.js";
|
|
12
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";
|
|
13
15
|
import { GitWorktreeService } from "../worktree/worktree.service.js";
|
|
14
16
|
import { RepositoryCoordinatorLock } from "../sandbox/repository-lock.js";
|
|
15
17
|
import { AttemptCoordinatedProviderProcessRunner, AttemptProcessSupervisorRegistry, } from "../runtime/attempt-process-supervisor-registry.js";
|
|
@@ -20,6 +22,20 @@ import { validateSandboxContract } from "../sandbox/contract.js";
|
|
|
20
22
|
import { runSandboxProcess } from "../sandbox/process.js";
|
|
21
23
|
import { DockerSandboxRuntime, selectSandboxConfigRefs } from "../sandbox/runtime.js";
|
|
22
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
|
+
}
|
|
23
39
|
export class ForgeBridgeService {
|
|
24
40
|
config;
|
|
25
41
|
connections;
|
|
@@ -32,6 +48,8 @@ export class ForgeBridgeService {
|
|
|
32
48
|
sandboxMaintenanceTimer = null;
|
|
33
49
|
sandboxMaintenanceRunning = false;
|
|
34
50
|
closed = null;
|
|
51
|
+
transportRecovery = null;
|
|
52
|
+
transportRecoveryGeneration = 0;
|
|
35
53
|
stopped = false;
|
|
36
54
|
activeSessions = new Map();
|
|
37
55
|
reservations = new Map();
|
|
@@ -40,6 +58,7 @@ export class ForgeBridgeService {
|
|
|
40
58
|
sandboxRuntime;
|
|
41
59
|
sandboxResourceResolver;
|
|
42
60
|
sessions;
|
|
61
|
+
sandboxAmbientMemoryCommitmentBytes = null;
|
|
43
62
|
constructor(config, sessions = undefined, connections = new SignalRBridgeConnectionFactory(), preflight, sandboxRuntime, sandboxResourceResolver) {
|
|
44
63
|
this.config = config;
|
|
45
64
|
this.connections = connections;
|
|
@@ -82,14 +101,39 @@ export class ForgeBridgeService {
|
|
|
82
101
|
}
|
|
83
102
|
const connection = this.connections.create(this.config);
|
|
84
103
|
this.connection = connection;
|
|
104
|
+
this.transportRecovery = null;
|
|
85
105
|
this.closed = new Promise((resolve) => {
|
|
86
|
-
connection.onClose(() => {
|
|
106
|
+
connection.onClose((error) => {
|
|
87
107
|
this.stopHeartbeat();
|
|
108
|
+
this.completeTransportRecovery(error ?? new Error("bridge connection closed"));
|
|
88
109
|
this.abortActiveSessions("bridge connection closed");
|
|
89
110
|
this.abortActiveReadinessProbes("bridge connection closed");
|
|
90
111
|
resolve();
|
|
91
112
|
});
|
|
92
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
|
+
});
|
|
93
137
|
connection.onDispatch((dispatch) => this.handleDispatch(dispatch));
|
|
94
138
|
connection.onOffer?.((dispatch) => this.handleOffer(dispatch));
|
|
95
139
|
connection.onStart?.((dispatch) => this.handleStart(dispatch));
|
|
@@ -98,18 +142,39 @@ export class ForgeBridgeService {
|
|
|
98
142
|
connection.onReap?.((request) => this.handleReap(request));
|
|
99
143
|
connection.onReadinessProbe?.((request) => this.handleReadinessProbe(request));
|
|
100
144
|
await connection.start();
|
|
101
|
-
await this.
|
|
102
|
-
version: this.config.version,
|
|
103
|
-
protocolVersion: this.config.protocolVersion,
|
|
104
|
-
minServerVersion: this.config.minServerVersion ?? null,
|
|
105
|
-
runnerInstanceId: this.config.runnerInstanceId,
|
|
106
|
-
capabilities: this.capabilities()
|
|
107
|
-
});
|
|
145
|
+
await this.registerConnection(connection);
|
|
108
146
|
await this.flushCleanupReports();
|
|
109
147
|
this.startHeartbeat();
|
|
110
148
|
this.startLeaseWatchdog();
|
|
111
149
|
this.startSandboxMaintenance();
|
|
112
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
|
+
}
|
|
113
178
|
async run(signal) {
|
|
114
179
|
let attempt = 0;
|
|
115
180
|
try {
|
|
@@ -223,7 +288,8 @@ export class ForgeBridgeService {
|
|
|
223
288
|
if (normalized.sandbox?.executionMode === "docker") {
|
|
224
289
|
await this.flushCleanupReports().catch(() => undefined);
|
|
225
290
|
}
|
|
226
|
-
|
|
291
|
+
const redactedResult = redactRunOnceResultPacket(result, redactionSecrets);
|
|
292
|
+
await this.sendResult(dispatch, compactTerminalResult(dispatch, redactedResult));
|
|
227
293
|
}
|
|
228
294
|
catch (error) {
|
|
229
295
|
const summary = controller.signal.aborted
|
|
@@ -254,13 +320,12 @@ export class ForgeBridgeService {
|
|
|
254
320
|
|| dispatch.lease.fencingToken !== envelope.fencingToken)
|
|
255
321
|
return;
|
|
256
322
|
const sandbox = dispatch.sandbox;
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
this.reservations.delete(sandbox.dispatchAttemptId);
|
|
260
|
-
existing = undefined;
|
|
261
|
-
}
|
|
323
|
+
this.expireReservations(Date.now());
|
|
324
|
+
const existing = this.reservations.get(sandbox.dispatchAttemptId);
|
|
262
325
|
if (existing) {
|
|
263
|
-
const sameFence = existing.
|
|
326
|
+
const sameFence = existing.leaseId === dispatch.lease.id
|
|
327
|
+
&& existing.fencingToken === dispatch.lease.fencingToken
|
|
328
|
+
&& existing.runnerInstanceId === sandbox.runnerInstanceId;
|
|
264
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);
|
|
265
330
|
return;
|
|
266
331
|
}
|
|
@@ -287,7 +352,7 @@ export class ForgeBridgeService {
|
|
|
287
352
|
}
|
|
288
353
|
const providerNetworkFailure = sandboxProviderNetworkFailure(dispatch, this.config);
|
|
289
354
|
if (providerNetworkFailure) {
|
|
290
|
-
await this.sendOfferDecision(dispatch, false, "
|
|
355
|
+
await this.sendOfferDecision(dispatch, false, "provider_network_policy_denied", providerNetworkFailure);
|
|
291
356
|
return;
|
|
292
357
|
}
|
|
293
358
|
const authFailure = sandboxAuthFailure(dispatch, this.config);
|
|
@@ -296,15 +361,12 @@ export class ForgeBridgeService {
|
|
|
296
361
|
return;
|
|
297
362
|
}
|
|
298
363
|
}
|
|
299
|
-
const runtimeState = this.sandboxRuntime.snapshot();
|
|
300
|
-
const usedResources = this.usedSandboxResources(runtimeState.degraded);
|
|
301
|
-
const activeResources = this.activeSandboxResources(runtimeState.degraded);
|
|
302
|
-
const capacity = this.sandboxResourceCapacity(preflight, activeResources);
|
|
303
364
|
let requestedResources = sandbox.requestedResources;
|
|
304
365
|
let sandboxConfigCommitSha = sandbox.sandboxConfigCommitSha ?? null;
|
|
366
|
+
const normalizedDispatch = this.normalizeDispatch(dispatch);
|
|
305
367
|
if (sandbox.executionMode === "docker") {
|
|
306
368
|
try {
|
|
307
|
-
const profile = await this.sandboxResourceResolver(
|
|
369
|
+
const profile = await this.sandboxResourceResolver(normalizedDispatch);
|
|
308
370
|
requestedResources = profile.resources;
|
|
309
371
|
sandboxConfigCommitSha = profile.sandboxConfigCommitSha;
|
|
310
372
|
}
|
|
@@ -313,22 +375,85 @@ export class ForgeBridgeService {
|
|
|
313
375
|
return;
|
|
314
376
|
}
|
|
315
377
|
}
|
|
316
|
-
if (
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
+
};
|
|
320
420
|
}
|
|
321
|
-
|
|
322
|
-
|
|
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 = {
|
|
323
441
|
leaseId: dispatch.lease.id,
|
|
324
442
|
fencingToken: dispatch.lease.fencingToken,
|
|
325
443
|
runnerInstanceId: sandbox.runnerInstanceId,
|
|
326
444
|
expiresAt: dispatch.lease.expiresAt,
|
|
327
445
|
reservedUntil,
|
|
328
446
|
sandboxed: sandbox.executionMode === "docker",
|
|
329
|
-
resources: requestedResources
|
|
330
|
-
}
|
|
331
|
-
|
|
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
|
+
};
|
|
332
457
|
}
|
|
333
458
|
async handleStart(rawDispatch) {
|
|
334
459
|
const envelope = parseStartEnvelope(rawDispatch);
|
|
@@ -637,17 +762,20 @@ export class ForgeBridgeService {
|
|
|
637
762
|
}
|
|
638
763
|
expireLeases() {
|
|
639
764
|
const now = Date.now();
|
|
640
|
-
|
|
641
|
-
if (Date.parse(reservation.expiresAt) <= now || Date.parse(reservation.reservedUntil) <= now) {
|
|
642
|
-
this.reservations.delete(attemptId);
|
|
643
|
-
}
|
|
644
|
-
}
|
|
765
|
+
this.expireReservations(now);
|
|
645
766
|
for (const active of this.activeSessions.values()) {
|
|
646
767
|
if (Date.parse(active.leaseExpiresAt) <= now && !active.controller.signal.aborted) {
|
|
647
768
|
active.controller.abort("dispatch lease expired");
|
|
648
769
|
}
|
|
649
770
|
}
|
|
650
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
|
+
}
|
|
651
779
|
stopHeartbeat() {
|
|
652
780
|
if (this.heartbeatTimer) {
|
|
653
781
|
clearInterval(this.heartbeatTimer);
|
|
@@ -716,6 +844,11 @@ export class ForgeBridgeService {
|
|
|
716
844
|
}
|
|
717
845
|
}
|
|
718
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();
|
|
719
852
|
await this.invokeAck("Heartbeat", {
|
|
720
853
|
status: "online",
|
|
721
854
|
capabilities: this.capabilities(),
|
|
@@ -729,7 +862,18 @@ export class ForgeBridgeService {
|
|
|
729
862
|
const reserved = [...this.reservations.values()].filter(reservation => reservation.sandboxed).length;
|
|
730
863
|
const used = this.usedSandboxResources(runtimeState.degraded);
|
|
731
864
|
const activeResources = this.activeSandboxResources(runtimeState.degraded);
|
|
732
|
-
const capacity = this.sandboxResourceCapacity(preflight, activeResources);
|
|
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;
|
|
733
877
|
return {
|
|
734
878
|
schemaVersion: 3,
|
|
735
879
|
activeSessionCount: this.activeSessions.size,
|
|
@@ -752,7 +896,7 @@ export class ForgeBridgeService {
|
|
|
752
896
|
allowsOpenPublicSandbox: this.config.allowOpenPublicSandbox,
|
|
753
897
|
sandboxPublicationModes: [this.config.sandboxPublicationMode ?? "shared_filesystem"],
|
|
754
898
|
sandboxAuthModes: sandboxAuthModesFor(this.config),
|
|
755
|
-
availableMemoryBytes:
|
|
899
|
+
availableMemoryBytes: memoryAdmission?.availableForAdmissionBytes ?? null,
|
|
756
900
|
availableCpuMillis: preflight ? Math.max(0, capacity.cpuMillis - used.cpuMillis) : null,
|
|
757
901
|
availableWorkspaceBytes: preflight ? Math.max(0, capacity.workspaceBytes - used.workspaceBytes) : null,
|
|
758
902
|
diskLow: preflight?.diskLow ?? null,
|
|
@@ -766,8 +910,11 @@ export class ForgeBridgeService {
|
|
|
766
910
|
hostReserveBytes: preflight.hostReserveBytes,
|
|
767
911
|
activeReservationBytes: used.memoryBytes,
|
|
768
912
|
daemonTotalBytes: preflight.totalMemoryBytes,
|
|
769
|
-
daemonAvailableBytes: preflight.
|
|
770
|
-
|
|
913
|
+
daemonAvailableBytes: preflight.rawDaemonAvailableMemoryBytes ?? 0,
|
|
914
|
+
hostAvailableBytes: preflight.rawHostAvailableMemoryBytes,
|
|
915
|
+
conservativeAvailableBytes: preflight.rawAvailableMemoryBytes,
|
|
916
|
+
availableForAdmissionBytes: memoryAdmission.availableForAdmissionBytes,
|
|
917
|
+
memoryAdmission: memoryAdmission,
|
|
771
918
|
} : null,
|
|
772
919
|
hostRuntime: this.sessions.runtimeCapacity?.() ?? null,
|
|
773
920
|
supportsApiSecretGrant: this.config.supportsApiSecretGrant,
|
|
@@ -799,9 +946,15 @@ export class ForgeBridgeService {
|
|
|
799
946
|
...Array.from({ length: degradedReservations }, () => defaultSandboxResources())
|
|
800
947
|
]);
|
|
801
948
|
}
|
|
802
|
-
sandboxResourceCapacity(preflight, activeResources) {
|
|
949
|
+
sandboxResourceCapacity(preflight, activeResources, usedResources) {
|
|
803
950
|
if (!preflight)
|
|
804
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
|
+
}
|
|
805
958
|
return {
|
|
806
959
|
// Host free memory and disk already reflect active containers. Add their
|
|
807
960
|
// commitments back to form a stable total capacity, then subtract them
|
|
@@ -811,9 +964,7 @@ export class ForgeBridgeService {
|
|
|
811
964
|
workerMemoryLimitBytes: 0,
|
|
812
965
|
serviceMemoryLimitBytes: 0,
|
|
813
966
|
runtimeOverheadBytes: 0,
|
|
814
|
-
memoryBytes:
|
|
815
|
-
- (this.config.sandboxHostMemoryReserveBytes ?? 2 * 1024 ** 3)
|
|
816
|
-
+ activeResources.memoryBytes),
|
|
967
|
+
memoryBytes: sandboxMemoryCommitmentCapacity(preflight, activeResources.memoryBytes, this.sandboxAmbientMemoryCommitmentBytes),
|
|
817
968
|
workspaceBytes: Math.max(0, preflight.availableWorkspaceBytes
|
|
818
969
|
- this.config.minimumWorkspaceBytes
|
|
819
970
|
+ activeResources.workspaceBytes),
|
|
@@ -822,21 +973,81 @@ export class ForgeBridgeService {
|
|
|
822
973
|
};
|
|
823
974
|
}
|
|
824
975
|
async invokeAck(methodName, payload) {
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
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
|
+
}
|
|
828
1008
|
}
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
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;
|
|
832
1039
|
}
|
|
833
|
-
|
|
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;
|
|
834
1045
|
}
|
|
835
1046
|
async waitForClose(signal) {
|
|
836
1047
|
if (!this.closed) {
|
|
837
1048
|
return;
|
|
838
1049
|
}
|
|
839
|
-
await
|
|
1050
|
+
await waitForPromiseOrAbort(this.closed, signal);
|
|
840
1051
|
}
|
|
841
1052
|
async stopConnection() {
|
|
842
1053
|
this.stopHeartbeat();
|
|
@@ -862,6 +1073,11 @@ function isDispatchNoLongerPending(error) {
|
|
|
862
1073
|
}
|
|
863
1074
|
return error instanceof Error && error.message === "Dispatch is no longer pending.";
|
|
864
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
|
+
}
|
|
865
1081
|
function createDefaultSessionService(sandboxStateRoot) {
|
|
866
1082
|
const rawProcessRunner = new StreamingProviderProcessRunner();
|
|
867
1083
|
const processSupervisors = new AttemptProcessSupervisorRegistry();
|
|
@@ -890,10 +1106,11 @@ export class SignalRBridgeConnection {
|
|
|
890
1106
|
Authorization: `Bearer ${config.deviceToken}`
|
|
891
1107
|
}
|
|
892
1108
|
})
|
|
1109
|
+
.withAutomaticReconnect([...config.reconnectBackoffMs])
|
|
893
1110
|
.configureLogging(LogLevel.Warning)
|
|
894
1111
|
.build();
|
|
895
1112
|
this.connection.on("Superseded", () => {
|
|
896
|
-
void this.connection.stop();
|
|
1113
|
+
void this.connection.stop().catch(() => undefined);
|
|
897
1114
|
});
|
|
898
1115
|
}
|
|
899
1116
|
start() {
|
|
@@ -905,6 +1122,14 @@ export class SignalRBridgeConnection {
|
|
|
905
1122
|
onClose(handler) {
|
|
906
1123
|
this.connection.onclose(handler);
|
|
907
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
|
+
}
|
|
908
1133
|
onDispatch(handler) {
|
|
909
1134
|
this.connection.on("Dispatch", (raw) => {
|
|
910
1135
|
void handler(raw).catch(() => undefined);
|
|
@@ -947,9 +1172,7 @@ export class SignalRBridgeConnection {
|
|
|
947
1172
|
export function defaultBridgeServiceConfig(overrides = {}) {
|
|
948
1173
|
const defaults = defaultForgeBridgeConfig();
|
|
949
1174
|
const cliProviders = splitCsv(process.env.FORGE_CLI_PROVIDERS ?? "codex");
|
|
950
|
-
const
|
|
951
|
-
? 2 * 1024 ** 3
|
|
952
|
-
: 1024 ** 3;
|
|
1175
|
+
const configuredSandboxHostMemoryReserveBytes = optionalPositiveFiniteNumber(process.env.FORGE_SANDBOX_HOST_MEMORY_RESERVE_BYTES);
|
|
953
1176
|
const sandboxPublicationMode = process.env.FORGE_SANDBOX_PUBLICATION_MODE === "bridge_push" ? "bridge_push" : "shared_filesystem";
|
|
954
1177
|
const config = {
|
|
955
1178
|
serverUrl: process.env.FORGE_URL ?? "http://localhost:5208",
|
|
@@ -969,7 +1192,9 @@ export function defaultBridgeServiceConfig(overrides = {}) {
|
|
|
969
1192
|
diskHighWatermarkPercent: positiveFiniteNumber(Number(process.env.FORGE_DISK_HIGH_WATERMARK_PERCENT ?? 85), 85),
|
|
970
1193
|
diskLowWatermarkPercent: positiveFiniteNumber(Number(process.env.FORGE_DISK_LOW_WATERMARK_PERCENT ?? 75), 75),
|
|
971
1194
|
minimumWorkspaceBytes: positiveFiniteNumber(Number(process.env.FORGE_MINIMUM_WORKSPACE_BYTES ?? 10 * 1024 ** 3), 10 * 1024 ** 3),
|
|
972
|
-
|
|
1195
|
+
// Undefined means preflight derives the bounded reserve from the actual
|
|
1196
|
+
// Docker/host capacity domain after sampling it.
|
|
1197
|
+
sandboxHostMemoryReserveBytes: configuredSandboxHostMemoryReserveBytes,
|
|
973
1198
|
defaultSandboxWorkerMemoryLimitBytes: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_DEFAULT_WORKER_MEMORY_LIMIT_BYTES ?? 2 * 1024 ** 3), 2 * 1024 ** 3),
|
|
974
1199
|
defaultSandboxServiceMemoryLimitBytes: nonNegativeFiniteNumber(Number(process.env.FORGE_SANDBOX_DEFAULT_SERVICE_MEMORY_LIMIT_BYTES ?? 0), 0),
|
|
975
1200
|
sandboxRuntimeOverheadBytes: Math.max(128 * 1024 ** 2, nonNegativeFiniteNumber(Number(process.env.FORGE_SANDBOX_RUNTIME_OVERHEAD_BYTES ?? 128 * 1024 ** 2), 128 * 1024 ** 2)),
|
|
@@ -1022,6 +1247,12 @@ function normalizeServerUrl(serverUrl) {
|
|
|
1022
1247
|
function positiveFiniteNumber(value, fallback) {
|
|
1023
1248
|
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
1024
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
|
+
}
|
|
1025
1256
|
function nonNegativeFiniteNumber(value, fallback) {
|
|
1026
1257
|
return Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
1027
1258
|
}
|
|
@@ -1195,26 +1426,39 @@ function formatDelay(ms) {
|
|
|
1195
1426
|
function logBridgeStatus(message) {
|
|
1196
1427
|
process.stderr.write(`[${new Date().toISOString()}] ${message}\n`);
|
|
1197
1428
|
}
|
|
1198
|
-
function delay(ms, signal) {
|
|
1429
|
+
export function delay(ms, signal) {
|
|
1199
1430
|
if (signal?.aborted) {
|
|
1200
1431
|
return Promise.resolve();
|
|
1201
1432
|
}
|
|
1202
1433
|
return new Promise((resolve) => {
|
|
1203
|
-
const
|
|
1204
|
-
signal?.addEventListener("abort", () => {
|
|
1434
|
+
const complete = () => {
|
|
1205
1435
|
clearTimeout(timeout);
|
|
1436
|
+
signal?.removeEventListener("abort", complete);
|
|
1206
1437
|
resolve();
|
|
1207
|
-
}
|
|
1438
|
+
};
|
|
1439
|
+
const timeout = setTimeout(complete, ms);
|
|
1440
|
+
signal?.addEventListener("abort", complete, { once: true });
|
|
1441
|
+
if (signal?.aborted)
|
|
1442
|
+
complete();
|
|
1208
1443
|
});
|
|
1209
1444
|
}
|
|
1210
|
-
function
|
|
1445
|
+
export async function waitForPromiseOrAbort(promise, signal) {
|
|
1211
1446
|
if (!signal) {
|
|
1212
|
-
|
|
1447
|
+
await promise;
|
|
1448
|
+
return;
|
|
1213
1449
|
}
|
|
1214
1450
|
if (signal.aborted) {
|
|
1215
|
-
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);
|
|
1216
1461
|
}
|
|
1217
|
-
return new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
|
|
1218
1462
|
}
|
|
1219
1463
|
export function canReapManagedSandboxes(preflight) {
|
|
1220
1464
|
return canRunDockerMaintenance(preflight);
|
|
@@ -1234,9 +1478,14 @@ function sandboxNetworkPolicyFailure(dispatch, config) {
|
|
|
1234
1478
|
}
|
|
1235
1479
|
return null;
|
|
1236
1480
|
}
|
|
1237
|
-
function infrastructureFailureEventType(summary) {
|
|
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;
|
|
1238
1485
|
if (summary.startsWith("worktree_prepare_failed:"))
|
|
1239
1486
|
return "worktree_prepare_failed";
|
|
1487
|
+
if (summary.startsWith("provider_network_unavailable:"))
|
|
1488
|
+
return "provider_network_unavailable";
|
|
1240
1489
|
if (summary.startsWith("provider_start_unavailable:"))
|
|
1241
1490
|
return "provider_start_unavailable";
|
|
1242
1491
|
if (summary.startsWith("host_port_collision:"))
|
|
@@ -1308,6 +1557,15 @@ function sandboxAuthFailure(dispatch, config) {
|
|
|
1308
1557
|
const doctor = adapter.doctor();
|
|
1309
1558
|
return doctor.available ? null : doctor.message;
|
|
1310
1559
|
}
|
|
1560
|
+
function rejectedOfferReservation(code, message) {
|
|
1561
|
+
return {
|
|
1562
|
+
accepted: false,
|
|
1563
|
+
code,
|
|
1564
|
+
message,
|
|
1565
|
+
reservedUntil: null,
|
|
1566
|
+
resources: null,
|
|
1567
|
+
};
|
|
1568
|
+
}
|
|
1311
1569
|
export function canReserveSandboxResources(used, requested, capacity) {
|
|
1312
1570
|
return used.sandboxSlots + requested.sandboxSlots <= capacity.sandboxSlots
|
|
1313
1571
|
&& used.cpuMillis + requested.cpuMillis <= capacity.cpuMillis
|
|
@@ -1316,6 +1574,15 @@ export function canReserveSandboxResources(used, requested, capacity) {
|
|
|
1316
1574
|
&& used.pids + requested.pids <= capacity.pids
|
|
1317
1575
|
&& used.buildSlots + requested.buildSlots <= capacity.buildSlots;
|
|
1318
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
|
+
}
|
|
1319
1586
|
export function sandboxCapacityFailureMessage(used, requested, capacity, preflight, config) {
|
|
1320
1587
|
const availableSlots = Math.max(0, capacity.sandboxSlots - used.sandboxSlots);
|
|
1321
1588
|
if (requested.sandboxSlots > availableSlots) {
|
|
@@ -1346,14 +1613,6 @@ export function sandboxCapacityFailureMessage(used, requested, capacity, preflig
|
|
|
1346
1613
|
const availableBuildSlots = Math.max(0, capacity.buildSlots - used.buildSlots);
|
|
1347
1614
|
return `Sandbox build capacity is insufficient: requested ${requested.buildSlots}, available ${availableBuildSlots}, ${used.buildSlots} already reserved.`;
|
|
1348
1615
|
}
|
|
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
|
-
}
|
|
1357
1616
|
function sumSandboxResources(resources) {
|
|
1358
1617
|
return resources.reduce((total, resource) => ({
|
|
1359
1618
|
sandboxSlots: total.sandboxSlots + resource.sandboxSlots,
|
|
@@ -1425,8 +1684,16 @@ export async function resolveSandboxResourcesFromRepo(dispatch, baseRuntimeOverh
|
|
|
1425
1684
|
throw new Error("The approved immutable sandbox contract is unavailable in the mapped repository.");
|
|
1426
1685
|
}
|
|
1427
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
|
+
});
|
|
1428
1692
|
return {
|
|
1429
|
-
resources: resolveDynamicSandboxResources(
|
|
1693
|
+
resources: resolveDynamicSandboxResources({
|
|
1694
|
+
...sandbox.requestedResources,
|
|
1695
|
+
workspaceBytes: Math.max(sandbox.requestedResources.workspaceBytes, workspaceBytes),
|
|
1696
|
+
}, contract.contract.services.length, sandbox.networkPolicy.mode, baseRuntimeOverheadBytes),
|
|
1430
1697
|
sandboxConfigCommitSha: configCommitSha,
|
|
1431
1698
|
};
|
|
1432
1699
|
}
|