@agent-delivery-harness/cli 0.8.4 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-delivery-harness/cli",
3
- "version": "0.8.4",
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.4"
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
  );
@@ -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
- const failures = await session.executeAll(scoped.map(([id, request]) => ({ provider: context.config.providers.find(p => p.id === id)!, obligationIds: request.obligationIds })));
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
- const result = await context.invokeProvider?.({
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: {
@@ -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>): Promise<void> {
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
  }