@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-delivery-harness/cli",
3
- "version": "0.8.1",
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.1"
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
- open: () =>
570
- runtime.openProviderRail === undefined
571
- ? openProviderRailProcess({ command, cwd: runtime.cwd, env: { ...runtime.env } })
572
- : runtime.openProviderRail({ providerId, command, cwd: runtime.cwd, env: runtime.env }),
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 },
@@ -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 { chmod, lstat, mkdir, mkdtemp, realpath, rename, rm, writeFile } from "node:fs/promises";
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 removed = await Promise.allSettled(directories.map(dir => rm(dir, { recursive: true, force: true })));
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
@@ -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. The one exception is a malformed declaration: a repository said
10
- * something and it didn't take, so its code and reason go to stderr, leaving
11
- * what a command writes to stdout as it is.
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();
@@ -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
- return { command, sha256: sha256Hex(await readFile(file)), mode: (await stat(file)).mode & 0o111 };
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
- let next = 0;
260
- await Promise.all(this.workers.map(async snapshots => {
261
- while (next < ids.length && failures.size === 0) {
262
- const id = ids[next++]!, lines: string[] = [];
263
- try { await run(id, this.concurrency === 1 ? this.serial : { snapshots, write: text => { lines.push(text); } }); }
264
- catch (error) { failures.set(id, error); }
265
- finally { for (const line of lines) this.context.write(line); }
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
- // Every scoped check's profile sharing this setup may write its own outputs in the shared tree;
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"]) => createCheckSnapshot({ rootDir: this.context.rootDir, candidate: this.candidate, outputs: sharedOutputs, gitContext: profile.gitContext ?? "full", environment: { PATH: this.context.env["PATH"] ?? process.env["PATH"] ?? "/usr/bin:/bin" }, ...(physicalSource ? { physicalSource } : {}), ...(profile.dependencies ? { dependencies: profile.dependencies } : {}), ...(this.context.signal ? { signal: this.context.signal } : {}),
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.bases.set(key, base);
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
- try { return acquired(await warm.clone(method, onPhase), { action: executed ? "executed" : "reused", snapshot: "cloned", clone: method }); }
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 built, or the
25
- * worker's tree retained from its previous check). `clone` names the copy-on-write method, or says
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 {