@loucompanion/forge-bridge 0.1.3-dev.f66395c3ac21 → 0.1.4-dev.bfa11ebe98c6
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 +165 -1
- package/dist/bridge/bridge-chat.types.d.ts +111 -0
- package/dist/bridge/bridge-chat.types.js +401 -0
- package/dist/bridge/bridge.base.d.ts +9 -1
- package/dist/bridge/bridge.service.d.ts +129 -3
- package/dist/bridge/bridge.service.js +2426 -70
- package/dist/bridge/bridge.types.d.ts +218 -2
- package/dist/bridge/dispatch-result-outbox.d.ts +25 -0
- package/dist/bridge/dispatch-result-outbox.js +62 -0
- package/dist/bridge/dispatch-result-worker.d.ts +2 -0
- package/dist/bridge/dispatch-result-worker.js +122 -0
- package/dist/bridge/internals.d.ts +2 -0
- package/dist/bridge/internals.js +1 -0
- package/dist/bridge/readiness-result-outbox.d.ts +21 -0
- package/dist/bridge/readiness-result-outbox.js +54 -0
- package/dist/bridge/readiness-result-worker.d.ts +2 -0
- package/dist/bridge/readiness-result-worker.js +121 -0
- package/dist/bridge/runner-host-capabilities.d.ts +15 -0
- package/dist/bridge/runner-host-capabilities.js +93 -0
- 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.d.ts +2 -0
- package/dist/cli/cli.help.js +217 -0
- package/dist/cli/cli.service.d.ts +55 -7
- package/dist/cli/cli.service.js +429 -219
- package/dist/cli/local-config.d.ts +7 -0
- package/dist/cli/local-config.js +51 -3
- package/dist/runtime/aspnet-runtime-prebuild.d.ts +36 -0
- package/dist/runtime/aspnet-runtime-prebuild.js +341 -0
- package/dist/runtime/attempt-node-runtime-launcher.d.ts +18 -0
- package/dist/runtime/attempt-node-runtime-launcher.js +140 -0
- package/dist/runtime/attempt-path-operation.d.ts +21 -0
- package/dist/runtime/attempt-path-operation.js +94 -0
- package/dist/runtime/attempt-private-directory.d.ts +6 -0
- package/dist/runtime/attempt-private-directory.js +54 -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 +397 -0
- package/dist/runtime/canonical-source-file.d.ts +3 -0
- package/dist/runtime/canonical-source-file.js +54 -0
- package/dist/runtime/docker-engine-probe.d.ts +90 -0
- package/dist/runtime/docker-engine-probe.js +333 -0
- package/dist/runtime/generated-output-reset.d.ts +20 -0
- package/dist/runtime/generated-output-reset.js +157 -0
- package/dist/runtime/host-database-capability-boundary.d.ts +16 -0
- package/dist/runtime/host-database-capability-boundary.js +62 -0
- package/dist/runtime/host-diagnostics.d.ts +11 -0
- package/dist/runtime/host-diagnostics.js +191 -0
- package/dist/runtime/host-execution-root.d.ts +4 -0
- package/dist/runtime/host-execution-root.js +39 -0
- package/dist/runtime/host-listener-inspector.d.ts +51 -0
- package/dist/runtime/host-listener-inspector.js +385 -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 +58 -0
- package/dist/runtime/host-runtime-contract.js +754 -0
- package/dist/runtime/host-runtime-lease.d.ts +215 -0
- package/dist/runtime/host-runtime-lease.js +2449 -0
- package/dist/runtime/host-runtime-promotion.d.ts +32 -0
- package/dist/runtime/host-runtime-promotion.js +206 -0
- package/dist/runtime/memory-capacity.d.ts +70 -0
- package/dist/runtime/memory-capacity.js +123 -0
- package/dist/runtime/native-test-postgres-fallback.d.ts +25 -0
- package/dist/runtime/native-test-postgres-fallback.js +91 -0
- package/dist/runtime/node-host-runtime-adapter.d.ts +54 -0
- package/dist/runtime/node-host-runtime-adapter.js +285 -0
- package/dist/runtime/pinned-directory-process.d.ts +9 -0
- package/dist/runtime/pinned-directory-process.js +61 -0
- package/dist/runtime/pinned-source-directory.d.ts +17 -0
- package/dist/runtime/pinned-source-directory.js +94 -0
- package/dist/runtime/playwright-host-runtime-adapter.d.ts +21 -0
- package/dist/runtime/playwright-host-runtime-adapter.js +268 -0
- package/dist/runtime/prepared-runtime-identity.d.ts +6 -0
- package/dist/runtime/prepared-runtime-identity.js +218 -0
- package/dist/runtime/prepared-runtime-readiness.d.ts +30 -0
- package/dist/runtime/prepared-runtime-readiness.js +285 -0
- package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
- package/dist/runtime/runtime-isolation-lease.js +161 -0
- package/dist/runtime/workspace-resource-estimator.d.ts +17 -0
- package/dist/runtime/workspace-resource-estimator.js +174 -0
- package/dist/sandbox/command-runner.d.ts +4 -0
- package/dist/sandbox/command-runner.js +47 -0
- package/dist/sandbox/contract.d.ts +5 -0
- package/dist/sandbox/contract.js +276 -0
- package/dist/sandbox/event-budget.d.ts +11 -0
- package/dist/sandbox/event-budget.js +27 -0
- package/dist/sandbox/image-policy.d.ts +35 -0
- package/dist/sandbox/image-policy.js +247 -0
- package/dist/sandbox/index.d.ts +5 -0
- package/dist/sandbox/index.js +5 -0
- package/dist/sandbox/lease-watchdog.d.ts +18 -0
- package/dist/sandbox/lease-watchdog.js +67 -0
- package/dist/sandbox/preflight.d.ts +84 -0
- package/dist/sandbox/preflight.js +521 -0
- package/dist/sandbox/process.d.ts +20 -0
- package/dist/sandbox/process.js +209 -0
- package/dist/sandbox/provider-auth/codex.d.ts +26 -0
- package/dist/sandbox/provider-auth/codex.js +212 -0
- package/dist/sandbox/repository-lock.d.ts +6 -0
- package/dist/sandbox/repository-lock.js +128 -0
- package/dist/sandbox/runtime.d.ts +302 -0
- package/dist/sandbox/runtime.js +2367 -0
- package/dist/sandbox/sandbox.types.d.ts +93 -0
- package/dist/sandbox/sandbox.types.js +1 -0
- package/dist/sandbox/smoke.d.ts +76 -0
- package/dist/sandbox/smoke.js +380 -0
- package/dist/sandbox/worker-supervisor.d.ts +13 -0
- package/dist/sandbox/worker-supervisor.js +36 -0
- package/dist/sandbox/worker.d.ts +1 -0
- package/dist/sandbox/worker.js +225 -0
- package/dist/sandbox/workspace.service.d.ts +13 -0
- package/dist/sandbox/workspace.service.js +74 -0
- package/dist/session/managed-attempt-tools.d.ts +28 -0
- package/dist/session/managed-attempt-tools.js +1011 -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 +337 -0
- package/dist/session/managed-verification-timeouts.d.ts +8 -0
- package/dist/session/managed-verification-timeouts.js +17 -0
- package/dist/session/phase-completion.artifacts.d.ts +27 -0
- package/dist/session/phase-completion.artifacts.js +829 -0
- package/dist/session/phase-completion.d.ts +53 -42
- package/dist/session/phase-completion.helpers.d.ts +117 -0
- package/dist/session/phase-completion.helpers.js +707 -0
- package/dist/session/phase-completion.js +125 -3007
- package/dist/session/phase-completion.prompts.d.ts +14 -0
- package/dist/session/phase-completion.prompts.js +233 -0
- package/dist/session/phase-completion.runtime.d.ts +49 -0
- package/dist/session/phase-completion.runtime.js +833 -0
- package/dist/session/phase-completion.schemas.d.ts +3 -0
- package/dist/session/phase-completion.schemas.js +859 -0
- package/dist/session/phase-completion.validation.d.ts +38 -0
- package/dist/session/phase-completion.validation.js +918 -0
- package/dist/session/pre-publication-validation.d.ts +75 -0
- package/dist/session/pre-publication-validation.js +657 -0
- package/dist/session/provider-cli/bridge-cli.conformance-fixtures.d.ts +72 -0
- package/dist/session/provider-cli/bridge-cli.conformance-fixtures.js +462 -0
- package/dist/session/provider-cli/ci-repair-readiness.d.ts +16 -0
- package/dist/session/provider-cli/ci-repair-readiness.js +110 -0
- package/dist/session/provider-cli/provider-cli.artifacts.d.ts +95 -0
- package/dist/session/provider-cli/provider-cli.artifacts.js +812 -0
- package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
- package/dist/session/provider-cli/provider-cli.deterministic-payloads.d.ts +12 -0
- package/dist/session/provider-cli/provider-cli.deterministic-payloads.js +279 -0
- package/dist/session/provider-cli/provider-cli.deterministic.d.ts +14 -0
- package/dist/session/provider-cli/provider-cli.deterministic.js +407 -0
- package/dist/session/provider-cli/provider-cli.process.d.ts +99 -0
- package/dist/session/provider-cli/provider-cli.process.js +874 -0
- package/dist/session/provider-cli/provider-cli.prompts.d.ts +52 -0
- package/dist/session/provider-cli/provider-cli.prompts.js +715 -0
- package/dist/session/provider-cli/provider-cli.qa-sidecars.d.ts +11 -0
- package/dist/session/provider-cli/provider-cli.qa-sidecars.js +66 -0
- package/dist/session/provider-cli/provider-cli.repair-epoch.d.ts +6 -0
- package/dist/session/provider-cli/provider-cli.repair-epoch.js +11 -0
- package/dist/session/provider-cli/provider-cli.runtime.d.ts +16 -0
- package/dist/session/provider-cli/provider-cli.runtime.js +78 -0
- package/dist/session/provider-cli/provider-cli.service.d.ts +47 -53
- package/dist/session/provider-cli/provider-cli.service.js +1257 -2497
- package/dist/session/provider-cli/provider-cli.types.d.ts +8 -0
- package/dist/session/provider-cli/provider-output-state.d.ts +32 -0
- package/dist/session/provider-cli/provider-output-state.js +817 -0
- 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 +448 -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 +586 -59
- package/dist/session/session.types.d.ts +324 -3
- package/dist/session/session.types.js +939 -10
- package/dist/session/unreferenced-exports.d.ts +12 -0
- package/dist/session/unreferenced-exports.js +59 -0
- package/dist/shared/format/format-bytes.d.ts +1 -0
- package/dist/shared/format/format-bytes.js +9 -0
- package/dist/shared/git/git.js +2 -1
- package/dist/shared/path/path.d.ts +1 -0
- package/dist/shared/path/path.js +21 -1
- package/dist/shared/process/dotnet-toolchain.d.ts +17 -0
- package/dist/shared/process/dotnet-toolchain.js +307 -0
- 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 +3 -0
- package/dist/shared/process/process.js +13 -7
- package/dist/shared/process/windows-launch.d.ts +8 -0
- package/dist/shared/process/windows-launch.js +41 -0
- package/dist/shared/provider-capabilities.d.ts +4 -0
- package/dist/shared/provider-capabilities.js +88 -0
- package/dist/shared/redaction/redaction.d.ts +1 -0
- package/dist/shared/redaction/redaction.js +47 -4
- 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 +82 -10
- package/dist/worktree/dependency-warmer.d.ts +53 -1
- package/dist/worktree/dependency-warmer.js +619 -11
- 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/story-qa-reference-hydrator.d.ts +4 -0
- package/dist/worktree/story-qa-reference-hydrator.js +253 -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 +16 -3
- package/dist/worktree/worktree.service.js +334 -54
- package/dist/worktree/worktree.types.d.ts +9 -1
- package/package.json +12 -7
- package/runtime/windows-job-supervisor.ps1 +722 -0
- package/runtime/windows-listener-owner.ps1 +33 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { ForgeFailureError, sanitizeFailureError } from "../session/session.types.js";
|
|
5
|
+
const terminalChildStatuses = new Set(["exited", "failed"]);
|
|
6
|
+
const defaultMetadataReadyTimeoutMs = process.platform === "win32" ? 60_000 : 10_000;
|
|
7
|
+
/**
|
|
8
|
+
* Owns the independently supervised process trees that belong to one host
|
|
9
|
+
* dispatch attempt. Each named child gets a distinct Windows Job Object or
|
|
10
|
+
* Unix process group through the existing process runner, while this
|
|
11
|
+
* coordinator supplies shared fencing, cancellation, and a durable ledger.
|
|
12
|
+
*/
|
|
13
|
+
export class AttemptProcessSupervisor {
|
|
14
|
+
runner;
|
|
15
|
+
ledgerPath;
|
|
16
|
+
identity;
|
|
17
|
+
metadataReadyTimeoutMs;
|
|
18
|
+
records = [];
|
|
19
|
+
currentByName = new Map();
|
|
20
|
+
attemptController = new AbortController();
|
|
21
|
+
status = "active";
|
|
22
|
+
persistTail = Promise.resolve();
|
|
23
|
+
persistenceFailure;
|
|
24
|
+
stopOperation = null;
|
|
25
|
+
constructor(identity, runner, options = {}) {
|
|
26
|
+
this.runner = runner;
|
|
27
|
+
this.identity = validateIdentity(identity);
|
|
28
|
+
this.metadataReadyTimeoutMs = boundedInteger(options.metadataReadyTimeoutMs ?? defaultMetadataReadyTimeoutMs, 100, 120_000, "metadataReadyTimeoutMs");
|
|
29
|
+
this.ledgerPath = path.join(this.identity.logDirectory, "attempt-process-supervisor.json");
|
|
30
|
+
if (options.signal?.aborted) {
|
|
31
|
+
this.status = "stopping";
|
|
32
|
+
this.attemptController.abort(options.signal.reason);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
options.signal?.addEventListener("abort", () => {
|
|
36
|
+
// stopAll already persists a degraded terminal snapshot when a child
|
|
37
|
+
// rejects during cancellation. Do not relabel that process failure as
|
|
38
|
+
// a persistence failure: doing so poisons every later cleanup retry,
|
|
39
|
+
// even after all supervised processes have exited.
|
|
40
|
+
void this.stopAll(this.identity.fencingToken, options.signal?.reason).catch(() => undefined);
|
|
41
|
+
}, { once: true });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async startChild(name, request, handlers = {}, signal) {
|
|
45
|
+
assertChildName(name);
|
|
46
|
+
if (this.status !== "active" || this.attemptController.signal.aborted) {
|
|
47
|
+
throw new Error("attempt_process_supervisor_closed: the attempt no longer accepts child processes.");
|
|
48
|
+
}
|
|
49
|
+
if (signal?.aborted)
|
|
50
|
+
throw cancellationError(signal);
|
|
51
|
+
const existing = this.currentByName.get(name);
|
|
52
|
+
if (existing && !terminalChildStatuses.has(existing.status)) {
|
|
53
|
+
throw new Error(`attempt_process_supervisor_conflict: child '${name}' is already active.`);
|
|
54
|
+
}
|
|
55
|
+
const generation = (existing?.generation ?? 0) + 1;
|
|
56
|
+
const childId = `${name}:${generation}`;
|
|
57
|
+
const processSupervisorId = childProcessSupervisorId(this.identity.processSupervisorId, childId);
|
|
58
|
+
const metadataPath = path.join(this.identity.logDirectory, `process-supervisor.${name}.${generation}.json`);
|
|
59
|
+
const controller = new AbortController();
|
|
60
|
+
const combinedSignal = signal
|
|
61
|
+
? AbortSignal.any([this.attemptController.signal, controller.signal, signal])
|
|
62
|
+
: AbortSignal.any([this.attemptController.signal, controller.signal]);
|
|
63
|
+
const failureRedactionValues = [...new Set((request.failureRedactionValues ?? []).filter(value => value.length > 0))];
|
|
64
|
+
const { failureRedactionValues: _failureRedactionValues, ...executableRequest } = request;
|
|
65
|
+
const childRequest = {
|
|
66
|
+
...executableRequest,
|
|
67
|
+
env: {
|
|
68
|
+
...request.env,
|
|
69
|
+
FORGE_RUNTIME_MODE: "host",
|
|
70
|
+
FORGE_RESOURCE_LEASE_ID: this.identity.executionLeaseId,
|
|
71
|
+
FORGE_EXECUTION_LEASE_ID: this.identity.executionLeaseId,
|
|
72
|
+
FORGE_DISPATCH_ATTEMPT_ID: this.identity.dispatchAttemptId,
|
|
73
|
+
FORGE_FENCING_TOKEN: this.identity.fencingToken,
|
|
74
|
+
FORGE_LOG_DIR: this.identity.logDirectory,
|
|
75
|
+
FORGE_PROCESS_SUPERVISOR_ID: processSupervisorId,
|
|
76
|
+
FORGE_PROCESS_SUPERVISOR_METADATA_PATH: metadataPath,
|
|
77
|
+
FORGE_PROCESS_CHILD_ID: childId,
|
|
78
|
+
FORGE_PROCESS_CHILD_NAME: name,
|
|
79
|
+
FORGE_PROCESS_CHILD_GENERATION: String(generation),
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
const startedAt = new Date().toISOString();
|
|
83
|
+
const record = {
|
|
84
|
+
name,
|
|
85
|
+
generation,
|
|
86
|
+
childId,
|
|
87
|
+
processSupervisorId,
|
|
88
|
+
metadataPath,
|
|
89
|
+
controller,
|
|
90
|
+
failureRedactionValues,
|
|
91
|
+
startedAt,
|
|
92
|
+
status: "starting",
|
|
93
|
+
result: Promise.resolve({ exitCode: 1, stdout: "", stderr: "not started" }),
|
|
94
|
+
};
|
|
95
|
+
this.records.push(record);
|
|
96
|
+
this.currentByName.set(name, record);
|
|
97
|
+
const result = this.runner.run(childRequest, handlers, combinedSignal);
|
|
98
|
+
record.result = result;
|
|
99
|
+
void result.then(value => this.settleChild(record, "exited", value.exitCode, undefined, value), error => this.settleChild(record, "failed", undefined, error));
|
|
100
|
+
try {
|
|
101
|
+
await this.persist();
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
controller.abort("Attempt process ledger could not be persisted.");
|
|
105
|
+
await result.catch(() => undefined);
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
const ready = this.observeChildReady(record);
|
|
109
|
+
void ready.catch(() => undefined);
|
|
110
|
+
return {
|
|
111
|
+
name,
|
|
112
|
+
generation,
|
|
113
|
+
childId,
|
|
114
|
+
processSupervisorId,
|
|
115
|
+
metadataPath,
|
|
116
|
+
ready,
|
|
117
|
+
result,
|
|
118
|
+
stop: async (fencingToken) => await this.stopChild(name, generation, fencingToken),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Starts a child and proves that the OS process tree is supervised before
|
|
123
|
+
* returning it to the caller. A launch that settles before producing its
|
|
124
|
+
* provider-ready supervision record is safe to retry: the raw runner only
|
|
125
|
+
* persists that record after the actual provider child has started, before
|
|
126
|
+
* Forge announces the provider as active.
|
|
127
|
+
*/
|
|
128
|
+
async startChildReady(name, request, handlers = {}, signal, options = {}) {
|
|
129
|
+
const maxStartAttempts = boundedInteger(options.maxStartAttempts ?? 1, 1, 5, "maxStartAttempts");
|
|
130
|
+
const retryDelayMs = boundedInteger(options.retryDelayMs ?? 100, 0, 5_000, "retryDelayMs");
|
|
131
|
+
let lastError;
|
|
132
|
+
for (let attempt = 1; attempt <= maxStartAttempts; attempt += 1) {
|
|
133
|
+
const child = await this.startChild(name, request, handlers, signal);
|
|
134
|
+
try {
|
|
135
|
+
await child.ready;
|
|
136
|
+
return child;
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
lastError = error;
|
|
140
|
+
await child.result.catch(() => undefined);
|
|
141
|
+
if (!isRetryableStartFailure(error) || attempt === maxStartAttempts)
|
|
142
|
+
throw error;
|
|
143
|
+
if (signal?.aborted)
|
|
144
|
+
throw cancellationError(signal);
|
|
145
|
+
if (retryDelayMs > 0)
|
|
146
|
+
await wait(retryDelayMs * attempt);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
throw lastError;
|
|
150
|
+
}
|
|
151
|
+
async stopChild(name, generation, fencingToken, reason = "Forge stopped the supervised child.") {
|
|
152
|
+
this.assertFence(fencingToken);
|
|
153
|
+
const record = this.currentByName.get(name);
|
|
154
|
+
if (!record || record.generation !== generation) {
|
|
155
|
+
throw new Error(`attempt_process_supervisor_stale: child '${name}' generation ${generation} is no longer current.`);
|
|
156
|
+
}
|
|
157
|
+
if (!terminalChildStatuses.has(record.status)) {
|
|
158
|
+
record.status = "stopping";
|
|
159
|
+
record.controller.abort(reason);
|
|
160
|
+
await this.persist();
|
|
161
|
+
}
|
|
162
|
+
return await record.result;
|
|
163
|
+
}
|
|
164
|
+
async stopAll(fencingToken, reason = "Forge stopped the supervised attempt.") {
|
|
165
|
+
this.assertFence(fencingToken);
|
|
166
|
+
// The durable terminal ledger is part of the stop operation's contract.
|
|
167
|
+
// Join an in-flight stop before inspecting terminal state so lease cleanup
|
|
168
|
+
// cannot remove the log directory underneath its final persistence.
|
|
169
|
+
if (this.stopOperation)
|
|
170
|
+
return await this.stopOperation;
|
|
171
|
+
if (this.status === "stopped")
|
|
172
|
+
return;
|
|
173
|
+
const operation = this.performStopAll(reason);
|
|
174
|
+
this.stopOperation = operation;
|
|
175
|
+
void operation.catch(() => undefined).finally(() => {
|
|
176
|
+
if (this.stopOperation === operation && this.status !== "stopped")
|
|
177
|
+
this.stopOperation = null;
|
|
178
|
+
});
|
|
179
|
+
return await this.stopOperation;
|
|
180
|
+
}
|
|
181
|
+
async performStopAll(reason) {
|
|
182
|
+
this.status = "stopping";
|
|
183
|
+
this.attemptController.abort(reason);
|
|
184
|
+
const active = [...this.currentByName.values()].filter(record => !terminalChildStatuses.has(record.status));
|
|
185
|
+
for (const record of active)
|
|
186
|
+
record.status = "stopping";
|
|
187
|
+
await this.persist();
|
|
188
|
+
const results = await Promise.allSettled(active.map(record => record.result));
|
|
189
|
+
const rejected = results.flatMap((result, index) => result.status === "rejected"
|
|
190
|
+
? [sanitizeFailureError(result.reason, active[index].failureRedactionValues)]
|
|
191
|
+
: []);
|
|
192
|
+
const terminalStatus = rejected.length > 0 || this.persistenceFailure !== undefined ? "degraded" : "stopped";
|
|
193
|
+
await this.persist({
|
|
194
|
+
...this.snapshot(),
|
|
195
|
+
status: terminalStatus,
|
|
196
|
+
updatedAt: new Date().toISOString(),
|
|
197
|
+
});
|
|
198
|
+
this.status = terminalStatus;
|
|
199
|
+
if (rejected.length > 0) {
|
|
200
|
+
throw new AggregateError(rejected, "attempt_process_supervisor_cleanup_degraded");
|
|
201
|
+
}
|
|
202
|
+
if (this.persistenceFailure !== undefined)
|
|
203
|
+
throw this.persistenceFailure;
|
|
204
|
+
}
|
|
205
|
+
snapshot() {
|
|
206
|
+
return {
|
|
207
|
+
schemaVersion: 1,
|
|
208
|
+
attemptSupervisorId: this.identity.processSupervisorId,
|
|
209
|
+
dispatchAttemptId: this.identity.dispatchAttemptId,
|
|
210
|
+
executionLeaseId: this.identity.executionLeaseId,
|
|
211
|
+
status: this.status,
|
|
212
|
+
updatedAt: new Date().toISOString(),
|
|
213
|
+
children: this.records.map(record => ({
|
|
214
|
+
name: record.name,
|
|
215
|
+
generation: record.generation,
|
|
216
|
+
childId: record.childId,
|
|
217
|
+
processSupervisorId: record.processSupervisorId,
|
|
218
|
+
metadataPath: record.metadataPath,
|
|
219
|
+
status: record.status,
|
|
220
|
+
startedAt: record.startedAt,
|
|
221
|
+
...(record.settledAt ? { settledAt: record.settledAt } : {}),
|
|
222
|
+
...(record.exitCode === undefined ? {} : { exitCode: record.exitCode }),
|
|
223
|
+
...(record.failure ? { failure: record.failure } : {}),
|
|
224
|
+
...(record.peakMemoryBytes === undefined ? {} : { peakMemoryBytes: record.peakMemoryBytes }),
|
|
225
|
+
...(record.memorySampleCount === undefined ? {} : { memorySampleCount: record.memorySampleCount }),
|
|
226
|
+
...(record.memorySource ? { memorySource: record.memorySource } : {}),
|
|
227
|
+
...(record.memoryObservedAt ? { memoryObservedAt: record.memoryObservedAt } : {}),
|
|
228
|
+
})),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
providerMemoryTelemetry() {
|
|
232
|
+
const observations = this.records
|
|
233
|
+
.filter(record => record.name === "provider"
|
|
234
|
+
&& record.peakMemoryBytes !== undefined
|
|
235
|
+
&& record.peakMemoryBytes > 0
|
|
236
|
+
&& record.memorySampleCount !== undefined
|
|
237
|
+
&& record.memorySampleCount > 0
|
|
238
|
+
&& record.memorySource
|
|
239
|
+
&& record.memoryObservedAt);
|
|
240
|
+
if (observations.length === 0)
|
|
241
|
+
return null;
|
|
242
|
+
const peak = observations.reduce((current, candidate) => (candidate.peakMemoryBytes ?? 0) > (current.peakMemoryBytes ?? 0) ? candidate : current);
|
|
243
|
+
return {
|
|
244
|
+
peakMemoryBytes: peak.peakMemoryBytes,
|
|
245
|
+
sampleCount: observations.reduce((total, candidate) => total + (candidate.memorySampleCount ?? 0), 0),
|
|
246
|
+
source: peak.memorySource,
|
|
247
|
+
observedAt: peak.memoryObservedAt,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
async observeChildReady(record) {
|
|
251
|
+
const deadline = Date.now() + this.metadataReadyTimeoutMs;
|
|
252
|
+
while (Date.now() <= deadline) {
|
|
253
|
+
if (await fileExists(record.metadataPath)) {
|
|
254
|
+
if (record.status === "starting") {
|
|
255
|
+
record.status = "running";
|
|
256
|
+
await this.persist();
|
|
257
|
+
}
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (terminalChildStatuses.has(record.status)) {
|
|
261
|
+
const detail = await childStartFailureDetail(record);
|
|
262
|
+
throw new ForgeFailureError("attempt_process_supervisor_start_failed", `attempt_process_supervisor_start_failed: child '${record.childId}' exited before supervision metadata was produced${detail ? `: ${detail}` : "."}`, "bridge");
|
|
263
|
+
}
|
|
264
|
+
await wait(25);
|
|
265
|
+
}
|
|
266
|
+
record.controller.abort("Supervised child metadata was not produced before the readiness deadline.");
|
|
267
|
+
const detail = await childStartFailureDetail(record);
|
|
268
|
+
throw new ForgeFailureError("attempt_process_supervisor_start_timeout", `attempt_process_supervisor_start_timeout: child '${record.childId}' did not become supervised${detail ? `: ${detail}` : "."}`, "bridge");
|
|
269
|
+
}
|
|
270
|
+
async settleChild(record, status, exitCode, failure, result) {
|
|
271
|
+
record.status = status;
|
|
272
|
+
record.settledAt = new Date().toISOString();
|
|
273
|
+
record.exitCode = exitCode;
|
|
274
|
+
record.failure = failure === undefined
|
|
275
|
+
? undefined
|
|
276
|
+
: boundedFailure(failure, record.failureRedactionValues);
|
|
277
|
+
if (result?.peakMemoryBytes !== undefined
|
|
278
|
+
&& result.peakMemoryBytes > 0
|
|
279
|
+
&& result.memorySampleCount !== undefined
|
|
280
|
+
&& result.memorySampleCount > 0
|
|
281
|
+
&& result.memorySource
|
|
282
|
+
&& result.memoryObservedAt) {
|
|
283
|
+
record.peakMemoryBytes = result.peakMemoryBytes;
|
|
284
|
+
record.memorySampleCount = result.memorySampleCount;
|
|
285
|
+
record.memorySource = result.memorySource;
|
|
286
|
+
record.memoryObservedAt = result.memoryObservedAt;
|
|
287
|
+
}
|
|
288
|
+
await this.persist().catch(error => {
|
|
289
|
+
this.persistenceFailure ??= error;
|
|
290
|
+
this.status = "degraded";
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
assertFence(fencingToken) {
|
|
294
|
+
if (fencingToken !== this.identity.fencingToken) {
|
|
295
|
+
throw new Error("attempt_process_supervisor_fenced: fencing token does not own this attempt.");
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
persist(snapshot = this.snapshot()) {
|
|
299
|
+
const operation = this.persistTail.then(async () => {
|
|
300
|
+
await ensurePrivateDirectory(this.identity.logDirectory);
|
|
301
|
+
const temporary = `${this.ledgerPath}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
|
|
302
|
+
try {
|
|
303
|
+
const handle = await fs.open(temporary, "wx", 0o600);
|
|
304
|
+
try {
|
|
305
|
+
await handle.writeFile(`${JSON.stringify(snapshot)}\n`);
|
|
306
|
+
await handle.sync();
|
|
307
|
+
}
|
|
308
|
+
finally {
|
|
309
|
+
await handle.close();
|
|
310
|
+
}
|
|
311
|
+
await fs.rename(temporary, this.ledgerPath);
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
await fs.rm(temporary, { force: true }).catch(() => undefined);
|
|
315
|
+
throw error;
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
this.persistTail = operation.catch(() => undefined);
|
|
319
|
+
return operation;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
function validateIdentity(identity) {
|
|
323
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/u.test(identity.processSupervisorId)) {
|
|
324
|
+
throw new Error("attempt_process_supervisor_invalid: process supervisor id is invalid.");
|
|
325
|
+
}
|
|
326
|
+
assertUuid(identity.dispatchAttemptId, "dispatch attempt");
|
|
327
|
+
assertUuid(identity.executionLeaseId, "execution lease");
|
|
328
|
+
assertUuid(identity.fencingToken, "fencing token");
|
|
329
|
+
if (!path.isAbsolute(identity.logDirectory)) {
|
|
330
|
+
throw new Error("attempt_process_supervisor_invalid: log directory must be absolute.");
|
|
331
|
+
}
|
|
332
|
+
return { ...identity, logDirectory: path.resolve(identity.logDirectory) };
|
|
333
|
+
}
|
|
334
|
+
function assertChildName(name) {
|
|
335
|
+
if (!/^[a-z][a-z0-9_-]{0,31}$/u.test(name)) {
|
|
336
|
+
throw new Error("attempt_process_supervisor_invalid: child name must be a safe lowercase identifier.");
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
function childProcessSupervisorId(attemptSupervisorId, childId) {
|
|
340
|
+
const digest = createHash("sha256")
|
|
341
|
+
.update(JSON.stringify([attemptSupervisorId, childId]))
|
|
342
|
+
.digest("hex")
|
|
343
|
+
.slice(0, 32);
|
|
344
|
+
return `host-${digest}`;
|
|
345
|
+
}
|
|
346
|
+
function assertUuid(value, label) {
|
|
347
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(value)) {
|
|
348
|
+
throw new Error(`attempt_process_supervisor_invalid: ${label} id must be a UUID.`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
function boundedInteger(value, minimum, maximum, name) {
|
|
352
|
+
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
|
353
|
+
throw new Error(`attempt_process_supervisor_invalid: ${name} must be between ${minimum} and ${maximum}.`);
|
|
354
|
+
}
|
|
355
|
+
return value;
|
|
356
|
+
}
|
|
357
|
+
function boundedFailure(error, exactSecrets = []) {
|
|
358
|
+
const message = sanitizeFailureError(error, exactSecrets).message;
|
|
359
|
+
return message.replaceAll(/[\r\n\t]+/gu, " ").slice(0, 512);
|
|
360
|
+
}
|
|
361
|
+
async function childStartFailureDetail(record) {
|
|
362
|
+
try {
|
|
363
|
+
const result = await record.result;
|
|
364
|
+
const stderr = boundedDiagnostic(result.stderr, record.failureRedactionValues);
|
|
365
|
+
return stderr
|
|
366
|
+
? `exit ${result.exitCode}; stderr: ${stderr}`
|
|
367
|
+
: `exit ${result.exitCode}`;
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
return boundedFailure(error, record.failureRedactionValues);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
function boundedDiagnostic(value, exactSecrets = []) {
|
|
374
|
+
return sanitizeFailureError(value, exactSecrets).message
|
|
375
|
+
.replaceAll(/[\r\n\t]+/gu, " ")
|
|
376
|
+
.trim()
|
|
377
|
+
.slice(0, 512);
|
|
378
|
+
}
|
|
379
|
+
function isRetryableStartFailure(error) {
|
|
380
|
+
return error instanceof ForgeFailureError
|
|
381
|
+
&& (error.failureCode === "attempt_process_supervisor_start_failed"
|
|
382
|
+
|| error.failureCode === "attempt_process_supervisor_start_timeout");
|
|
383
|
+
}
|
|
384
|
+
function cancellationError(signal) {
|
|
385
|
+
return new Error(String(signal.reason ?? "Supervised child launch was cancelled."));
|
|
386
|
+
}
|
|
387
|
+
async function ensurePrivateDirectory(directory) {
|
|
388
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
389
|
+
if (process.platform !== "win32")
|
|
390
|
+
await fs.chmod(directory, 0o700);
|
|
391
|
+
}
|
|
392
|
+
async function fileExists(target) {
|
|
393
|
+
return await fs.stat(target).then(() => true, () => false);
|
|
394
|
+
}
|
|
395
|
+
function wait(milliseconds) {
|
|
396
|
+
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
397
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { PinnedDirectoryIdentity } from "./pinned-directory-process.js";
|
|
2
|
+
/** Keep the validated source inode open: reopening a checked pathname can follow a replacement. */
|
|
3
|
+
export declare function readCanonicalSourceFile(target: string, maximumBytes: number, expectedIdentity?: PinnedDirectoryIdentity): Promise<Buffer>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
/** Keep the validated source inode open: reopening a checked pathname can follow a replacement. */
|
|
5
|
+
export async function readCanonicalSourceFile(target, maximumBytes, expectedIdentity) {
|
|
6
|
+
const resolved = path.resolve(target);
|
|
7
|
+
const pathBefore = await canonicalStat(resolved);
|
|
8
|
+
if (expectedIdentity && !sameFile(pathBefore, expectedIdentity)) {
|
|
9
|
+
throw new Error("host_runtime_source_changed: source identity changed before reading.");
|
|
10
|
+
}
|
|
11
|
+
const handle = await fs.open(resolved, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)
|
|
12
|
+
| (constants.O_NONBLOCK ?? 0));
|
|
13
|
+
try {
|
|
14
|
+
const before = await handle.stat({ bigint: true });
|
|
15
|
+
if (!before.isFile() || before.nlink !== 1n || !sameFile(pathBefore, before) || before.size > BigInt(maximumBytes)) {
|
|
16
|
+
throw new Error("host_runtime_source_invalid: source changed or exceeds the byte bound.");
|
|
17
|
+
}
|
|
18
|
+
if (!sameFile(before, await canonicalStat(resolved))) {
|
|
19
|
+
throw new Error("host_runtime_source_changed: source changed before reading.");
|
|
20
|
+
}
|
|
21
|
+
const bytes = Buffer.alloc(Number(before.size));
|
|
22
|
+
let offset = 0;
|
|
23
|
+
while (offset < bytes.length) {
|
|
24
|
+
const read = await handle.read(bytes, offset, bytes.length - offset, offset);
|
|
25
|
+
if (!read.bytesRead)
|
|
26
|
+
throw new Error("host_runtime_source_changed: incomplete source read.");
|
|
27
|
+
offset += read.bytesRead;
|
|
28
|
+
}
|
|
29
|
+
const after = await handle.stat({ bigint: true });
|
|
30
|
+
const pathAfter = await canonicalStat(resolved);
|
|
31
|
+
if (after.nlink !== 1n || !sameFile(before, after) || !sameFile(after, pathAfter) || before.size !== after.size
|
|
32
|
+
|| before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs) {
|
|
33
|
+
throw new Error("host_runtime_source_changed: source changed while reading.");
|
|
34
|
+
}
|
|
35
|
+
return bytes;
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
await handle.close();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function canonicalStat(target) {
|
|
42
|
+
const stat = await fs.lstat(target, { bigint: true });
|
|
43
|
+
const real = await fs.realpath(target);
|
|
44
|
+
const realStat = await fs.lstat(real, { bigint: true });
|
|
45
|
+
const samePath = process.platform === "win32" ? real.toLowerCase() === target.toLowerCase() : real === target;
|
|
46
|
+
if (!samePath || !stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1n || !realStat.isFile()
|
|
47
|
+
|| realStat.isSymbolicLink() || realStat.nlink !== 1n || !sameFile(stat, realStat)) {
|
|
48
|
+
throw new Error("host_runtime_source_invalid: source must be a canonical regular file.");
|
|
49
|
+
}
|
|
50
|
+
return realStat;
|
|
51
|
+
}
|
|
52
|
+
function sameFile(left, right) {
|
|
53
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
54
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { CommandRunner } from "../sandbox/sandbox.types.js";
|
|
2
|
+
export interface DockerEngineCapabilitySnapshot {
|
|
3
|
+
readonly available: boolean;
|
|
4
|
+
readonly sampledAt: string;
|
|
5
|
+
readonly runtimeVersion: string | null;
|
|
6
|
+
readonly operatingSystem: string | null;
|
|
7
|
+
readonly context: string;
|
|
8
|
+
readonly unavailableCode: string | null;
|
|
9
|
+
readonly unavailableReason: string | null;
|
|
10
|
+
}
|
|
11
|
+
export interface DockerEngineProbeOptions {
|
|
12
|
+
readonly dockerExecutable?: string;
|
|
13
|
+
readonly dockerContext?: string;
|
|
14
|
+
readonly recovery?: DockerEngineRecoveryPolicy;
|
|
15
|
+
readonly recoveryExecutor?: DockerEngineRecoveryExecutor;
|
|
16
|
+
readonly platform?: NodeJS.Platform;
|
|
17
|
+
readonly dockerHost?: string;
|
|
18
|
+
readonly now?: () => number;
|
|
19
|
+
readonly wait?: (delayMs: number) => Promise<void>;
|
|
20
|
+
readonly onStatus?: (message: string) => void;
|
|
21
|
+
}
|
|
22
|
+
export interface DockerEngineRecoveryPolicy {
|
|
23
|
+
readonly enabled: boolean;
|
|
24
|
+
readonly failureThreshold?: number;
|
|
25
|
+
readonly confirmationDelayMs?: number;
|
|
26
|
+
readonly cooldownMs?: number;
|
|
27
|
+
readonly startupTimeoutMs?: number;
|
|
28
|
+
readonly pollIntervalMs?: number;
|
|
29
|
+
readonly canRecover?: () => boolean;
|
|
30
|
+
}
|
|
31
|
+
export interface DockerEngineRecoveryResult {
|
|
32
|
+
readonly started: boolean;
|
|
33
|
+
readonly reason?: string;
|
|
34
|
+
}
|
|
35
|
+
export interface DockerEngineRecoveryExecutor {
|
|
36
|
+
restart(): Promise<DockerEngineRecoveryResult>;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Probes ordinary Docker-daemon access for host-run Testcontainers and project
|
|
40
|
+
* services. This is deliberately independent from the stricter Forge sandbox
|
|
41
|
+
* preflight: a usable Docker Desktop engine may satisfy docker_engine even
|
|
42
|
+
* when it is not approved to host an agent sandbox.
|
|
43
|
+
*/
|
|
44
|
+
export declare class DockerEngineProbe {
|
|
45
|
+
private readonly commands;
|
|
46
|
+
private readonly cacheMs;
|
|
47
|
+
private cached;
|
|
48
|
+
private sampledAtMs;
|
|
49
|
+
private inFlight;
|
|
50
|
+
private requiredWorkRecoveryInFlight;
|
|
51
|
+
private consecutiveFailures;
|
|
52
|
+
private lastRecoveryAttemptAtMs;
|
|
53
|
+
private readonly docker;
|
|
54
|
+
private readonly context;
|
|
55
|
+
private readonly platform;
|
|
56
|
+
private readonly dockerHost;
|
|
57
|
+
private readonly now;
|
|
58
|
+
private readonly wait;
|
|
59
|
+
private readonly recovery;
|
|
60
|
+
private readonly recoveryExecutor;
|
|
61
|
+
private readonly onStatus;
|
|
62
|
+
constructor(options?: DockerEngineProbeOptions, commands?: CommandRunner, cacheMs?: number);
|
|
63
|
+
current(): DockerEngineCapabilitySnapshot | null;
|
|
64
|
+
refresh(force?: boolean): Promise<DockerEngineCapabilitySnapshot>;
|
|
65
|
+
/**
|
|
66
|
+
* Establishes a fresh daemon fence for work that declares Docker as a hard
|
|
67
|
+
* prerequisite. This remains bounded, cooldown protected and single-flight;
|
|
68
|
+
* it never contacts Docker unless a caller explicitly invokes this method.
|
|
69
|
+
*/
|
|
70
|
+
ensureAvailableForRequiredWork(): Promise<DockerEngineCapabilitySnapshot>;
|
|
71
|
+
private inspectSafely;
|
|
72
|
+
private recoverIfEligible;
|
|
73
|
+
private isLocalDockerDesktopContext;
|
|
74
|
+
private inspect;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Restarts only the local Docker Desktop installation. It never targets a
|
|
78
|
+
* remote daemon and is used only behind the explicit bridge recovery policy.
|
|
79
|
+
*/
|
|
80
|
+
export declare class WindowsDockerDesktopRecoveryExecutor implements DockerEngineRecoveryExecutor {
|
|
81
|
+
private readonly platform;
|
|
82
|
+
private readonly commands;
|
|
83
|
+
private readonly programFiles;
|
|
84
|
+
private readonly wait;
|
|
85
|
+
constructor(platform?: NodeJS.Platform, commands?: CommandRunner, programFiles?: string, wait?: (delayMs: number) => Promise<void>);
|
|
86
|
+
restart(): Promise<DockerEngineRecoveryResult>;
|
|
87
|
+
private readProcessState;
|
|
88
|
+
private cleanupOrphanedCompanions;
|
|
89
|
+
private startDesktop;
|
|
90
|
+
}
|