@agent-delivery-harness/cli 0.8.1 → 0.8.3
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/package.json +2 -2
- package/src/boundary.ts +7 -4
- package/src/check-snapshot.ts +75 -2
- package/src/delivery-posture.ts +7 -5
- package/src/fixtures/live-descriptors-parent.ts +50 -0
- package/src/scoped-checks.ts +235 -38
- package/src/scoped-diagnostics.ts +3 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-delivery-harness/cli",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Delivery harness CLI: admit, prepare, review-context, submit-evidence, gate, record, verify, check",
|
|
6
6
|
"license": "FSL-1.1-ALv2",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"node": ">=22.6.0"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@agent-delivery-harness/kernel": "0.8.
|
|
16
|
+
"@agent-delivery-harness/kernel": "0.8.3"
|
|
17
17
|
},
|
|
18
18
|
"exports": {
|
|
19
19
|
".": "./src/index.ts"
|
package/src/boundary.ts
CHANGED
|
@@ -244,6 +244,7 @@ export interface CliRuntime {
|
|
|
244
244
|
readonly command: readonly [string, ...string[]];
|
|
245
245
|
readonly cwd: string;
|
|
246
246
|
readonly env: EnvSnapshot;
|
|
247
|
+
readonly inheritDescriptors?: readonly (3 | 4)[];
|
|
247
248
|
}) => Promise<ProviderRailSession>;
|
|
248
249
|
}
|
|
249
250
|
|
|
@@ -533,6 +534,8 @@ async function runConfiguredCommand(
|
|
|
533
534
|
const provider = config.providers.find((registration) => registration.id === providerId);
|
|
534
535
|
if (provider?.command === undefined) return undefined;
|
|
535
536
|
const command = provider.command;
|
|
537
|
+
// Only this provider's own declaration reaches its process.
|
|
538
|
+
const inherited = provider.inheritDescriptors;
|
|
536
539
|
const requestId = randomUUID();
|
|
537
540
|
const allocation = await artifacts.allocateRunRoot({ providerId, runId: requestId });
|
|
538
541
|
if (!allocation.ok) {
|
|
@@ -566,10 +569,10 @@ async function runConfiguredCommand(
|
|
|
566
569
|
requiresEvidence,
|
|
567
570
|
},
|
|
568
571
|
{
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
572
|
+
// One call, so the test seam is given exactly what the real launch is.
|
|
573
|
+
open: () => (runtime.openProviderRail ?? openProviderRailProcess)({
|
|
574
|
+
providerId, command, cwd: runtime.cwd, env: { ...runtime.env }, ...(inherited ? { inheritDescriptors: inherited } : {}),
|
|
575
|
+
}),
|
|
573
576
|
publishManifest: (manifestPath) =>
|
|
574
577
|
submitManifest(
|
|
575
578
|
{ rootDir: runtime.cwd, config, manifestPath },
|
package/src/check-snapshot.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Private execution tree. Never shares writable source, objects, index or dependencies. */
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
-
import {
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { chmod, lstat, mkdir, mkdtemp, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
4
5
|
import { tmpdir } from "node:os";
|
|
5
6
|
import path from "node:path";
|
|
6
7
|
import { candidateTreeSourceReader, capturePhysicalSource, createExecPort, isPhysicalSourceDefinition, physicalSourceOutputOverlap, type CandidateCommandRunner, type PhysicalSourceDefinition, type ExecOutcome } from "@agent-delivery-harness/kernel";
|
|
@@ -60,8 +61,80 @@ function phaseMeter(onPhase?: (phase: SnapshotPhase, durationMs: number) => void
|
|
|
60
61
|
try { return await operation(); } finally { try { onPhase?.(phase, performance.now() - started); } catch { /* Timing cannot change admission. */ } }
|
|
61
62
|
};
|
|
62
63
|
}
|
|
64
|
+
/** Where removed trees wait to be deleted: beside them in the temporary directory, so moving one is a rename on
|
|
65
|
+
* the same volume. Nothing here is evidence, and nothing reads it. */
|
|
66
|
+
const trash = () => path.join(tmpdir(), `delivery-harness-trash-${process.getuid?.() ?? "user"}`);
|
|
67
|
+
/**
|
|
68
|
+
* The trash, only when it is this user's own private directory. A temporary directory may be shared, and the
|
|
69
|
+
* sweep deletes what it lists, so a link or another user's directory at that name is never followed: the
|
|
70
|
+
* caller removes in place instead.
|
|
71
|
+
*/
|
|
72
|
+
async function ownTrash(create: boolean): Promise<string | undefined> {
|
|
73
|
+
const dir = trash();
|
|
74
|
+
try {
|
|
75
|
+
if (create) await mkdir(dir, { mode: 0o700 }).catch(error => { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; });
|
|
76
|
+
const found = await lstat(dir);
|
|
77
|
+
// A link's own mode usually fails the mode test as well; isDirectory() still refuses one made private.
|
|
78
|
+
return found.isDirectory() && found.uid === process.getuid?.() && (found.mode & 0o077) === 0 ? dir : undefined;
|
|
79
|
+
} catch { return undefined; }
|
|
80
|
+
}
|
|
81
|
+
/** Deletes `paths` in a process this one does not wait for or outlive the need of. Best effort: whatever a stopped
|
|
82
|
+
* deletion leaves in the trash, the next session's sweep removes. Batched so no argument list outgrows the host's. */
|
|
83
|
+
function deleteDetached(paths: readonly string[]): void {
|
|
84
|
+
for (let at = 0; at < paths.length; at += 256) {
|
|
85
|
+
try {
|
|
86
|
+
const child = spawn("/bin/rm", ["-rf", "--", ...paths.slice(at, at + 256)], { detached: true, stdio: "ignore" });
|
|
87
|
+
child.on("error", () => { /* Left for the next sweep. */ });
|
|
88
|
+
child.unref();
|
|
89
|
+
} catch { /* Left for the next sweep. */ }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** Whether the process that moved a tree aside may still be deleting it. A reused pid only delays a sweep. */
|
|
93
|
+
function ownerAlive(pid: number): boolean {
|
|
94
|
+
try { process.kill(pid, 0); return true; }
|
|
95
|
+
catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; }
|
|
96
|
+
}
|
|
97
|
+
/** Removes, in the background, what sessions that have ended left in the trash. An entry is named for the process
|
|
98
|
+
* that moved it there; one whose owner is still alive, this process included, is left to that owner's deletion. */
|
|
99
|
+
export async function sweepRemovedTrees(): Promise<void> {
|
|
100
|
+
if (process.platform === "win32") return;
|
|
101
|
+
const dir = await ownTrash(false);
|
|
102
|
+
if (!dir) return;
|
|
103
|
+
const entries = await readdir(dir).catch(() => [] as string[]);
|
|
104
|
+
deleteDetached(entries.filter(entry => {
|
|
105
|
+
const pid = Number(/^(\d+)-/.exec(entry)?.[1]);
|
|
106
|
+
return !Number.isSafeInteger(pid) || pid <= 0 || !ownerAlive(pid);
|
|
107
|
+
}).map(entry => path.join(dir, entry)));
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Takes each owned tree out of its path at once, by renaming it into the trash, and deletes it in a detached
|
|
111
|
+
* process: a tree with installed dependencies can take ten seconds or more to remove, and the evidence it
|
|
112
|
+
* produced is already retained. A tree that cannot be moved is removed in place, as before, and only a tree
|
|
113
|
+
* neither moved nor removed fails the cleanup.
|
|
114
|
+
*/
|
|
63
115
|
async function removeTrees(directories: readonly string[]): Promise<void> {
|
|
64
|
-
const
|
|
116
|
+
const moved: string[] = [];
|
|
117
|
+
const into = process.platform === "win32" ? undefined : await ownTrash(true);
|
|
118
|
+
const removed = await Promise.allSettled(directories.map(async dir => {
|
|
119
|
+
if (into) {
|
|
120
|
+
try {
|
|
121
|
+
// A rename never copies; on another volume the tree is removed in place instead. The device check only
|
|
122
|
+
// spares a doomed rename: one across volumes fails with EXDEV and falls through to the same removal.
|
|
123
|
+
const [from, to] = await Promise.all([stat(path.dirname(dir)), stat(into)]);
|
|
124
|
+
if (from.dev === to.dev) {
|
|
125
|
+
const target = path.join(into, `${process.pid}-${randomUUID()}`);
|
|
126
|
+
await rename(dir, target);
|
|
127
|
+
moved.push(target);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
} catch (error) {
|
|
131
|
+
// Done only when the tree itself is gone, not when the trash went missing under the move.
|
|
132
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT" && await lstat(dir).then(() => false, gone => (gone as NodeJS.ErrnoException).code === "ENOENT")) return;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
await rm(dir, { recursive: true, force: true });
|
|
136
|
+
}));
|
|
137
|
+
deleteDetached(moved);
|
|
65
138
|
if (removed.some(result => result.status === "rejected")) throw new CheckSnapshotError("check_snapshot_cleanup_failed", "Cannot remove the owned execution snapshot after retaining evidence.");
|
|
66
139
|
}
|
|
67
140
|
/** One copy-on-write clone of a directory tree into a path that must not exist yet. On macOS
|
package/src/delivery-posture.ts
CHANGED
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
* delivery record and run no obligation, and that outcome is the whole record.
|
|
7
7
|
* It journals no completion: `gate ok` in a run would read as a gate that passed.
|
|
8
8
|
* Under `gate` the command carries on, with output as it would be had nothing
|
|
9
|
-
* been declared.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* been declared. There are two exceptions, each of which writes its code and
|
|
10
|
+
* reason to stderr, leaving what a command writes to stdout as it is: a malformed
|
|
11
|
+
* declaration, where a repository said something and it didn't take, and a
|
|
12
|
+
* command run from a directory that is not its repository's root, which is the
|
|
13
|
+
* caller's to fix rather than the base's.
|
|
12
14
|
*/
|
|
13
|
-
import { resolveDeliveryPosture } from "@agent-delivery-harness/kernel";
|
|
15
|
+
import { deliveryPostureRefusedOutsideRoot, resolveDeliveryPosture } from "@agent-delivery-harness/kernel";
|
|
14
16
|
import { CliInterruption, type CommandContext, type CommandResult } from "./boundary.ts";
|
|
15
17
|
import { oneLine } from "./run-surface.ts";
|
|
16
18
|
|
|
@@ -34,6 +36,6 @@ export async function focusedOutcome(context: CommandContext): Promise<Extract<C
|
|
|
34
36
|
].join("\n"),
|
|
35
37
|
};
|
|
36
38
|
}
|
|
37
|
-
if (resolved.code === "posture_malformed") context.writeError(`${resolved.code}: ${oneLine(resolved.reason, 512)}`);
|
|
39
|
+
if (resolved.code === "posture_malformed" || deliveryPostureRefusedOutsideRoot(resolved)) context.writeError(`${resolved.code}: ${oneLine(resolved.reason, 512)}`);
|
|
38
40
|
return undefined;
|
|
39
41
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// V26-3314: run by live-providers.test.ts with a marker pipe on fd 3. Collects
|
|
2
|
+
// live results once with a provider that declares fd 3 and once with one that
|
|
3
|
+
// does not, and prints what each provider held on fd 3 and what its
|
|
4
|
+
// environment said it inherited.
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
import adopter from "../../../../harness.config.ts";
|
|
11
|
+
import { captureGitCandidate, channelIdentity, collectLiveProviderResults, computePreparationFingerprint, defineHarnessConfig, evaluateCandidateActivation, INHERITED_DESCRIPTORS_ENV, withDeliverableIdentity } from "@agent-delivery-harness/kernel";
|
|
12
|
+
|
|
13
|
+
const exec = promisify(execFile);
|
|
14
|
+
const provider = `const fs=require('fs');let held=null;try{held=fs.readFileSync(3,'utf8')}catch{}
|
|
15
|
+
fs.writeFileSync('.git/inherited',JSON.stringify({held,stated:process.env.${INHERITED_DESCRIPTORS_ENV}??null}));
|
|
16
|
+
require('readline').createInterface({input:process.stdin}).on('line',line=>{const m=JSON.parse(line);
|
|
17
|
+
if(m.kind==='negotiate')console.log(JSON.stringify({kind:'negotiation',outcome:'supported',selectedVersion:'delivery-provider-rails/1',supportedVersions:['delivery-provider-rails/1']}));
|
|
18
|
+
if(m.kind==='request')console.log(JSON.stringify({kind:'terminal',version:m.version,requestId:m.requestId,sequence:1,summary:'observed',outcome:'success'}));});`;
|
|
19
|
+
|
|
20
|
+
async function collect(env: NodeJS.ProcessEnv): Promise<unknown[]> {
|
|
21
|
+
const reports: unknown[] = [];
|
|
22
|
+
for (const declared of [false, true]) {
|
|
23
|
+
const dir = await mkdtemp(path.join(tmpdir(), "live-descriptors-"));
|
|
24
|
+
try {
|
|
25
|
+
const git = (...args: string[]) => exec("git", args, { cwd: dir });
|
|
26
|
+
await git("init", "-q"); await git("config", "user.name", "Test"); await git("config", "user.email", "test@example.invalid");
|
|
27
|
+
await writeFile(path.join(dir, "source.ts"), "export const a=1;"); await writeFile(path.join(dir, "harness.config.ts"), "export default {};");
|
|
28
|
+
await git("add", "."); await git("-c", "commit.gpgsign=false", "commit", "-qm", "base"); await git("branch", "origin/main");
|
|
29
|
+
const config = defineHarnessConfig({ ...adopter, preparationCommands: [], preparationWiringPaths: ["harness.config.ts"],
|
|
30
|
+
providers: [{ id: "live.sensor", findingCodes: [], command: [process.execPath, "-e", provider], ...(declared ? { inheritDescriptors: [3] as const } : {}) }],
|
|
31
|
+
obligations: [{ ...adopter.obligations[0]!, id: "live.current", freshness: "live", providers: ["live.sensor"], activation: { kind: "always" }, humanWaiverAllowed: false, allowedResolutionKinds: ["satisfied_live_fact", "not_applicable"] }] });
|
|
32
|
+
const capture = await captureGitCandidate({ rootDir: dir, config, workspaceId: "test-workspace", computeIdentity: withDeliverableIdentity() });
|
|
33
|
+
if (!capture.ok) throw new Error(JSON.stringify(capture));
|
|
34
|
+
const result = await collectLiveProviderResults({ rootDir: dir, config, candidate: capture.candidate,
|
|
35
|
+
projection: await evaluateCandidateActivation({ rootDir: dir, config, candidate: capture.candidate }),
|
|
36
|
+
evidenceContext: { preparationFingerprint: await computePreparationFingerprint(dir, config), release: null },
|
|
37
|
+
env: { ...env, [INHERITED_DESCRIPTORS_ENV]: `3:${channelIdentity(3)}` } });
|
|
38
|
+
reports.push({ status: result.liveResults[0]?.status ?? null, ...JSON.parse(await readFile(path.join(dir, ".git/inherited"), "utf8")) });
|
|
39
|
+
} finally {
|
|
40
|
+
await rm(dir, { recursive: true, force: true });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return reports;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function main(): Promise<void> {
|
|
47
|
+
console.log(JSON.stringify(await collect(process.env)));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
await main();
|
package/src/scoped-checks.ts
CHANGED
|
@@ -1,19 +1,34 @@
|
|
|
1
1
|
/** Invocation-scoped execution coordinator; durable truth remains per-check evidence. */
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { access, mkdir, readFile, realpath, rm, stat } from "node:fs/promises";
|
|
3
|
+
import { access, mkdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { deliveryRecordPathFor, recordOutputProvider, computeDeliverableIdentity, capturePhysicalSource, captureCheckBindings, captureCheckOutputSnapshots, candidateTreeEvidenceReader, candidateTreeSourceReader, createExecPort, digestCanonical, resolveRecordStorage, runGitCommand, scopedCheckIdentity, selectScopedCheckAttempt, sha256Hex, submitManifest,
|
|
6
6
|
createResourceApplicabilityProjection, matchResourceApplicabilityProjection, createResourceApplicabilityBinding, reconstructResourceApplicability, discoverRecords, portableArtifactContents, isRecordFreshForCandidate, isRecordNeutralPath, type ResourceApplicabilityBinding, type InstalledDataAttemptOrigin, type InstalledDataResourceRequest, normalizeInstalledDataResourceRequests, type InstalledDataResourceContext, type InstalledDataResourceCapture,
|
|
7
7
|
type CandidateBinding, type CapturedCandidate, type ProviderRegistration, type ScopedCheckPlan, type ScopedExecutionProfile, type ScopedRuntimeObservation, type RecordCandidateBinding } from "@agent-delivery-harness/kernel";
|
|
8
8
|
import type { CommandContext } from "./boundary.ts";
|
|
9
9
|
import { AttemptStore, type AttemptPayload, type StoredAttempt } from "./scoped-attempts.ts";
|
|
10
|
-
import { CheckSnapshotError, createCheckSnapshot, executionPath, snapshotCloneMethod, type CheckSnapshot, type CloneMethod, type SnapshotRequest } from "./check-snapshot.ts";
|
|
10
|
+
import { CheckSnapshotError, createCheckSnapshot, executionPath, snapshotCloneMethod, sweepRemovedTrees, type CheckSnapshot, type CloneMethod, type SnapshotRequest } from "./check-snapshot.ts";
|
|
11
11
|
import { captureInstalledDataBinding, type InstalledDataSourceTree } from "./scoped-installed-data.ts";
|
|
12
12
|
import { captureOwnedInstalledDataResources } from "./installed-data-resources.ts";
|
|
13
13
|
import { redactScopedStreams, scopedCommandDiagnostic, scopedDependencyDiagnostic, scopedDiagnosticFailure, type RecordedScopedAttemptDiagnostic, type ScopedSetupDiagnostic } from "./scoped-diagnostics.ts";
|
|
14
14
|
export function scopedCandidate(candidate: CandidateBinding): RecordCandidateBinding {
|
|
15
15
|
return { treeSha: candidate.treeSha, deliverableDigest: candidate.deliverable.digest, identityToken: candidate.deliverable.identity, baseRef: candidate.base.ref, baseTipSha: candidate.base.tipSha, mergeBaseSha: candidate.base.mergeBaseSha, workspaceId: candidate.workspaceId };
|
|
16
16
|
}
|
|
17
|
+
/** A host tool's sha256 within this process, by the file's identity and last change: every scoped check binds
|
|
18
|
+
* the same node, package manager and setup tool, and each is often 50–120 MB. A changed file has a new key. */
|
|
19
|
+
const hostToolDigests = new Map<string, Promise<string>>();
|
|
20
|
+
export async function hostToolDigest(file: string, readBytes: (file: string) => Promise<Uint8Array> = readFile): Promise<{ sha256: string; mode: number }> {
|
|
21
|
+
const before = await stat(file);
|
|
22
|
+
const key = JSON.stringify([before.dev, before.ino, before.size, before.mtimeMs, before.ctimeMs]);
|
|
23
|
+
let digest = hostToolDigests.get(key);
|
|
24
|
+
if (!digest) {
|
|
25
|
+
// ctime moves on every write, so a file changed during this read can never present this key again.
|
|
26
|
+
digest = readBytes(file).then(bytes => sha256Hex(bytes));
|
|
27
|
+
hostToolDigests.set(key, digest);
|
|
28
|
+
digest.catch(() => { if (hostToolDigests.get(key) === digest) hostToolDigests.delete(key); });
|
|
29
|
+
}
|
|
30
|
+
return { sha256: await digest, mode: before.mode & 0o111 };
|
|
31
|
+
}
|
|
17
32
|
async function executableIdentity(command: string, root: string, searchPath: string, cwd: string, read: Awaited<ReturnType<typeof candidateTreeEvidenceReader>>) {
|
|
18
33
|
if (command.includes(path.sep) && !path.isAbsolute(command)) {
|
|
19
34
|
const file = path.posix.normalize(path.posix.join(cwd, command));
|
|
@@ -27,7 +42,8 @@ async function executableIdentity(command: string, root: string, searchPath: str
|
|
|
27
42
|
try { await access(file); } catch { continue; }
|
|
28
43
|
const relative = path.relative(await realpath(root), await realpath(file));
|
|
29
44
|
if (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) throw new CheckSnapshotError("check_runtime_author_path", "Repository execution tools must use a relative command so they run from the private prepared tree.");
|
|
30
|
-
|
|
45
|
+
const { sha256, mode } = await hostToolDigest(file);
|
|
46
|
+
return { command, sha256, mode };
|
|
31
47
|
}
|
|
32
48
|
throw new CheckSnapshotError("check_runtime_unavailable", "A declared execution tool cannot be resolved and bound.");
|
|
33
49
|
}
|
|
@@ -60,11 +76,25 @@ function oneAtATime() {
|
|
|
60
76
|
let tail: Promise<unknown> = Promise.resolve();
|
|
61
77
|
return <T>(task: () => Promise<T>): Promise<T> => { const result = tail.then(task); tail = result.catch(() => undefined); return result; };
|
|
62
78
|
}
|
|
79
|
+
/** Runs the tasks it is given one at a time; of those waiting, the highest `priority` goes next, ties in call order. */
|
|
80
|
+
function highestFirst() {
|
|
81
|
+
const waiting: { priority: number; start: () => void }[] = [];
|
|
82
|
+
let busy = false;
|
|
83
|
+
const next = () => {
|
|
84
|
+
if (busy || waiting.length === 0) return;
|
|
85
|
+
const index = waiting.reduce((best, entry, i) => entry.priority > waiting[best]!.priority ? i : best, 0);
|
|
86
|
+
busy = true; waiting.splice(index, 1)[0]!.start();
|
|
87
|
+
};
|
|
88
|
+
return <T>(priority: number, task: () => Promise<T>): Promise<T> => new Promise<T>((resolve, reject) => {
|
|
89
|
+
waiting.push({ priority, start: () => { task().then(resolve, reject).finally(() => { busy = false; next(); }); } });
|
|
90
|
+
next();
|
|
91
|
+
});
|
|
92
|
+
}
|
|
63
93
|
type AcquiredSetup = Omit<ScopedSetupDiagnostic, "verification" | "phases">;
|
|
64
94
|
/** A fail-fast gate's hold on one check: the signal that cancels it, and the check whose failure did. */
|
|
65
95
|
interface Cancellation { readonly signal: AbortSignal; readonly after: () => string }
|
|
66
96
|
/** One worker's view while it runs a check: the snapshots only it uses, by setup key, and where its lines go. */
|
|
67
|
-
interface Lane { readonly snapshots: Map<string, CheckSnapshot>; readonly write: (text: string) => void }
|
|
97
|
+
interface Lane { readonly snapshots: Map<string, CheckSnapshot>; readonly write: (text: string) => void; readonly priority?: number }
|
|
68
98
|
export class ScopedChecks {
|
|
69
99
|
/** `scopedExecution.concurrency` workers, each owning its snapshots; the first is the serial lane. */
|
|
70
100
|
private readonly workers: Map<string, CheckSnapshot>[];
|
|
@@ -82,6 +112,16 @@ export class ScopedChecks {
|
|
|
82
112
|
/** With more than one worker: one read-only warm base per setup key, which no command runs in. Each
|
|
83
113
|
* worker takes a copy-on-write clone of it; without cloning each worker builds its own, as before. */
|
|
84
114
|
private readonly bases = new Map<string, Promise<CheckSnapshot>>();
|
|
115
|
+
/** Each base's settlement, after which its entry is current: a failed setup has left `bases`. */
|
|
116
|
+
private readonly settling = new WeakMap<Promise<CheckSnapshot>, Promise<void>>();
|
|
117
|
+
private readonly settled = new WeakSet<Promise<CheckSnapshot>>();
|
|
118
|
+
/** Stops the bases `prewarm` started, which no attempt owns, once no check can need one still setting up. */
|
|
119
|
+
private readonly prewarming = new AbortController();
|
|
120
|
+
/** Prewarmed bases not yet taken as a worker's own tree, which a path-bound one becomes. */
|
|
121
|
+
private readonly prewarmedBases = new WeakSet<Promise<CheckSnapshot>>();
|
|
122
|
+
/** Concurrent copy-on-write clones of one large tree each take several times as long as one alone, so
|
|
123
|
+
* they run one at a time, the check expected to run longest first. */
|
|
124
|
+
private readonly cloningOne = highestFirst();
|
|
85
125
|
private cloning?: Promise<CloneMethod | undefined>;
|
|
86
126
|
/** Profiles whose checks a tree has run and verified since it was made: their outputs may be in it. */
|
|
87
127
|
private readonly ranOutputs = new WeakMap<CheckSnapshot, Set<string>>();
|
|
@@ -143,6 +183,8 @@ export class ScopedChecks {
|
|
|
143
183
|
static async create(context: CommandContext, candidate: CapturedCandidate): Promise<ScopedChecks | undefined> {
|
|
144
184
|
const providers = scopedProviders(context);
|
|
145
185
|
if (!providers.length) return undefined;
|
|
186
|
+
// Trees an earlier session moved aside but did not finish deleting: one listing, in the background.
|
|
187
|
+
void sweepRemovedTrees().catch(() => undefined);
|
|
146
188
|
const session = new ScopedChecks(context, candidate);
|
|
147
189
|
const storage = await resolveRecordStorage(context.rootDir, { storageNamespace: context.config.storageNamespace, leaf: "scoped-attempts" });
|
|
148
190
|
const { inventory, read } = await preparedSource(context, candidate);
|
|
@@ -234,14 +276,22 @@ export class ScopedChecks {
|
|
|
234
276
|
* duration less that attempt's own dependency setup; never-timed checks follow timed ones and ties keep
|
|
235
277
|
* declaration order. Checks then run one dependency setup at a time, in the order each setup first
|
|
236
278
|
* appears, so each setup installs once. */
|
|
279
|
+
/** A check's most recent passed or failed attempt's duration less that attempt's own dependency setup, any
|
|
280
|
+
* wait for another's warm base, and its clone with that clone's queue; undefined for a never-timed check. */
|
|
281
|
+
private async lastCost(id: string): Promise<number | undefined> {
|
|
282
|
+
const last = (await this.stores.get(id)!.read()).filter(r => r.attempt.status === "passed" || r.attempt.status === "failed").at(-1)?.payload;
|
|
283
|
+
const waited = last?.diagnostic?.setup?.phases.filter(p => p.phase === "warm-base-wait" || p.phase === "clone").reduce((sum, p) => sum + p.durationMs, 0) ?? 0;
|
|
284
|
+
return last?.durationMs === undefined ? undefined : last.durationMs - (last.diagnostic?.dependency?.durationMs ?? 0) - waited;
|
|
285
|
+
}
|
|
286
|
+
/** How long each check is expected to run: its last cost, or, never timed, its declared `timeoutMs`. */
|
|
287
|
+
private async expectedCosts(ids: readonly string[]): Promise<Map<string, number>> {
|
|
288
|
+
const costs = new Map<string, number>();
|
|
289
|
+
for (const id of ids) costs.set(id, await this.lastCost(id) ?? this.context.config.providers.find(p => p.id === id)?.check?.timeoutMs ?? 0);
|
|
290
|
+
return costs;
|
|
291
|
+
}
|
|
237
292
|
private async cheapestFirst(ids: readonly string[]): Promise<string[]> {
|
|
238
293
|
const cost = new Map<string, number>();
|
|
239
|
-
for (const id of ids)
|
|
240
|
-
const last = (await this.stores.get(id)!.read()).filter(r => r.attempt.status === "passed" || r.attempt.status === "failed").at(-1)?.payload;
|
|
241
|
-
// A check that waited on another's warm base did not pay that setup either.
|
|
242
|
-
const waited = last?.diagnostic?.setup?.phases.filter(p => p.phase === "warm-base-wait").reduce((sum, p) => sum + p.durationMs, 0) ?? 0;
|
|
243
|
-
cost.set(id, last?.durationMs === undefined ? Infinity : last.durationMs - (last.diagnostic?.dependency?.durationMs ?? 0) - waited);
|
|
244
|
-
}
|
|
294
|
+
for (const id of ids) cost.set(id, await this.lastCost(id) ?? Infinity);
|
|
245
295
|
const [first, ...rest] = ids;
|
|
246
296
|
if (first === undefined) return [];
|
|
247
297
|
const byCost = [first, ...rest.sort((a, b) => cost.get(a)! === cost.get(b)! ? 0 : cost.get(a)! < cost.get(b)! ? -1 : 1)];
|
|
@@ -253,21 +303,68 @@ export class ScopedChecks {
|
|
|
253
303
|
* snapshots, so two checks never share one at once. A worker prints a check's lines together when it
|
|
254
304
|
* finishes; a single worker prints them as they come. After a check throws no new id starts, the running
|
|
255
305
|
* ones finish, and the first failure in `ids` order is thrown.
|
|
306
|
+
*
|
|
307
|
+
* A worker takes the first remaining id whose warm base is not still setting up, so no check's setup waits
|
|
308
|
+
* on a base being built; while every remaining id's base is, the worker waits for one to settle before
|
|
309
|
+
* taking a check. Once `halted`, the remaining ids are taken in order without waiting. With `expected`, a
|
|
310
|
+
* worker takes the startable id expected to run longest instead, and its clone queues by that cost, so
|
|
311
|
+
* the critical path gets its base, clone and worker first.
|
|
256
312
|
*/
|
|
257
|
-
private async pool(ids: readonly string[], run: (id: string, lane: Lane) => Promise<void>): Promise<void> {
|
|
258
|
-
const failures = new Map<string, unknown>();
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
313
|
+
private async pool(ids: readonly string[], run: (id: string, lane: Lane) => Promise<void>, halted: () => boolean = () => false, expected?: ReadonlyMap<string, number>): Promise<void> {
|
|
314
|
+
const failures = new Map<string, unknown>(), remaining = [...ids];
|
|
315
|
+
const sleepers = new Set<() => void>();
|
|
316
|
+
const woken = () => new Promise<void>(resolve => { sleepers.add(resolve); });
|
|
317
|
+
const waker = () => { for (const sleeper of [...sleepers]) sleeper(); sleepers.clear(); };
|
|
318
|
+
this.poolWakers.add(waker);
|
|
319
|
+
const take = async (): Promise<string | undefined> => {
|
|
320
|
+
for (;;) {
|
|
321
|
+
if (failures.size > 0 || remaining.length === 0) return undefined;
|
|
322
|
+
const index = halted() ? 0 : remaining.reduce((best, id, i) => !this.startable(id) ? best
|
|
323
|
+
: best < 0 || (expected?.get(id) ?? 0) > (expected?.get(remaining[best]!) ?? 0) ? i : best, -1);
|
|
324
|
+
if (index >= 0) { const id = remaining.splice(index, 1)[0]!; this.claim(id); return id; }
|
|
325
|
+
await Promise.race([woken(), ...remaining.flatMap(id => { const key = this.keyOf(id), base = this.bases.get(key); return base ? [this.settling.get(base)!] : this.claims.has(key) ? [this.claims.get(key)!.released] : []; })]);
|
|
266
326
|
}
|
|
267
|
-
}
|
|
327
|
+
};
|
|
328
|
+
try {
|
|
329
|
+
await Promise.all(this.workers.map(async snapshots => {
|
|
330
|
+
for (let id = await take(); id !== undefined; id = await take()) {
|
|
331
|
+
const lines: string[] = [];
|
|
332
|
+
try { await run(id, this.concurrency === 1 ? this.serial : { snapshots, write: text => { lines.push(text); }, priority: expected?.get(id) ?? 0 }); }
|
|
333
|
+
catch (error) { failures.set(id, error); this.wakePools(); }
|
|
334
|
+
finally { this.release(this.keyOf(id), id); for (const line of lines) this.context.write(line); }
|
|
335
|
+
}
|
|
336
|
+
}));
|
|
337
|
+
} finally { this.poolWakers.delete(waker); }
|
|
268
338
|
const failed = ids.find(id => failures.has(id));
|
|
269
339
|
if (failed !== undefined) throw failures.get(failed);
|
|
270
340
|
}
|
|
341
|
+
private readonly poolWakers = new Set<() => void>();
|
|
342
|
+
private wakePools(): void { for (const wake of [...this.poolWakers]) wake(); }
|
|
343
|
+
private keyOf(id: string): string {
|
|
344
|
+
return setupKey(this.profileOf(id), this.context.config.providers.find(p => p.id === id)?.check?.physicalSource);
|
|
345
|
+
}
|
|
346
|
+
/** A base for this id's setup key whose setup has settled, or no base and no check about to set one up. */
|
|
347
|
+
private startable(id: string): boolean {
|
|
348
|
+
const key = this.keyOf(id), base = this.bases.get(key);
|
|
349
|
+
return base === undefined ? !this.claims.has(key) : this.settled.has(base);
|
|
350
|
+
}
|
|
351
|
+
/** While bases are shared, a check taken for a key with no base holds the key until its setup has started
|
|
352
|
+
* one (or it finished), so a second check of that key waits outside its attempt rather than on that build. */
|
|
353
|
+
private readonly claims = new Map<string, { readonly owner: string; readonly released: Promise<void>; readonly release: () => void }>();
|
|
354
|
+
private sharing = false;
|
|
355
|
+
private claim(id: string): void {
|
|
356
|
+
const key = this.keyOf(id);
|
|
357
|
+
if (!this.sharing || this.bases.has(key) || this.claims.has(key)) return;
|
|
358
|
+
let release = () => {};
|
|
359
|
+
const released = new Promise<void>(resolve => { release = resolve; });
|
|
360
|
+
this.claims.set(key, { owner: id, released, release });
|
|
361
|
+
}
|
|
362
|
+
/** Ends the key's claim: once a base exists, whoever holds it; when a check finishes, only its own. */
|
|
363
|
+
private release(key: string, owner?: string): void {
|
|
364
|
+
const claim = this.claims.get(key);
|
|
365
|
+
if (!claim || (owner !== undefined && claim.owner !== owner)) return;
|
|
366
|
+
this.claims.delete(key); claim.release();
|
|
367
|
+
}
|
|
271
368
|
/** Stops at the first failure: a failed mechanical check publishes no receipt. */
|
|
272
369
|
async satisfyMechanical(): Promise<void> {
|
|
273
370
|
const ids = await this.cheapestFirst(this.context.config.scopedExecution!.mechanicalProviders);
|
|
@@ -277,8 +374,11 @@ export class ScopedChecks {
|
|
|
277
374
|
const provider = this.context.config.providers.find(p => p.id === id)!;
|
|
278
375
|
await this.execute(provider, this.context.config.obligations.filter(o => o.providers.includes(id)).map(o => o.id), true, lane);
|
|
279
376
|
};
|
|
280
|
-
// The first declared check guards the rest, so it finishes before any other starts.
|
|
377
|
+
// The first declared check guards the rest, so it finishes before any other starts, or sets up.
|
|
378
|
+
const executing = async (some: readonly string[]) => { const out: string[] = []; for (const id of some) if ((await this.selected(id))?.attempt.status !== "passed") out.push(id); return out; };
|
|
379
|
+
await this.prewarm(await executing(ids.slice(0, 1)));
|
|
281
380
|
await this.pool(ids.slice(0, 1), satisfy);
|
|
381
|
+
await this.prewarm(await executing(ids.slice(1)));
|
|
282
382
|
await this.pool(ids.slice(1), satisfy);
|
|
283
383
|
}
|
|
284
384
|
/**
|
|
@@ -305,18 +405,24 @@ export class ScopedChecks {
|
|
|
305
405
|
if (failFast && firstFailure === undefined) {
|
|
306
406
|
firstFailure = id;
|
|
307
407
|
for (const [other, controller] of running) if (other !== id) controller.abort();
|
|
408
|
+
// A worker waiting for a base takes the rest now, which never start.
|
|
409
|
+
this.wakePools();
|
|
308
410
|
}
|
|
309
411
|
} finally { running.delete(id); }
|
|
310
412
|
};
|
|
413
|
+
const halted = () => firstFailure !== undefined;
|
|
414
|
+
const expected = await this.expectedCosts(requests.map(r => r.provider.id));
|
|
311
415
|
const resource = requests.find(r => this.acceptsApplicability(r.provider, r.obligationIds));
|
|
416
|
+
// V1 has one conditional consumer. Keep the first configured check ahead of
|
|
417
|
+
// it, and execute both on the capture worker before releasing the rest to
|
|
418
|
+
// the pool. Other workers never inherit that worker's installed tree.
|
|
419
|
+
const serialIds = resource ? [...new Set([requests[0]!.provider.id, resource.provider.id])] : [];
|
|
420
|
+
// Those two hold every other worker, so their bases set up first, in that order.
|
|
421
|
+
await this.prewarm(requests.map(r => r.provider.id), expected, serialIds);
|
|
312
422
|
if (resource) {
|
|
313
|
-
// V1 has one conditional consumer. Keep the first configured check ahead of
|
|
314
|
-
// it, and execute both on the capture worker before releasing the rest to
|
|
315
|
-
// the pool. Other workers never inherit that worker's installed tree.
|
|
316
|
-
const serialIds = [...new Set([requests[0]!.provider.id, resource.provider.id])];
|
|
317
423
|
for (const id of serialIds) await run(id, this.serial);
|
|
318
|
-
await this.pool(requests.map(r => r.provider.id).filter(id => !serialIds.includes(id)), run);
|
|
319
|
-
} else await this.pool(requests.map(r => r.provider.id), run);
|
|
424
|
+
await this.pool(requests.map(r => r.provider.id).filter(id => !serialIds.includes(id)), run, halted, expected);
|
|
425
|
+
} else await this.pool(requests.map(r => r.provider.id), run, halted, expected);
|
|
320
426
|
if (firstFailure !== undefined) await this.cancelUnstarted(unstarted, firstFailure);
|
|
321
427
|
return requests.flatMap(r => failures.has(r.provider.id) ? [failures.get(r.provider.id)!] : []);
|
|
322
428
|
}
|
|
@@ -333,12 +439,98 @@ export class ScopedChecks {
|
|
|
333
439
|
/** One acquisition path preserves setup sharing, output ownership and worker isolation. A worker's tree
|
|
334
440
|
* for a setup key is built here, cloned from the warm base, or retained from its previous check.
|
|
335
441
|
* `onSetup` hears how the tree is being got before any setup runs, so a failed setup is attributed. */
|
|
442
|
+
/** Every scoped check's profile sharing this setup may write its own outputs in the shared tree;
|
|
443
|
+
* only those profiles passed the overlap refusal in create(). */
|
|
444
|
+
private sharedOutputsFor(key: string): string[] {
|
|
445
|
+
return [...new Set(this.context.config.scopedExecution!.profiles
|
|
446
|
+
.filter(p => this.context.config.providers.some(q => q.check?.scope?.profile === p.id && setupKey(p, q.check.physicalSource) === key)).flatMap(p => p.mutableOutputs))];
|
|
447
|
+
}
|
|
448
|
+
private buildSnapshot(profile: ScopedExecutionProfile, physicalSource: SnapshotRequest["physicalSource"] | undefined, onPhase: NonNullable<SnapshotRequest["onPhase"]>, dependencyResult?: SnapshotRequest["onDependencyResult"], signal = this.context.signal): Promise<CheckSnapshot> {
|
|
449
|
+
return createCheckSnapshot({ rootDir: this.context.rootDir, candidate: this.candidate, outputs: this.sharedOutputsFor(setupKey(profile, physicalSource)), gitContext: profile.gitContext ?? "full", environment: { PATH: this.context.env["PATH"] ?? process.env["PATH"] ?? "/usr/bin:/bin" }, ...(physicalSource ? { physicalSource } : {}), ...(profile.dependencies ? { dependencies: profile.dependencies } : {}), ...(signal ? { signal } : {}),
|
|
450
|
+
onPhase, ...(dependencyResult ? { onDependencyResult: dependencyResult } : {}) });
|
|
451
|
+
}
|
|
452
|
+
/** Shares `base` for its setup key. A failed setup is not shared: it leaves `bases` as it settles, and the
|
|
453
|
+
* next check of this key sets up again, as it would on its own worker. */
|
|
454
|
+
private shareBase(key: string, base: Promise<CheckSnapshot>): void {
|
|
455
|
+
this.bases.set(key, base);
|
|
456
|
+
this.settling.set(base, base.then(() => { this.settled.add(base); }, () => { this.settled.add(base); if (this.bases.get(key) === base) this.bases.delete(key); }));
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Sets up the warm base of each setup key among `ids` that has none yet, so the checks that clone it neither
|
|
460
|
+
* build it nor wait for another check to. Bases set up one at a time: concurrent dependency installs on one
|
|
461
|
+
* machine each take about twice as long, so a key's base is ready soonest on its own. With `expected`, the
|
|
462
|
+
* keys of `leading` go first, in that order, and then the key whose own critical path is longest: its last
|
|
463
|
+
* recorded build plus the longest expected check that needs it. Without it, the order of `ids`. Every key
|
|
464
|
+
* is registered at once, so no worker takes its checks before its base settles. No attempt owns this setup:
|
|
465
|
+
* a check cloning the base records it as reused and carries no dependency result, and a failed one leaves
|
|
466
|
+
* no base, so the check that next needs its key sets up itself and owns that result. Only with cloning:
|
|
467
|
+
* without it each worker builds its own tree, as before.
|
|
468
|
+
*/
|
|
469
|
+
private async prewarm(ids: readonly string[], expected?: ReadonlyMap<string, number>, leading: readonly string[] = []): Promise<void> {
|
|
470
|
+
if (this.concurrency === 1 || !await (this.cloning ??= snapshotCloneMethod())) return;
|
|
471
|
+
this.sharing = true;
|
|
472
|
+
const signal = this.context.signal ? AbortSignal.any([this.context.signal, this.prewarming.signal]) : this.prewarming.signal;
|
|
473
|
+
const keyed = new Map<string, { id: string; check: number; order: number }>();
|
|
474
|
+
for (const [order, id] of ids.entries()) {
|
|
475
|
+
const key = this.keyOf(id), check = expected?.get(id) ?? 0, seen = keyed.get(key);
|
|
476
|
+
if (this.bases.has(key)) continue;
|
|
477
|
+
if (!seen) keyed.set(key, { id, check, order });
|
|
478
|
+
else if (check > seen.check) keyed.set(key, { id, check, order: seen.order });
|
|
479
|
+
}
|
|
480
|
+
const rank = (key: string) => { const index = leading.findIndex(id => this.keyOf(id) === key); return index < 0 ? leading.length : index; };
|
|
481
|
+
const critical = new Map<string, number>();
|
|
482
|
+
for (const [key, { check }] of keyed) critical.set(key, expected ? await this.lastBuild(key) + check : 0);
|
|
483
|
+
const keys = [...keyed].sort(([a, x], [b, y]) => rank(a) - rank(b) || critical.get(b)! - critical.get(a)! || x.order - y.order);
|
|
484
|
+
for (const [key, { id }] of keys) {
|
|
485
|
+
const profile = this.profileOf(id), physicalSource = this.context.config.providers.find(p => p.id === id)?.check?.physicalSource;
|
|
486
|
+
const previous = this.prewarmTail;
|
|
487
|
+
// A build queued when the prewarm stops sees the aborted signal and settles as interrupted.
|
|
488
|
+
const base = (async () => {
|
|
489
|
+
await previous;
|
|
490
|
+
const started = performance.now();
|
|
491
|
+
const snapshot = await this.buildSnapshot(profile, physicalSource, (phase, durationMs) => this.context.write(`prewarm ${profile.id}: ${phase} ${Math.round(durationMs)}ms`), undefined, signal);
|
|
492
|
+
// Recorded in the background: a base never waits on its timing.
|
|
493
|
+
this.timingWrites.push(this.recordBuild(key, performance.now() - started));
|
|
494
|
+
return snapshot;
|
|
495
|
+
})();
|
|
496
|
+
this.prewarmTail = base.then(() => undefined, () => undefined);
|
|
497
|
+
this.prewarmedBases.add(base);
|
|
498
|
+
this.shareBase(key, base);
|
|
499
|
+
base.catch(error => { if (!this.prewarming.signal.aborted) this.context.write(`prewarm ${profile.id}: setup failed (${error instanceof CheckSnapshotError ? error.code : "unclassified"}); its first check sets up itself`); });
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
/** Where the last successful prewarm of each setup key's base is timed, resolved once a session. Ordering
|
|
503
|
+
* only: no admission, gate or record reads it, and a missing or unreadable timing counts as no build time.
|
|
504
|
+
* A check-owned build is not timed: it follows a failed prewarm and may share the machine with the chain. */
|
|
505
|
+
private timings?: Promise<string>;
|
|
506
|
+
private readonly timingWrites: Promise<void>[] = [];
|
|
507
|
+
private async timingPath(key: string): Promise<string> {
|
|
508
|
+
const dir = await (this.timings ??= resolveRecordStorage(this.context.rootDir, { storageNamespace: this.context.config.storageNamespace, leaf: "scoped-setup-timings" }).then(storage => storage.storageDir));
|
|
509
|
+
return path.join(dir, `${digestCanonical({ gate: this.context.config.gateId, setup: key })}.json`);
|
|
510
|
+
}
|
|
511
|
+
private async lastBuild(key: string): Promise<number> {
|
|
512
|
+
try {
|
|
513
|
+
const timing = JSON.parse(await readFile(await this.timingPath(key), "utf8")) as { version?: unknown; buildMs?: unknown };
|
|
514
|
+
return timing.version === "scoped-setup-timing/1" && typeof timing.buildMs === "number" && Number.isFinite(timing.buildMs) && timing.buildMs >= 0 ? timing.buildMs : 0;
|
|
515
|
+
} catch { return 0; }
|
|
516
|
+
}
|
|
517
|
+
private async recordBuild(key: string, buildMs: number): Promise<void> {
|
|
518
|
+
let temporary: string | undefined;
|
|
519
|
+
try {
|
|
520
|
+
const file = await this.timingPath(key);
|
|
521
|
+
temporary = `${file}.${randomUUID()}`;
|
|
522
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
523
|
+
await writeFile(temporary, `${JSON.stringify({ version: "scoped-setup-timing/1", buildMs: Math.round(buildMs) })}\n`);
|
|
524
|
+
await rename(temporary, file);
|
|
525
|
+
} catch {
|
|
526
|
+
// Timing orders prewarms; it cannot fail a setup, and leaves no partial file behind.
|
|
527
|
+
if (temporary) await rm(temporary, { force: true }).catch(() => undefined);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
private prewarmTail: Promise<void> = Promise.resolve();
|
|
336
531
|
private async acquireSnapshot(profile: ScopedExecutionProfile, lane: Lane, onDependencyResult?: SnapshotRequest["onDependencyResult"], physicalSource?: SnapshotRequest["physicalSource"], onSetup: (setup: AcquiredSetup) => void = () => {}): Promise<{ snapshot: CheckSnapshot; sharedOutputs: string[]; setup: AcquiredSetup }> {
|
|
337
532
|
const key = setupKey(profile, physicalSource);
|
|
338
|
-
|
|
339
|
-
// only those profiles passed the overlap refusal in create().
|
|
340
|
-
const sharedOutputs = [...new Set(this.context.config.scopedExecution!.profiles
|
|
341
|
-
.filter(p => this.context.config.providers.some(q => q.check?.scope?.profile === p.id && setupKey(p, q.check.physicalSource) === key)).flatMap(p => p.mutableOutputs))];
|
|
533
|
+
const sharedOutputs = this.sharedOutputsFor(key);
|
|
342
534
|
const method = this.concurrency === 1 ? undefined : await (this.cloning ??= snapshotCloneMethod());
|
|
343
535
|
const acquired = (snapshot: CheckSnapshot, setup: AcquiredSetup) => { lane.snapshots.set(key, snapshot); onSetup(setup); return { snapshot, sharedOutputs, setup }; };
|
|
344
536
|
const retained = lane.snapshots.get(key);
|
|
@@ -354,19 +546,16 @@ export class ScopedChecks {
|
|
|
354
546
|
if (write) write(phase, durationMs);
|
|
355
547
|
else lane.write(`snapshot ${profile.id}: bootstrap ${phase} ${Math.round(durationMs)}ms`);
|
|
356
548
|
};
|
|
357
|
-
const build = (dependencyResult?: SnapshotRequest["onDependencyResult"]) =>
|
|
358
|
-
onPhase, ...(dependencyResult ? { onDependencyResult: dependencyResult } : {}) });
|
|
549
|
+
const build = (dependencyResult?: SnapshotRequest["onDependencyResult"]) => this.buildSnapshot(profile, physicalSource, onPhase, dependencyResult);
|
|
359
550
|
let executed = false, clone: AcquiredSetup["clone"] = this.concurrency === 1 ? "not-used" : "unavailable";
|
|
360
551
|
if (method) {
|
|
361
552
|
let base = this.bases.get(key);
|
|
362
553
|
if (!base) {
|
|
363
554
|
executed = true;
|
|
364
555
|
base = build(onDependencyResult);
|
|
365
|
-
this.
|
|
366
|
-
// A failed setup is not shared: the next check of this key sets up again, as it would on its own worker.
|
|
367
|
-
const failed = base;
|
|
368
|
-
failed.catch(() => { if (this.bases.get(key) === failed) this.bases.delete(key); });
|
|
556
|
+
this.shareBase(key, base);
|
|
369
557
|
}
|
|
558
|
+
this.release(key);
|
|
370
559
|
onSetup({ action: executed ? "executed" : "reused", snapshot: "cloned", clone: method });
|
|
371
560
|
const waiting = performance.now();
|
|
372
561
|
let warm: CheckSnapshot | undefined;
|
|
@@ -382,8 +571,13 @@ export class ScopedChecks {
|
|
|
382
571
|
clone = "path-bound";
|
|
383
572
|
lane.write(`snapshot ${profile.id}: setup wrote its own path into the tree; each worker builds its own`);
|
|
384
573
|
if (executed) return acquired(warm, { action: "executed", snapshot: "built", clone });
|
|
574
|
+
// A prewarmed base no check owns becomes exactly one worker's own tree; every other worker builds its own.
|
|
575
|
+
if (this.prewarmedBases.delete(base)) return acquired(warm, { action: "reused", snapshot: "built", clone });
|
|
385
576
|
} else if (warm) {
|
|
386
|
-
|
|
577
|
+
// Clones run one at a time; the time this one queued counts as its clone.
|
|
578
|
+
const queued = performance.now(), source = warm;
|
|
579
|
+
const cloned = () => this.cloningOne(lane.priority ?? 0, () => { const wait = performance.now() - queued; return source.clone(method, (phase, durationMs) => onPhase(phase, phase === "clone" ? durationMs + wait : durationMs)); });
|
|
580
|
+
try { return acquired(await cloned(), { action: executed ? "executed" : "reused", snapshot: "cloned", clone: method }); }
|
|
387
581
|
catch (error) {
|
|
388
582
|
if (!(error instanceof CheckSnapshotError) || this.context.signal?.aborted) throw error;
|
|
389
583
|
// The base stays for other workers; this one sets up its own tree, as without cloning.
|
|
@@ -691,6 +885,9 @@ export class ScopedChecks {
|
|
|
691
885
|
this.cleanupStarted = true;
|
|
692
886
|
await this.closeCapturePhase();
|
|
693
887
|
await Promise.all(this.executionTails.values());
|
|
888
|
+
// No check needs a base still setting up now; stop rather than wait for it.
|
|
889
|
+
this.prewarming.abort();
|
|
890
|
+
await Promise.all(this.timingWrites);
|
|
694
891
|
for (const snapshots of this.workers) { for (const snapshot of snapshots.values()) await snapshot.cleanup(); snapshots.clear(); }
|
|
695
892
|
const bases = [...this.bases.values()]; this.bases.clear();
|
|
696
893
|
for (const base of bases) await (await base.catch(() => undefined))?.cleanup();
|
|
@@ -21,8 +21,9 @@ export interface ScopedDependencyDiagnostic {
|
|
|
21
21
|
const setupPhases = ["materialization", "baseline-inventory", "dependencies", "post-dependency-inventory", "warm-base-wait", "clone", "pre-command-verification"] as const;
|
|
22
22
|
/** How this attempt got the tree its command ran in, and what that cost up to the command.
|
|
23
23
|
* `executed`: this attempt ran the setup (a tree it built, or the warm base it then cloned);
|
|
24
|
-
* `reused`: the setup had already run (a clone of a warm base another check
|
|
25
|
-
*
|
|
24
|
+
* `reused`: the setup had already run (a clone of a warm base another check or the session's prewarm
|
|
25
|
+
* built, a prewarmed path-bound base taken as this worker's own tree, or the worker's tree retained
|
|
26
|
+
* from its previous check). `clone` names the copy-on-write method, or says
|
|
26
27
|
* why each worker built its own tree: cloning is unavailable here, or the setup wrote its own path
|
|
27
28
|
* into the tree (`path-bound`), or there is one worker (`not-used`). */
|
|
28
29
|
export interface ScopedSetupDiagnostic {
|