@agent-delivery-harness/cli 0.8.0 → 0.8.2
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/check-snapshot.ts +33 -1
- package/src/scoped-checks.ts +215 -36
- 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.2",
|
|
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.2"
|
|
17
17
|
},
|
|
18
18
|
"exports": {
|
|
19
19
|
".": "./src/index.ts"
|
package/src/check-snapshot.ts
CHANGED
|
@@ -159,6 +159,29 @@ function selfReferences(...created: (string | undefined)[]): string[] {
|
|
|
159
159
|
export function executionPath(root: string, value: string): string {
|
|
160
160
|
return value.split(path.delimiter).filter(p => p && path.isAbsolute(p) && !inside(root, p) && !p.split(path.sep).includes("node_modules")).join(path.delimiter) || "/usr/bin:/bin";
|
|
161
161
|
}
|
|
162
|
+
/** The commit depth a materialization fetch needs, or undefined for the source's whole history.
|
|
163
|
+
* A tip lies at most `merge-base..tip` commits plus one above the merge-base on any path, so
|
|
164
|
+
* that depth from both tips keeps every commit a merge-base, ancestry or three-dot read walks.
|
|
165
|
+
* That holds only while the merge-base is the sole commit those ranges stand on: a side branch
|
|
166
|
+
* forked below it would arrive cut off from it and read as a second merge-base. */
|
|
167
|
+
async function materializationDepth(input: SnapshotRequest, git: (...args: string[]) => Promise<string>): Promise<number | undefined> {
|
|
168
|
+
if (input.gitContext === "none") return 1;
|
|
169
|
+
const { headSha, base } = input.candidate;
|
|
170
|
+
try {
|
|
171
|
+
// Reading the author's repository only; the fetch below still transfers object bytes.
|
|
172
|
+
// A tip above the merge-base stands on it alone exactly when it is the range's one boundary
|
|
173
|
+
// commit, which also makes it an ancestor of that tip and so fetched through it.
|
|
174
|
+
for (const tip of [headSha, base.tipSha]) {
|
|
175
|
+
const boundary = (await git("-C", input.rootDir, "rev-list", "--boundary", `${base.mergeBaseSha}..${tip}`))
|
|
176
|
+
.split("\n").filter(line => line.startsWith("-"));
|
|
177
|
+
if (tip !== base.mergeBaseSha && boundary.join("\n") !== `-${base.mergeBaseSha}`) return undefined;
|
|
178
|
+
}
|
|
179
|
+
const counts = await Promise.all([headSha, base.tipSha].map(tip => git("-C", input.rootDir, "rev-list", "--count", `${base.mergeBaseSha}..${tip}`)));
|
|
180
|
+
const longest = Math.max(...counts.map(Number));
|
|
181
|
+
return Number.isSafeInteger(longest) && longest >= 0 ? longest + 1 : undefined;
|
|
182
|
+
} catch { return undefined; }
|
|
183
|
+
}
|
|
184
|
+
|
|
162
185
|
export async function createCheckSnapshot(input: SnapshotRequest): Promise<CheckSnapshot> {
|
|
163
186
|
if (input.physicalSource !== undefined) return createFiniteCheckSnapshot(input);
|
|
164
187
|
const report = (phase: SnapshotPhase, started: number) => {
|
|
@@ -193,7 +216,16 @@ export async function createCheckSnapshot(input: SnapshotRequest): Promise<Check
|
|
|
193
216
|
// cannot create writable links back to the author's Git metadata.
|
|
194
217
|
// Accept the source's shallow boundary in this private repository. Without
|
|
195
218
|
// it, fetch can reject objects or succeed while dropping required grafts.
|
|
196
|
-
|
|
219
|
+
// Fetch only the history a check can read (V26-3284): none past the pinned
|
|
220
|
+
// commits without Git, and with Git the commits from the merge-base to each
|
|
221
|
+
// tip, which merge-base, ancestry and three-dot diffs need. A distance the
|
|
222
|
+
// source cannot measure keeps today's whole-history fetch.
|
|
223
|
+
const depth = await materializationDepth(input, git);
|
|
224
|
+
// Within a bounded full history the merge-base is already fetched from both
|
|
225
|
+
// tips; asked for by itself it would deepen past itself by the same depth.
|
|
226
|
+
const mergeBase = input.gitContext !== "none" && depth !== undefined ? [] : [input.candidate.base.mergeBaseSha];
|
|
227
|
+
await git("fetch", "--update-shallow", ...(depth === undefined ? [] : ["--depth", String(depth)]), "--no-tags", "--no-write-fetch-head", input.rootDir,
|
|
228
|
+
input.candidate.headSha, input.candidate.base.tipSha, ...mergeBase, input.candidate.treeSha);
|
|
197
229
|
await git("update-ref", "HEAD", input.candidate.headSha);
|
|
198
230
|
await git("read-tree", input.candidate.treeSha);
|
|
199
231
|
await git("checkout-index", "--all", "--force");
|
package/src/scoped-checks.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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,
|
|
@@ -60,11 +60,25 @@ function oneAtATime() {
|
|
|
60
60
|
let tail: Promise<unknown> = Promise.resolve();
|
|
61
61
|
return <T>(task: () => Promise<T>): Promise<T> => { const result = tail.then(task); tail = result.catch(() => undefined); return result; };
|
|
62
62
|
}
|
|
63
|
+
/** Runs the tasks it is given one at a time; of those waiting, the highest `priority` goes next, ties in call order. */
|
|
64
|
+
function highestFirst() {
|
|
65
|
+
const waiting: { priority: number; start: () => void }[] = [];
|
|
66
|
+
let busy = false;
|
|
67
|
+
const next = () => {
|
|
68
|
+
if (busy || waiting.length === 0) return;
|
|
69
|
+
const index = waiting.reduce((best, entry, i) => entry.priority > waiting[best]!.priority ? i : best, 0);
|
|
70
|
+
busy = true; waiting.splice(index, 1)[0]!.start();
|
|
71
|
+
};
|
|
72
|
+
return <T>(priority: number, task: () => Promise<T>): Promise<T> => new Promise<T>((resolve, reject) => {
|
|
73
|
+
waiting.push({ priority, start: () => { task().then(resolve, reject).finally(() => { busy = false; next(); }); } });
|
|
74
|
+
next();
|
|
75
|
+
});
|
|
76
|
+
}
|
|
63
77
|
type AcquiredSetup = Omit<ScopedSetupDiagnostic, "verification" | "phases">;
|
|
64
78
|
/** A fail-fast gate's hold on one check: the signal that cancels it, and the check whose failure did. */
|
|
65
79
|
interface Cancellation { readonly signal: AbortSignal; readonly after: () => string }
|
|
66
80
|
/** 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 }
|
|
81
|
+
interface Lane { readonly snapshots: Map<string, CheckSnapshot>; readonly write: (text: string) => void; readonly priority?: number }
|
|
68
82
|
export class ScopedChecks {
|
|
69
83
|
/** `scopedExecution.concurrency` workers, each owning its snapshots; the first is the serial lane. */
|
|
70
84
|
private readonly workers: Map<string, CheckSnapshot>[];
|
|
@@ -82,6 +96,16 @@ export class ScopedChecks {
|
|
|
82
96
|
/** With more than one worker: one read-only warm base per setup key, which no command runs in. Each
|
|
83
97
|
* worker takes a copy-on-write clone of it; without cloning each worker builds its own, as before. */
|
|
84
98
|
private readonly bases = new Map<string, Promise<CheckSnapshot>>();
|
|
99
|
+
/** Each base's settlement, after which its entry is current: a failed setup has left `bases`. */
|
|
100
|
+
private readonly settling = new WeakMap<Promise<CheckSnapshot>, Promise<void>>();
|
|
101
|
+
private readonly settled = new WeakSet<Promise<CheckSnapshot>>();
|
|
102
|
+
/** Stops the bases `prewarm` started, which no attempt owns, once no check can need one still setting up. */
|
|
103
|
+
private readonly prewarming = new AbortController();
|
|
104
|
+
/** Prewarmed bases not yet taken as a worker's own tree, which a path-bound one becomes. */
|
|
105
|
+
private readonly prewarmedBases = new WeakSet<Promise<CheckSnapshot>>();
|
|
106
|
+
/** Concurrent copy-on-write clones of one large tree each take several times as long as one alone, so
|
|
107
|
+
* they run one at a time, the check expected to run longest first. */
|
|
108
|
+
private readonly cloningOne = highestFirst();
|
|
85
109
|
private cloning?: Promise<CloneMethod | undefined>;
|
|
86
110
|
/** Profiles whose checks a tree has run and verified since it was made: their outputs may be in it. */
|
|
87
111
|
private readonly ranOutputs = new WeakMap<CheckSnapshot, Set<string>>();
|
|
@@ -234,14 +258,22 @@ export class ScopedChecks {
|
|
|
234
258
|
* duration less that attempt's own dependency setup; never-timed checks follow timed ones and ties keep
|
|
235
259
|
* declaration order. Checks then run one dependency setup at a time, in the order each setup first
|
|
236
260
|
* appears, so each setup installs once. */
|
|
261
|
+
/** A check's most recent passed or failed attempt's duration less that attempt's own dependency setup, any
|
|
262
|
+
* wait for another's warm base, and its clone with that clone's queue; undefined for a never-timed check. */
|
|
263
|
+
private async lastCost(id: string): Promise<number | undefined> {
|
|
264
|
+
const last = (await this.stores.get(id)!.read()).filter(r => r.attempt.status === "passed" || r.attempt.status === "failed").at(-1)?.payload;
|
|
265
|
+
const waited = last?.diagnostic?.setup?.phases.filter(p => p.phase === "warm-base-wait" || p.phase === "clone").reduce((sum, p) => sum + p.durationMs, 0) ?? 0;
|
|
266
|
+
return last?.durationMs === undefined ? undefined : last.durationMs - (last.diagnostic?.dependency?.durationMs ?? 0) - waited;
|
|
267
|
+
}
|
|
268
|
+
/** How long each check is expected to run: its last cost, or, never timed, its declared `timeoutMs`. */
|
|
269
|
+
private async expectedCosts(ids: readonly string[]): Promise<Map<string, number>> {
|
|
270
|
+
const costs = new Map<string, number>();
|
|
271
|
+
for (const id of ids) costs.set(id, await this.lastCost(id) ?? this.context.config.providers.find(p => p.id === id)?.check?.timeoutMs ?? 0);
|
|
272
|
+
return costs;
|
|
273
|
+
}
|
|
237
274
|
private async cheapestFirst(ids: readonly string[]): Promise<string[]> {
|
|
238
275
|
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
|
-
}
|
|
276
|
+
for (const id of ids) cost.set(id, await this.lastCost(id) ?? Infinity);
|
|
245
277
|
const [first, ...rest] = ids;
|
|
246
278
|
if (first === undefined) return [];
|
|
247
279
|
const byCost = [first, ...rest.sort((a, b) => cost.get(a)! === cost.get(b)! ? 0 : cost.get(a)! < cost.get(b)! ? -1 : 1)];
|
|
@@ -253,21 +285,68 @@ export class ScopedChecks {
|
|
|
253
285
|
* snapshots, so two checks never share one at once. A worker prints a check's lines together when it
|
|
254
286
|
* finishes; a single worker prints them as they come. After a check throws no new id starts, the running
|
|
255
287
|
* ones finish, and the first failure in `ids` order is thrown.
|
|
288
|
+
*
|
|
289
|
+
* A worker takes the first remaining id whose warm base is not still setting up, so no check's setup waits
|
|
290
|
+
* on a base being built; while every remaining id's base is, the worker waits for one to settle before
|
|
291
|
+
* taking a check. Once `halted`, the remaining ids are taken in order without waiting. With `expected`, a
|
|
292
|
+
* worker takes the startable id expected to run longest instead, and its clone queues by that cost, so
|
|
293
|
+
* the critical path gets its base, clone and worker first.
|
|
256
294
|
*/
|
|
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
|
-
|
|
295
|
+
private async pool(ids: readonly string[], run: (id: string, lane: Lane) => Promise<void>, halted: () => boolean = () => false, expected?: ReadonlyMap<string, number>): Promise<void> {
|
|
296
|
+
const failures = new Map<string, unknown>(), remaining = [...ids];
|
|
297
|
+
const sleepers = new Set<() => void>();
|
|
298
|
+
const woken = () => new Promise<void>(resolve => { sleepers.add(resolve); });
|
|
299
|
+
const waker = () => { for (const sleeper of [...sleepers]) sleeper(); sleepers.clear(); };
|
|
300
|
+
this.poolWakers.add(waker);
|
|
301
|
+
const take = async (): Promise<string | undefined> => {
|
|
302
|
+
for (;;) {
|
|
303
|
+
if (failures.size > 0 || remaining.length === 0) return undefined;
|
|
304
|
+
const index = halted() ? 0 : remaining.reduce((best, id, i) => !this.startable(id) ? best
|
|
305
|
+
: best < 0 || (expected?.get(id) ?? 0) > (expected?.get(remaining[best]!) ?? 0) ? i : best, -1);
|
|
306
|
+
if (index >= 0) { const id = remaining.splice(index, 1)[0]!; this.claim(id); return id; }
|
|
307
|
+
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
308
|
}
|
|
267
|
-
}
|
|
309
|
+
};
|
|
310
|
+
try {
|
|
311
|
+
await Promise.all(this.workers.map(async snapshots => {
|
|
312
|
+
for (let id = await take(); id !== undefined; id = await take()) {
|
|
313
|
+
const lines: string[] = [];
|
|
314
|
+
try { await run(id, this.concurrency === 1 ? this.serial : { snapshots, write: text => { lines.push(text); }, priority: expected?.get(id) ?? 0 }); }
|
|
315
|
+
catch (error) { failures.set(id, error); this.wakePools(); }
|
|
316
|
+
finally { this.release(this.keyOf(id), id); for (const line of lines) this.context.write(line); }
|
|
317
|
+
}
|
|
318
|
+
}));
|
|
319
|
+
} finally { this.poolWakers.delete(waker); }
|
|
268
320
|
const failed = ids.find(id => failures.has(id));
|
|
269
321
|
if (failed !== undefined) throw failures.get(failed);
|
|
270
322
|
}
|
|
323
|
+
private readonly poolWakers = new Set<() => void>();
|
|
324
|
+
private wakePools(): void { for (const wake of [...this.poolWakers]) wake(); }
|
|
325
|
+
private keyOf(id: string): string {
|
|
326
|
+
return setupKey(this.profileOf(id), this.context.config.providers.find(p => p.id === id)?.check?.physicalSource);
|
|
327
|
+
}
|
|
328
|
+
/** A base for this id's setup key whose setup has settled, or no base and no check about to set one up. */
|
|
329
|
+
private startable(id: string): boolean {
|
|
330
|
+
const key = this.keyOf(id), base = this.bases.get(key);
|
|
331
|
+
return base === undefined ? !this.claims.has(key) : this.settled.has(base);
|
|
332
|
+
}
|
|
333
|
+
/** While bases are shared, a check taken for a key with no base holds the key until its setup has started
|
|
334
|
+
* one (or it finished), so a second check of that key waits outside its attempt rather than on that build. */
|
|
335
|
+
private readonly claims = new Map<string, { readonly owner: string; readonly released: Promise<void>; readonly release: () => void }>();
|
|
336
|
+
private sharing = false;
|
|
337
|
+
private claim(id: string): void {
|
|
338
|
+
const key = this.keyOf(id);
|
|
339
|
+
if (!this.sharing || this.bases.has(key) || this.claims.has(key)) return;
|
|
340
|
+
let release = () => {};
|
|
341
|
+
const released = new Promise<void>(resolve => { release = resolve; });
|
|
342
|
+
this.claims.set(key, { owner: id, released, release });
|
|
343
|
+
}
|
|
344
|
+
/** Ends the key's claim: once a base exists, whoever holds it; when a check finishes, only its own. */
|
|
345
|
+
private release(key: string, owner?: string): void {
|
|
346
|
+
const claim = this.claims.get(key);
|
|
347
|
+
if (!claim || (owner !== undefined && claim.owner !== owner)) return;
|
|
348
|
+
this.claims.delete(key); claim.release();
|
|
349
|
+
}
|
|
271
350
|
/** Stops at the first failure: a failed mechanical check publishes no receipt. */
|
|
272
351
|
async satisfyMechanical(): Promise<void> {
|
|
273
352
|
const ids = await this.cheapestFirst(this.context.config.scopedExecution!.mechanicalProviders);
|
|
@@ -277,8 +356,11 @@ export class ScopedChecks {
|
|
|
277
356
|
const provider = this.context.config.providers.find(p => p.id === id)!;
|
|
278
357
|
await this.execute(provider, this.context.config.obligations.filter(o => o.providers.includes(id)).map(o => o.id), true, lane);
|
|
279
358
|
};
|
|
280
|
-
// The first declared check guards the rest, so it finishes before any other starts.
|
|
359
|
+
// The first declared check guards the rest, so it finishes before any other starts, or sets up.
|
|
360
|
+
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; };
|
|
361
|
+
await this.prewarm(await executing(ids.slice(0, 1)));
|
|
281
362
|
await this.pool(ids.slice(0, 1), satisfy);
|
|
363
|
+
await this.prewarm(await executing(ids.slice(1)));
|
|
282
364
|
await this.pool(ids.slice(1), satisfy);
|
|
283
365
|
}
|
|
284
366
|
/**
|
|
@@ -305,18 +387,24 @@ export class ScopedChecks {
|
|
|
305
387
|
if (failFast && firstFailure === undefined) {
|
|
306
388
|
firstFailure = id;
|
|
307
389
|
for (const [other, controller] of running) if (other !== id) controller.abort();
|
|
390
|
+
// A worker waiting for a base takes the rest now, which never start.
|
|
391
|
+
this.wakePools();
|
|
308
392
|
}
|
|
309
393
|
} finally { running.delete(id); }
|
|
310
394
|
};
|
|
395
|
+
const halted = () => firstFailure !== undefined;
|
|
396
|
+
const expected = await this.expectedCosts(requests.map(r => r.provider.id));
|
|
311
397
|
const resource = requests.find(r => this.acceptsApplicability(r.provider, r.obligationIds));
|
|
398
|
+
// V1 has one conditional consumer. Keep the first configured check ahead of
|
|
399
|
+
// it, and execute both on the capture worker before releasing the rest to
|
|
400
|
+
// the pool. Other workers never inherit that worker's installed tree.
|
|
401
|
+
const serialIds = resource ? [...new Set([requests[0]!.provider.id, resource.provider.id])] : [];
|
|
402
|
+
// Those two hold every other worker, so their bases set up first, in that order.
|
|
403
|
+
await this.prewarm(requests.map(r => r.provider.id), expected, serialIds);
|
|
312
404
|
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
405
|
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);
|
|
406
|
+
await this.pool(requests.map(r => r.provider.id).filter(id => !serialIds.includes(id)), run, halted, expected);
|
|
407
|
+
} else await this.pool(requests.map(r => r.provider.id), run, halted, expected);
|
|
320
408
|
if (firstFailure !== undefined) await this.cancelUnstarted(unstarted, firstFailure);
|
|
321
409
|
return requests.flatMap(r => failures.has(r.provider.id) ? [failures.get(r.provider.id)!] : []);
|
|
322
410
|
}
|
|
@@ -333,12 +421,98 @@ export class ScopedChecks {
|
|
|
333
421
|
/** One acquisition path preserves setup sharing, output ownership and worker isolation. A worker's tree
|
|
334
422
|
* for a setup key is built here, cloned from the warm base, or retained from its previous check.
|
|
335
423
|
* `onSetup` hears how the tree is being got before any setup runs, so a failed setup is attributed. */
|
|
424
|
+
/** Every scoped check's profile sharing this setup may write its own outputs in the shared tree;
|
|
425
|
+
* only those profiles passed the overlap refusal in create(). */
|
|
426
|
+
private sharedOutputsFor(key: string): string[] {
|
|
427
|
+
return [...new Set(this.context.config.scopedExecution!.profiles
|
|
428
|
+
.filter(p => this.context.config.providers.some(q => q.check?.scope?.profile === p.id && setupKey(p, q.check.physicalSource) === key)).flatMap(p => p.mutableOutputs))];
|
|
429
|
+
}
|
|
430
|
+
private buildSnapshot(profile: ScopedExecutionProfile, physicalSource: SnapshotRequest["physicalSource"] | undefined, onPhase: NonNullable<SnapshotRequest["onPhase"]>, dependencyResult?: SnapshotRequest["onDependencyResult"], signal = this.context.signal): Promise<CheckSnapshot> {
|
|
431
|
+
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 } : {}),
|
|
432
|
+
onPhase, ...(dependencyResult ? { onDependencyResult: dependencyResult } : {}) });
|
|
433
|
+
}
|
|
434
|
+
/** Shares `base` for its setup key. A failed setup is not shared: it leaves `bases` as it settles, and the
|
|
435
|
+
* next check of this key sets up again, as it would on its own worker. */
|
|
436
|
+
private shareBase(key: string, base: Promise<CheckSnapshot>): void {
|
|
437
|
+
this.bases.set(key, base);
|
|
438
|
+
this.settling.set(base, base.then(() => { this.settled.add(base); }, () => { this.settled.add(base); if (this.bases.get(key) === base) this.bases.delete(key); }));
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Sets up the warm base of each setup key among `ids` that has none yet, so the checks that clone it neither
|
|
442
|
+
* build it nor wait for another check to. Bases set up one at a time: concurrent dependency installs on one
|
|
443
|
+
* machine each take about twice as long, so a key's base is ready soonest on its own. With `expected`, the
|
|
444
|
+
* keys of `leading` go first, in that order, and then the key whose own critical path is longest: its last
|
|
445
|
+
* recorded build plus the longest expected check that needs it. Without it, the order of `ids`. Every key
|
|
446
|
+
* is registered at once, so no worker takes its checks before its base settles. No attempt owns this setup:
|
|
447
|
+
* a check cloning the base records it as reused and carries no dependency result, and a failed one leaves
|
|
448
|
+
* no base, so the check that next needs its key sets up itself and owns that result. Only with cloning:
|
|
449
|
+
* without it each worker builds its own tree, as before.
|
|
450
|
+
*/
|
|
451
|
+
private async prewarm(ids: readonly string[], expected?: ReadonlyMap<string, number>, leading: readonly string[] = []): Promise<void> {
|
|
452
|
+
if (this.concurrency === 1 || !await (this.cloning ??= snapshotCloneMethod())) return;
|
|
453
|
+
this.sharing = true;
|
|
454
|
+
const signal = this.context.signal ? AbortSignal.any([this.context.signal, this.prewarming.signal]) : this.prewarming.signal;
|
|
455
|
+
const keyed = new Map<string, { id: string; check: number; order: number }>();
|
|
456
|
+
for (const [order, id] of ids.entries()) {
|
|
457
|
+
const key = this.keyOf(id), check = expected?.get(id) ?? 0, seen = keyed.get(key);
|
|
458
|
+
if (this.bases.has(key)) continue;
|
|
459
|
+
if (!seen) keyed.set(key, { id, check, order });
|
|
460
|
+
else if (check > seen.check) keyed.set(key, { id, check, order: seen.order });
|
|
461
|
+
}
|
|
462
|
+
const rank = (key: string) => { const index = leading.findIndex(id => this.keyOf(id) === key); return index < 0 ? leading.length : index; };
|
|
463
|
+
const critical = new Map<string, number>();
|
|
464
|
+
for (const [key, { check }] of keyed) critical.set(key, expected ? await this.lastBuild(key) + check : 0);
|
|
465
|
+
const keys = [...keyed].sort(([a, x], [b, y]) => rank(a) - rank(b) || critical.get(b)! - critical.get(a)! || x.order - y.order);
|
|
466
|
+
for (const [key, { id }] of keys) {
|
|
467
|
+
const profile = this.profileOf(id), physicalSource = this.context.config.providers.find(p => p.id === id)?.check?.physicalSource;
|
|
468
|
+
const previous = this.prewarmTail;
|
|
469
|
+
// A build queued when the prewarm stops sees the aborted signal and settles as interrupted.
|
|
470
|
+
const base = (async () => {
|
|
471
|
+
await previous;
|
|
472
|
+
const started = performance.now();
|
|
473
|
+
const snapshot = await this.buildSnapshot(profile, physicalSource, (phase, durationMs) => this.context.write(`prewarm ${profile.id}: ${phase} ${Math.round(durationMs)}ms`), undefined, signal);
|
|
474
|
+
// Recorded in the background: a base never waits on its timing.
|
|
475
|
+
this.timingWrites.push(this.recordBuild(key, performance.now() - started));
|
|
476
|
+
return snapshot;
|
|
477
|
+
})();
|
|
478
|
+
this.prewarmTail = base.then(() => undefined, () => undefined);
|
|
479
|
+
this.prewarmedBases.add(base);
|
|
480
|
+
this.shareBase(key, base);
|
|
481
|
+
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`); });
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
/** Where the last successful prewarm of each setup key's base is timed, resolved once a session. Ordering
|
|
485
|
+
* only: no admission, gate or record reads it, and a missing or unreadable timing counts as no build time.
|
|
486
|
+
* A check-owned build is not timed: it follows a failed prewarm and may share the machine with the chain. */
|
|
487
|
+
private timings?: Promise<string>;
|
|
488
|
+
private readonly timingWrites: Promise<void>[] = [];
|
|
489
|
+
private async timingPath(key: string): Promise<string> {
|
|
490
|
+
const dir = await (this.timings ??= resolveRecordStorage(this.context.rootDir, { storageNamespace: this.context.config.storageNamespace, leaf: "scoped-setup-timings" }).then(storage => storage.storageDir));
|
|
491
|
+
return path.join(dir, `${digestCanonical({ gate: this.context.config.gateId, setup: key })}.json`);
|
|
492
|
+
}
|
|
493
|
+
private async lastBuild(key: string): Promise<number> {
|
|
494
|
+
try {
|
|
495
|
+
const timing = JSON.parse(await readFile(await this.timingPath(key), "utf8")) as { version?: unknown; buildMs?: unknown };
|
|
496
|
+
return timing.version === "scoped-setup-timing/1" && typeof timing.buildMs === "number" && Number.isFinite(timing.buildMs) && timing.buildMs >= 0 ? timing.buildMs : 0;
|
|
497
|
+
} catch { return 0; }
|
|
498
|
+
}
|
|
499
|
+
private async recordBuild(key: string, buildMs: number): Promise<void> {
|
|
500
|
+
let temporary: string | undefined;
|
|
501
|
+
try {
|
|
502
|
+
const file = await this.timingPath(key);
|
|
503
|
+
temporary = `${file}.${randomUUID()}`;
|
|
504
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
505
|
+
await writeFile(temporary, `${JSON.stringify({ version: "scoped-setup-timing/1", buildMs: Math.round(buildMs) })}\n`);
|
|
506
|
+
await rename(temporary, file);
|
|
507
|
+
} catch {
|
|
508
|
+
// Timing orders prewarms; it cannot fail a setup, and leaves no partial file behind.
|
|
509
|
+
if (temporary) await rm(temporary, { force: true }).catch(() => undefined);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
private prewarmTail: Promise<void> = Promise.resolve();
|
|
336
513
|
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
514
|
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))];
|
|
515
|
+
const sharedOutputs = this.sharedOutputsFor(key);
|
|
342
516
|
const method = this.concurrency === 1 ? undefined : await (this.cloning ??= snapshotCloneMethod());
|
|
343
517
|
const acquired = (snapshot: CheckSnapshot, setup: AcquiredSetup) => { lane.snapshots.set(key, snapshot); onSetup(setup); return { snapshot, sharedOutputs, setup }; };
|
|
344
518
|
const retained = lane.snapshots.get(key);
|
|
@@ -354,19 +528,16 @@ export class ScopedChecks {
|
|
|
354
528
|
if (write) write(phase, durationMs);
|
|
355
529
|
else lane.write(`snapshot ${profile.id}: bootstrap ${phase} ${Math.round(durationMs)}ms`);
|
|
356
530
|
};
|
|
357
|
-
const build = (dependencyResult?: SnapshotRequest["onDependencyResult"]) =>
|
|
358
|
-
onPhase, ...(dependencyResult ? { onDependencyResult: dependencyResult } : {}) });
|
|
531
|
+
const build = (dependencyResult?: SnapshotRequest["onDependencyResult"]) => this.buildSnapshot(profile, physicalSource, onPhase, dependencyResult);
|
|
359
532
|
let executed = false, clone: AcquiredSetup["clone"] = this.concurrency === 1 ? "not-used" : "unavailable";
|
|
360
533
|
if (method) {
|
|
361
534
|
let base = this.bases.get(key);
|
|
362
535
|
if (!base) {
|
|
363
536
|
executed = true;
|
|
364
537
|
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); });
|
|
538
|
+
this.shareBase(key, base);
|
|
369
539
|
}
|
|
540
|
+
this.release(key);
|
|
370
541
|
onSetup({ action: executed ? "executed" : "reused", snapshot: "cloned", clone: method });
|
|
371
542
|
const waiting = performance.now();
|
|
372
543
|
let warm: CheckSnapshot | undefined;
|
|
@@ -382,8 +553,13 @@ export class ScopedChecks {
|
|
|
382
553
|
clone = "path-bound";
|
|
383
554
|
lane.write(`snapshot ${profile.id}: setup wrote its own path into the tree; each worker builds its own`);
|
|
384
555
|
if (executed) return acquired(warm, { action: "executed", snapshot: "built", clone });
|
|
556
|
+
// A prewarmed base no check owns becomes exactly one worker's own tree; every other worker builds its own.
|
|
557
|
+
if (this.prewarmedBases.delete(base)) return acquired(warm, { action: "reused", snapshot: "built", clone });
|
|
385
558
|
} else if (warm) {
|
|
386
|
-
|
|
559
|
+
// Clones run one at a time; the time this one queued counts as its clone.
|
|
560
|
+
const queued = performance.now(), source = warm;
|
|
561
|
+
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)); });
|
|
562
|
+
try { return acquired(await cloned(), { action: executed ? "executed" : "reused", snapshot: "cloned", clone: method }); }
|
|
387
563
|
catch (error) {
|
|
388
564
|
if (!(error instanceof CheckSnapshotError) || this.context.signal?.aborted) throw error;
|
|
389
565
|
// The base stays for other workers; this one sets up its own tree, as without cloning.
|
|
@@ -691,6 +867,9 @@ export class ScopedChecks {
|
|
|
691
867
|
this.cleanupStarted = true;
|
|
692
868
|
await this.closeCapturePhase();
|
|
693
869
|
await Promise.all(this.executionTails.values());
|
|
870
|
+
// No check needs a base still setting up now; stop rather than wait for it.
|
|
871
|
+
this.prewarming.abort();
|
|
872
|
+
await Promise.all(this.timingWrites);
|
|
694
873
|
for (const snapshots of this.workers) { for (const snapshot of snapshots.values()) await snapshot.cleanup(); snapshots.clear(); }
|
|
695
874
|
const bases = [...this.bases.values()]; this.bases.clear();
|
|
696
875
|
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 {
|