@agent-delivery-harness/cli 0.8.3 → 0.8.6
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 +4 -2
- package/src/commands/gate.ts +46 -3
- package/src/scoped-checks.ts +14 -7
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.6",
|
|
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.6"
|
|
17
17
|
},
|
|
18
18
|
"exports": {
|
|
19
19
|
".": "./src/index.ts"
|
package/src/boundary.ts
CHANGED
|
@@ -135,6 +135,8 @@ export interface CommandContext {
|
|
|
135
135
|
readonly providerId: string;
|
|
136
136
|
readonly payload: Readonly<Record<string, unknown>>;
|
|
137
137
|
readonly requiresEvidence: boolean;
|
|
138
|
+
/** Cancels this one attempt through the rail, as an interruption of the invocation would. */
|
|
139
|
+
readonly signal?: AbortSignal;
|
|
138
140
|
}) => Promise<ProviderRailInvocationResult | undefined>;
|
|
139
141
|
/** Emits one line of operator-facing output to stdout. */
|
|
140
142
|
readonly write: (text: string) => void;
|
|
@@ -530,7 +532,7 @@ async function runConfiguredCommand(
|
|
|
530
532
|
? { promptForWaiver: observation?.prompt(runtime.promptForWaiver) ?? runtime.promptForWaiver }
|
|
531
533
|
: {}),
|
|
532
534
|
...(runtime.liveResults === undefined ? {} : { liveResults: runtime.liveResults }),
|
|
533
|
-
invokeProvider: async ({ providerId, payload, requiresEvidence }) => {
|
|
535
|
+
invokeProvider: async ({ providerId, payload, requiresEvidence, signal: attemptSignal }) => {
|
|
534
536
|
const provider = config.providers.find((registration) => registration.id === providerId);
|
|
535
537
|
if (provider?.command === undefined) return undefined;
|
|
536
538
|
const command = provider.command;
|
|
@@ -583,7 +585,7 @@ async function runConfiguredCommand(
|
|
|
583
585
|
...wiring.storageOptions,
|
|
584
586
|
},
|
|
585
587
|
),
|
|
586
|
-
signal: runtime.signal ?? interruptController?.signal,
|
|
588
|
+
signal: [runtime.signal ?? interruptController?.signal, attemptSignal].filter((s): s is AbortSignal => s !== undefined).reduce<AbortSignal | undefined>((one, other) => one ? AbortSignal.any([one, other]) : other, undefined),
|
|
587
589
|
cancellationId: randomUUID(),
|
|
588
590
|
},
|
|
589
591
|
);
|
package/src/commands/gate.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { refuseOverBudget } from "../scoped-budget.ts";
|
|
|
14
14
|
* only ever offers a waiver to a `human` context, all-or-nothing over waivable
|
|
15
15
|
* findings; the CLI adds no waiver logic of its own.
|
|
16
16
|
*/
|
|
17
|
-
import { computeDeliverableIdentity, runAdmission, type AdmissionResult, type Blocker, type LiveProviderResult } from "@agent-delivery-harness/kernel";
|
|
17
|
+
import { computeDeliverableIdentity, digestCanonical, runAdmission, type AdmissionResult, type Blocker, type LiveProviderResult } from "@agent-delivery-harness/kernel";
|
|
18
18
|
import { CliInterruption, type CommandContext, type CommandDescriptor, type CommandResult } from "../boundary.ts";
|
|
19
19
|
import { focusedOutcome } from "../delivery-posture.ts";
|
|
20
20
|
import { oneLine } from "../run-surface.ts";
|
|
@@ -58,6 +58,8 @@ async function providerAdmission(
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
const liveResults: LiveProviderResult[] = options.includeInjectedLiveResults ? [...(context.liveResults ?? [])] : [];
|
|
61
|
+
const invokeProvider = context.invokeProvider;
|
|
62
|
+
const besideOutcomes = new Map<string, ({ result: Awaited<ReturnType<NonNullable<CommandContext["invokeProvider"]>>> } | { error: unknown }) & { obligationIds: readonly string[] }>();
|
|
61
63
|
const attempted = new Set<string>();
|
|
62
64
|
const attemptBlockers: Blocker[] = [];
|
|
63
65
|
let admission = await admit(input, admissionOptions);
|
|
@@ -90,9 +92,45 @@ async function providerAdmission(
|
|
|
90
92
|
const scoped = session !== undefined && session.concurrency > 1 ? [...requested].filter(([id]) => context.config.providers.find(p => p.id === id)!.check?.scope) : [];
|
|
91
93
|
if (session !== undefined && scoped.length > 0) {
|
|
92
94
|
for (const [id] of scoped) attempted.add(id);
|
|
93
|
-
|
|
95
|
+
// Live rail providers that declare they read no scoped result start alongside the scoped checks. Each outcome
|
|
96
|
+
// waits for the serial loop below to reach that provider, which applies it exactly as if it had run then: same
|
|
97
|
+
// order, same admission between each. A provider an existential obligation shares with another is left to the
|
|
98
|
+
// loop, which may never need it.
|
|
99
|
+
const shared = (id: string) => context.config.obligations.some(o => (o.providerPolicy ?? "all") === "existential" && o.providers.includes(id) && o.providers.length > 1);
|
|
100
|
+
const beside = invokeProvider === undefined ? [] : [...requested].filter(([id, request]) => {
|
|
101
|
+
const registration = context.config.providers.find(p => p.id === id)!;
|
|
102
|
+
return registration.independentOfScopedChecks === true && registration.command !== undefined && request.needsLiveResult && !request.requiresEvidence && !shared(id) && !besideOutcomes.has(id);
|
|
103
|
+
});
|
|
104
|
+
const cancelBeside = new AbortController();
|
|
105
|
+
let cancelledAfter: string | undefined;
|
|
106
|
+
// They start once the pool is saturated (every worker holds a check, or none is left to hand out), not at
|
|
107
|
+
// once: the checks handed out first, the longest expected first, have then been handed out, usually with time
|
|
108
|
+
// to size themselves while the rest wait on their bases.
|
|
109
|
+
const besideRuns: Promise<{ result: Awaited<ReturnType<NonNullable<CommandContext["invokeProvider"]>>> } | { error: unknown }>[] = [];
|
|
110
|
+
const startBeside = () => {
|
|
111
|
+
if (besideRuns.length > 0 || cancelBeside.signal.aborted) return;
|
|
112
|
+
for (const [providerId, request] of beside) besideRuns.push(invokeProvider!({ providerId, requiresEvidence: false, signal: cancelBeside.signal,
|
|
113
|
+
payload: { gateId: context.config.gateId, providerId, obligationIds: [...new Set(request.obligationIds)].sort(), candidate: admission.candidate! } })
|
|
114
|
+
.then(result => ({ result }), (error: unknown) => ({ error })));
|
|
115
|
+
};
|
|
116
|
+
let failures: CheckSnapshotError[];
|
|
117
|
+
try {
|
|
118
|
+
// The first scoped failure under failFast ends them too: nothing else of this admission may finish.
|
|
119
|
+
failures = await session.executeAll(scoped.map(([id, request]) => ({ provider: context.config.providers.find(p => p.id === id)!, obligationIds: request.obligationIds })),
|
|
120
|
+
failed => { cancelledAfter = failed; cancelBeside.abort(); }, startBeside);
|
|
121
|
+
} catch (error) { cancelBeside.abort(); await Promise.all(besideRuns); throw error; }
|
|
122
|
+
// executeAll either throws or has reported saturation: at once when the pool has nothing left, otherwise when its
|
|
123
|
+
// workers fill or its last check is handed out. A failFast cancel leaves them unstarted, as serial would.
|
|
124
|
+
const settled = await Promise.all(besideRuns);
|
|
125
|
+
// An interrupted invocation is an interruption, not a scoped failure's cancellation.
|
|
126
|
+
if (context.signal?.aborted) throw new CliInterruption();
|
|
94
127
|
attemptBlockers.push(...failures.map(scopedCheckBlocker));
|
|
95
128
|
failedFast = failFast && failures.length > 0;
|
|
129
|
+
for (const [index, [providerId]] of beside.entries()) {
|
|
130
|
+
// Stopped, or finished too late to count: never reported as passed, and its obligation stays unmet.
|
|
131
|
+
if (cancelledAfter !== undefined) context.write(`interrupted ${providerId}: check_cancelled after ${cancelledAfter} failed`);
|
|
132
|
+
else besideOutcomes.set(providerId, { ...settled[index]!, obligationIds: [...new Set(beside[index]![1].obligationIds)].sort() });
|
|
133
|
+
}
|
|
96
134
|
admission = await admit({ ...input, ...(liveResults.length === 0 ? {} : { liveResults }) }, admissionOptions);
|
|
97
135
|
continue;
|
|
98
136
|
}
|
|
@@ -118,7 +156,12 @@ async function providerAdmission(
|
|
|
118
156
|
admission = await admit({ ...input, ...(liveResults.length === 0 ? {} : { liveResults }) }, admissionOptions);
|
|
119
157
|
continue;
|
|
120
158
|
}
|
|
121
|
-
|
|
159
|
+
// A provider that already ran beside the scoped checks takes its outcome here, where serial would have run it.
|
|
160
|
+
// Only for the very request it answered: one the loop now makes differently runs again, as serial would run it.
|
|
161
|
+
const stored = besideOutcomes.get(providerId);
|
|
162
|
+
const early = stored && digestCanonical(stored.obligationIds) === digestCanonical([...new Set(request.obligationIds)].sort()) ? stored : undefined;
|
|
163
|
+
if (early && "error" in early) throw early.error;
|
|
164
|
+
const result = early ? early.result : await context.invokeProvider?.({
|
|
122
165
|
providerId,
|
|
123
166
|
requiresEvidence: request.requiresEvidence,
|
|
124
167
|
payload: {
|
package/src/scoped-checks.ts
CHANGED
|
@@ -310,7 +310,11 @@ export class ScopedChecks {
|
|
|
310
310
|
* worker takes the startable id expected to run longest instead, and its clone queues by that cost, so
|
|
311
311
|
* the critical path gets its base, clone and worker first.
|
|
312
312
|
*/
|
|
313
|
-
private async pool(ids: readonly string[], run: (id: string, lane: Lane) => Promise<void>, halted: () => boolean = () => false, expected?: ReadonlyMap<string, number
|
|
313
|
+
private async pool(ids: readonly string[], run: (id: string, lane: Lane) => Promise<void>, halted: () => boolean = () => false, expected?: ReadonlyMap<string, number>, onSaturated?: () => void): Promise<void> {
|
|
314
|
+
// Once every worker holds a check, or none remains to hand out: whichever comes first, and only once.
|
|
315
|
+
let busy = 0, saturated = false;
|
|
316
|
+
const saturate = () => { if (!saturated) { saturated = true; onSaturated?.(); } };
|
|
317
|
+
if (ids.length === 0) saturate();
|
|
314
318
|
const failures = new Map<string, unknown>(), remaining = [...ids];
|
|
315
319
|
const sleepers = new Set<() => void>();
|
|
316
320
|
const woken = () => new Promise<void>(resolve => { sleepers.add(resolve); });
|
|
@@ -321,7 +325,7 @@ export class ScopedChecks {
|
|
|
321
325
|
if (failures.size > 0 || remaining.length === 0) return undefined;
|
|
322
326
|
const index = halted() ? 0 : remaining.reduce((best, id, i) => !this.startable(id) ? best
|
|
323
327
|
: 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; }
|
|
328
|
+
if (index >= 0) { const id = remaining.splice(index, 1)[0]!; this.claim(id); busy += 1; if (remaining.length === 0 || busy >= this.workers.length) saturate(); return id; }
|
|
325
329
|
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] : []; })]);
|
|
326
330
|
}
|
|
327
331
|
};
|
|
@@ -331,7 +335,7 @@ export class ScopedChecks {
|
|
|
331
335
|
const lines: string[] = [];
|
|
332
336
|
try { await run(id, this.concurrency === 1 ? this.serial : { snapshots, write: text => { lines.push(text); }, priority: expected?.get(id) ?? 0 }); }
|
|
333
337
|
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); }
|
|
338
|
+
finally { busy -= 1; this.release(this.keyOf(id), id); for (const line of lines) this.context.write(line); }
|
|
335
339
|
}
|
|
336
340
|
}));
|
|
337
341
|
} finally { this.poolWakers.delete(waker); }
|
|
@@ -385,9 +389,11 @@ export class ScopedChecks {
|
|
|
385
389
|
* Runs the gate's requested checks on the pool. A check's own failure is returned in request order. Without
|
|
386
390
|
* `scopedExecution.failFast` it stops nothing; with it, the first failure cancels every other check: one not
|
|
387
391
|
* yet started never starts and one running has its process group signalled, and both are recorded
|
|
388
|
-
* `interrupted` with `check_cancelled` and left out of the returned failures.
|
|
392
|
+
* `interrupted` with `check_cancelled` and left out of the returned failures. `onFailFast` hears that first
|
|
393
|
+
* failure. `onAllStarted` hears, once, that the pool is saturated: every worker holds a check, or every
|
|
394
|
+
* requested check has been handed out (at once when none remains for the pool). Not at all if the pool throws.
|
|
389
395
|
*/
|
|
390
|
-
async executeAll(requests: readonly { readonly provider: ProviderRegistration; readonly obligationIds: readonly string[] }[]): Promise<CheckSnapshotError[]> {
|
|
396
|
+
async executeAll(requests: readonly { readonly provider: ProviderRegistration; readonly obligationIds: readonly string[] }[], onFailFast?: (failed: string) => void, onAllStarted?: () => void): Promise<CheckSnapshotError[]> {
|
|
391
397
|
const failures = new Map<string, CheckSnapshotError>();
|
|
392
398
|
const failFast = this.context.config.scopedExecution?.failFast === true;
|
|
393
399
|
const running = new Map<string, AbortController>(), unstarted: string[] = [];
|
|
@@ -404,6 +410,7 @@ export class ScopedChecks {
|
|
|
404
410
|
failures.set(id, error);
|
|
405
411
|
if (failFast && firstFailure === undefined) {
|
|
406
412
|
firstFailure = id;
|
|
413
|
+
onFailFast?.(id);
|
|
407
414
|
for (const [other, controller] of running) if (other !== id) controller.abort();
|
|
408
415
|
// A worker waiting for a base takes the rest now, which never start.
|
|
409
416
|
this.wakePools();
|
|
@@ -421,8 +428,8 @@ export class ScopedChecks {
|
|
|
421
428
|
await this.prewarm(requests.map(r => r.provider.id), expected, serialIds);
|
|
422
429
|
if (resource) {
|
|
423
430
|
for (const id of serialIds) await run(id, this.serial);
|
|
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);
|
|
431
|
+
await this.pool(requests.map(r => r.provider.id).filter(id => !serialIds.includes(id)), run, halted, expected, onAllStarted);
|
|
432
|
+
} else await this.pool(requests.map(r => r.provider.id), run, halted, expected, onAllStarted);
|
|
426
433
|
if (firstFailure !== undefined) await this.cancelUnstarted(unstarted, firstFailure);
|
|
427
434
|
return requests.flatMap(r => failures.has(r.provider.id) ? [failures.get(r.provider.id)!] : []);
|
|
428
435
|
}
|