@kylecheng3146/agent-ops 0.3.3 → 0.3.5
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/dist/packages/cli/src/args.js +34 -1
- package/dist/packages/cli/src/bin.js +48 -3
- package/dist/packages/cli/src/cli.js +13 -4
- package/dist/packages/cli/src/commands/parallel.js +3 -1
- package/dist/packages/cli/src/commands/review.js +11 -3
- package/dist/packages/cli/src/commands/verify.js +1 -1
- package/dist/packages/cli/src/hook-process.js +10 -1
- package/dist/runtime/src/contracts.js +1 -1
- package/dist/runtime/src/install/doctor.js +31 -1
- package/dist/runtime/src/parallel/finish.js +32 -10
- package/dist/runtime/src/parallel/manage.js +30 -1
- package/dist/runtime/src/parallel/service.js +111 -5
- package/dist/runtime/src/review/execute.js +125 -9
- package/dist/runtime/src/review/extract.js +40 -0
- package/dist/runtime/src/review/invocation.js +3 -0
- package/dist/runtime/src/review/usage.js +19 -0
- package/dist/runtime/src/schema/validate.js +1 -1
- package/dist/runtime/src/task/completion.js +2 -0
- package/dist/runtime/src/task/service.js +77 -1
- package/dist/runtime/src/verify/command-executor.js +6 -2
- package/dist/runtime/src/verify/evidence.js +2 -1
- package/dist/runtime/src/verify/service.js +5 -2
- package/dist/runtime/src/verify/test-count.js +89 -89
- package/docs/en/guides/configuration.md +26 -4
- package/docs/en/spec/acceptance-and-evidence.md +5 -1
- package/docs/en/spec/delegation.md +1 -1
- package/docs/zh-TW/guides/configuration.md +20 -4
- package/docs/zh-TW/spec/acceptance-and-evidence.md +6 -2
- package/docs/zh-TW/spec/delegation.md +1 -1
- package/package.json +1 -1
- package/schemas/evidence.schema.json +1 -1
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { chmod, copyFile, lstat, mkdir, mkdtemp, realpath, rm, stat } from "node:fs/promises";
|
|
2
|
+
import { chmod, copyFile, lstat, mkdir, mkdtemp, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { runVerificationCommand } from "../verify/spawn.js";
|
|
6
6
|
import { redactSecrets } from "../security/redact.js";
|
|
7
|
-
import { extractReviewObject } from "./extract.js";
|
|
7
|
+
import { answeredNothing, conversationIdOf, deniedToolsOf, extractFinalMessage, extractJsonObject, extractReviewObject } from "./extract.js";
|
|
8
8
|
import { buildTargetInvocation } from "./invocation.js";
|
|
9
9
|
import { reviewReportResults, reviewReportStatus, validateReviewReport } from "./report.js";
|
|
10
10
|
import { detectHostTarget, planReviewTargets } from "./roles.js";
|
|
11
11
|
import { detectHostRestriction } from "./host-sandbox.js";
|
|
12
12
|
import { buildAdversarialPrompt, buildReviewPrompt } from "./runner.js";
|
|
13
13
|
import { MAX_ADVERSARIAL_PROMPT_BYTES } from "./runner.js";
|
|
14
|
-
import { extractUsage } from "./usage.js";
|
|
14
|
+
import { extractUsage, sumUsage } from "./usage.js";
|
|
15
15
|
/**
|
|
16
16
|
* Full repository reviews need far more headroom than the lightweight auth
|
|
17
17
|
* probe. Five minutes was not enough: reviewing a real working tree, codex and
|
|
@@ -298,6 +298,43 @@ async function snapshotRepository(request, destination, options) {
|
|
|
298
298
|
}
|
|
299
299
|
return undefined;
|
|
300
300
|
}
|
|
301
|
+
/**
|
|
302
|
+
* What a lost turn is asked once, in its own conversation. The turn ended
|
|
303
|
+
* because a tool call was refused, and the reviewer already holds everything
|
|
304
|
+
* it inspected, so the cheapest recovery is to ask for the answer.
|
|
305
|
+
*/
|
|
306
|
+
/** A resumed turn only has to state an answer it already holds. */
|
|
307
|
+
const NUDGE_TIMEOUT_MS = 300_000;
|
|
308
|
+
const NUDGE_PROMPT = "Your previous turn ended without an answer because a tool call was refused. " +
|
|
309
|
+
"Do not call any tool. Using only what you have already inspected, reply now " +
|
|
310
|
+
"with the JSON object the schema requires and nothing else.";
|
|
311
|
+
/**
|
|
312
|
+
* Keeps what a lost turn left behind. The attempt directory is removed on the
|
|
313
|
+
* way out and its log is the only record of what the turn spent its tokens on,
|
|
314
|
+
* so without this a lost turn cannot be diagnosed. Best effort: it returns the
|
|
315
|
+
* repository-relative directory, or undefined, and never changes the outcome.
|
|
316
|
+
*/
|
|
317
|
+
async function keepLostTurn(repositoryRoot, sessionId, log, stdout, nudge) {
|
|
318
|
+
// Named in the diagnostic, so it keeps `/` on every platform; `join` below
|
|
319
|
+
// turns it into the native path.
|
|
320
|
+
const relative = `.agent-ops/reviews/lost-turns/${sessionId}`;
|
|
321
|
+
try {
|
|
322
|
+
const directory = join(repositoryRoot, relative);
|
|
323
|
+
await mkdir(directory, { recursive: true });
|
|
324
|
+
await writeFile(join(directory, "stdout.json"), stdout);
|
|
325
|
+
if (log !== undefined) {
|
|
326
|
+
await copyFile(log, join(directory, "agy.log"));
|
|
327
|
+
}
|
|
328
|
+
if (nudge !== undefined) {
|
|
329
|
+
await writeFile(join(directory, "nudge-stdout.json"), nudge.stdout);
|
|
330
|
+
await copyFile(nudge.log, join(directory, "agy-nudge.log")).catch(() => { });
|
|
331
|
+
}
|
|
332
|
+
return relative;
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
301
338
|
/**
|
|
302
339
|
* One target's attempt at one prompt, in a throwaway home directory. Returns a
|
|
303
340
|
* validated report or the reason this target produced no usable verdict; the
|
|
@@ -521,10 +558,88 @@ async function attemptTarget(request, options) {
|
|
|
521
558
|
payload !== undefined
|
|
522
559
|
? ` (fields: ${Object.keys(payload).sort().join(", ")})`
|
|
523
560
|
: "";
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
561
|
+
const lostTurn = payload === undefined && answeredNothing(target, spawned.stdout);
|
|
562
|
+
const denied = lostTurn ? deniedToolsOf(target, spawned.stdout) : [];
|
|
563
|
+
const conversationId = lostTurn ? conversationIdOf(target, spawned.stdout) : undefined;
|
|
564
|
+
let nudged;
|
|
565
|
+
let recovered;
|
|
566
|
+
if (conversationId !== undefined && request.resumeLostTurn !== false &&
|
|
567
|
+
!budgetSpent(options)) {
|
|
568
|
+
const nudgeLog = join(attemptDirectory, "agy-nudge.log");
|
|
569
|
+
const nudge = buildTargetInvocation({
|
|
570
|
+
...invocationRequest,
|
|
571
|
+
prompt: NUDGE_PROMPT,
|
|
572
|
+
conversationId,
|
|
573
|
+
logFile: nudgeLog,
|
|
574
|
+
repositoryRoot: snapshotRoot
|
|
575
|
+
});
|
|
576
|
+
if (nudge !== undefined) {
|
|
577
|
+
options.onProgress?.(`${target}: turn ended without answering${denied.length > 0 ? ` (denied ${denied.join(", ")})` : ""} → resuming the conversation once`);
|
|
578
|
+
const nudgeWatch = watchForStall(nudgeLog, options.signal, stallIdleMs);
|
|
579
|
+
let second;
|
|
580
|
+
try {
|
|
581
|
+
second = await runVerificationCommand({
|
|
582
|
+
id: `review-${request.label}-nudge`,
|
|
583
|
+
command: nudge.command,
|
|
584
|
+
args: [...nudge.args],
|
|
585
|
+
cwd: executionDirectory,
|
|
586
|
+
required: true,
|
|
587
|
+
evidence: { kind: "exit-code" },
|
|
588
|
+
timeoutMs: stageTimeout(options, NUDGE_TIMEOUT_MS)
|
|
589
|
+
}, {
|
|
590
|
+
cwd: executionDirectory,
|
|
591
|
+
...(options.runner === undefined ? {} : { runner: options.runner }),
|
|
592
|
+
...(options.outputLimitBytes === undefined
|
|
593
|
+
? {}
|
|
594
|
+
: { outputLimitBytes: options.outputLimitBytes }),
|
|
595
|
+
stdin: nudge.stdin,
|
|
596
|
+
env: environment,
|
|
597
|
+
replaceEnv: true,
|
|
598
|
+
signal: nudgeWatch.signal,
|
|
599
|
+
onActivity: nudgeWatch.beat
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
finally {
|
|
603
|
+
nudgeWatch.stop();
|
|
604
|
+
}
|
|
605
|
+
usage = sumUsage(usage, extractUsage(target, second.stdout, second.stderr));
|
|
606
|
+
throwIfInterrupted(target, options, second.failureClass);
|
|
607
|
+
nudged = { log: nudgeLog, stdout: second.stdout };
|
|
608
|
+
if (!nudgeWatch.stalled() && second.failureClass === "none" &&
|
|
609
|
+
!second.stdoutTruncated) {
|
|
610
|
+
// A resumed agy turn states its answer in `response` and carries no
|
|
611
|
+
// `structured_output`; validateReviewReport stays the authority.
|
|
612
|
+
const finalMessage = extractFinalMessage(target, second.stdout);
|
|
613
|
+
const answer = extractReviewObject(target, second.stdout) ??
|
|
614
|
+
(finalMessage === undefined ? undefined : extractJsonObject(finalMessage));
|
|
615
|
+
const checked = answer === undefined
|
|
616
|
+
? undefined
|
|
617
|
+
: validateReviewReport(answer, request.expectedCriterionIds, request.changedFiles);
|
|
618
|
+
if (checked?.ok) {
|
|
619
|
+
recovered = checked.value;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
const kept = lostTurn
|
|
625
|
+
? await keepLostTurn(request.repositoryRoot, sessionId, agyLog, spawned.stdout, nudged)
|
|
626
|
+
: undefined;
|
|
627
|
+
if (recovered !== undefined) {
|
|
628
|
+
options.onProgress?.(`${target}: the resumed conversation answered${kept === undefined ? "" : `; lost turn kept at ${kept}`}`);
|
|
629
|
+
return { kind: "verdict", report: recovered, sessionId, metrics: metrics() };
|
|
630
|
+
}
|
|
631
|
+
return {
|
|
632
|
+
...skip(reason, lostTurn
|
|
633
|
+
? "the reviewer ended its turn without answering (empty response, no structured output" +
|
|
634
|
+
(denied.length > 0 ? `; denied ${denied.join(", ")}` : "") +
|
|
635
|
+
")" +
|
|
636
|
+
(kept === undefined ? "" : `; kept ${kept}`)
|
|
637
|
+
: errors.length > 0
|
|
638
|
+
? `${errors}${fields}`
|
|
639
|
+
: firstComplaint(spawned.stdout, spawned.stderr) ??
|
|
640
|
+
"the answer carried no review report"),
|
|
641
|
+
...(lostTurn ? { transient: true } : {})
|
|
642
|
+
};
|
|
528
643
|
}
|
|
529
644
|
return {
|
|
530
645
|
kind: "verdict",
|
|
@@ -539,7 +654,8 @@ async function attemptTarget(request, options) {
|
|
|
539
654
|
}
|
|
540
655
|
/**
|
|
541
656
|
* `attemptTarget`, retried once in a fresh session when the service dropped
|
|
542
|
-
* the call. Only once: a service that
|
|
657
|
+
* the call or agy ended its turn without answering. Only once: a service that
|
|
658
|
+
* is down stays down.
|
|
543
659
|
*/
|
|
544
660
|
async function attemptTargetWithRetry(request, options) {
|
|
545
661
|
const first = await attemptTarget(request, options);
|
|
@@ -547,7 +663,7 @@ async function attemptTargetWithRetry(request, options) {
|
|
|
547
663
|
return first;
|
|
548
664
|
}
|
|
549
665
|
options.onProgress?.(`${request.target}: ${first.diagnostic} → retrying once`);
|
|
550
|
-
return attemptTarget(request, options);
|
|
666
|
+
return attemptTarget({ ...request, resumeLostTurn: false }, options);
|
|
551
667
|
}
|
|
552
668
|
/** Builds the `execute` callback with one necessary and one adversarial review. */
|
|
553
669
|
export function createReviewExecutor(options) {
|
|
@@ -50,6 +50,46 @@ export function extractFinalMessage(target, stdout) {
|
|
|
50
50
|
export function extractJsonObject(text) {
|
|
51
51
|
return parseObject(text.trim());
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* agy's plan mode can end its one `--print` turn without answering: a valid
|
|
55
|
+
* envelope with no `structured_output` and an empty `response`. That is a lost
|
|
56
|
+
* turn, not a verdict, so a fresh session is worth one retry. Anything the
|
|
57
|
+
* target did say, or any other target, is a real protocol failure instead.
|
|
58
|
+
*/
|
|
59
|
+
export function answeredNothing(target, stdout) {
|
|
60
|
+
if (target !== "agy") {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
const envelope = parseObject(stdout);
|
|
64
|
+
if (envelope === undefined || envelope.structured_output !== undefined) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
const response = envelope.response;
|
|
68
|
+
return response === undefined ||
|
|
69
|
+
(typeof response === "string" && response.trim().length === 0);
|
|
70
|
+
}
|
|
71
|
+
/** The conversation a lost agy turn can be resumed in; agy alone reports one. */
|
|
72
|
+
export function conversationIdOf(target, stdout) {
|
|
73
|
+
const id = target === "agy" ? parseObject(stdout)?.conversation_id : undefined;
|
|
74
|
+
return typeof id === "string" && id.length > 0 ? id : undefined;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The tools agy's print mode refused because nobody could confirm them. A turn
|
|
78
|
+
* that calls one ends there, which is what a lost turn is.
|
|
79
|
+
*/
|
|
80
|
+
export function deniedToolsOf(target, stdout) {
|
|
81
|
+
const denied = target === "agy" ? parseObject(stdout)?.denied_actions : undefined;
|
|
82
|
+
if (!Array.isArray(denied)) {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
return denied.flatMap((action) => {
|
|
86
|
+
if (!isRecord(action)) {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
const name = action.display_name ?? action.action;
|
|
90
|
+
return typeof name === "string" && name.length > 0 ? [name] : [];
|
|
91
|
+
});
|
|
92
|
+
}
|
|
53
93
|
/**
|
|
54
94
|
* Review results use a strict native structured-output transport. Unlike the
|
|
55
95
|
* legacy probe parser above, this path neither recovers fenced JSON nor accepts
|
|
@@ -149,6 +149,9 @@ export function buildTargetInvocation(request) {
|
|
|
149
149
|
? []
|
|
150
150
|
: ["--add-dir", request.repositoryRoot]),
|
|
151
151
|
...logArgs(request.target, request.logFile),
|
|
152
|
+
...(request.target === "agy" && request.conversationId !== undefined
|
|
153
|
+
? ["--conversation", request.conversationId]
|
|
154
|
+
: []),
|
|
152
155
|
...isolationArgs(request.target),
|
|
153
156
|
...shared
|
|
154
157
|
],
|
|
@@ -69,3 +69,22 @@ export function extractUsage(target, stdout, stderr) {
|
|
|
69
69
|
...defined("costUsd", count(envelope?.total_cost_usd))
|
|
70
70
|
});
|
|
71
71
|
}
|
|
72
|
+
/** Two rounds of one attempt add up; a field only one round reported stays. */
|
|
73
|
+
export function sumUsage(first, second) {
|
|
74
|
+
if (first === undefined || second === undefined) {
|
|
75
|
+
return first ?? second;
|
|
76
|
+
}
|
|
77
|
+
const add = (key) => {
|
|
78
|
+
const a = first[key];
|
|
79
|
+
const b = second[key];
|
|
80
|
+
return a === undefined && b === undefined ? {} : { [key]: (a ?? 0) + (b ?? 0) };
|
|
81
|
+
};
|
|
82
|
+
return {
|
|
83
|
+
...add("inputTokens"),
|
|
84
|
+
...add("outputTokens"),
|
|
85
|
+
...add("cacheReadTokens"),
|
|
86
|
+
...add("cacheWriteTokens"),
|
|
87
|
+
...add("totalTokens"),
|
|
88
|
+
...add("costUsd")
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -593,7 +593,7 @@ export function validateEvidence(value) {
|
|
|
593
593
|
"taskId",
|
|
594
594
|
"testCount",
|
|
595
595
|
"toolVersions"
|
|
596
|
-
], EVIDENCE_SCHEMA_VERSION);
|
|
596
|
+
], isRecord(value) && value.schemaVersion === 2 ? 2 : EVIDENCE_SCHEMA_VERSION);
|
|
597
597
|
if (isFailure(root)) {
|
|
598
598
|
return root;
|
|
599
599
|
}
|
|
@@ -25,6 +25,8 @@ export async function checkTaskCompletionEvidence(stored, options) {
|
|
|
25
25
|
if (!validation.ok)
|
|
26
26
|
continue;
|
|
27
27
|
const evidence = validation.value;
|
|
28
|
+
if (command.evidence.kind === "test-count" && evidence.schemaVersion === 2)
|
|
29
|
+
continue;
|
|
28
30
|
if (evidence.taskId === stored.task.id && evidence.criterionId === criterion.id &&
|
|
29
31
|
evidence.commandId === command.id && evidence.configHash === configHash &&
|
|
30
32
|
evidence.sourceFingerprint === sourceFingerprint &&
|
|
@@ -4,7 +4,7 @@ import { AgentOpsError } from "../fs/paths.js";
|
|
|
4
4
|
import { validateEvidence, validateTask } from "../schema/validate.js";
|
|
5
5
|
import { renderTaskMarkdown } from "./render.js";
|
|
6
6
|
import { checkTaskCompletionEvidence, findIncompleteSubtask } from "./completion.js";
|
|
7
|
-
import { calculateConfigHash, FileEvidenceStore } from "../verify/evidence.js";
|
|
7
|
+
import { calculateConfigHash, FileEvidenceStore, isPassingVerificationEvidence } from "../verify/evidence.js";
|
|
8
8
|
import { resolveReviewScope } from "../review/scope.js";
|
|
9
9
|
import { calculateSourceFingerprint } from "../verify/source-fingerprint.js";
|
|
10
10
|
import { advanceFailureFingerprint } from "../verify/fingerprint.js";
|
|
@@ -353,6 +353,82 @@ export class TaskService {
|
|
|
353
353
|
return cloneRecord(updated);
|
|
354
354
|
});
|
|
355
355
|
}
|
|
356
|
+
/** Append a fresh verification to a completed task without reopening it. */
|
|
357
|
+
async recordVerificationEvidence(snapshot, evidenceInput, sourceFingerprint) {
|
|
358
|
+
if (snapshot.status === "active") {
|
|
359
|
+
const result = await this.recordEvidence(snapshot.task.id, evidenceInput);
|
|
360
|
+
await this.clearFailure(snapshot.task.id);
|
|
361
|
+
return result;
|
|
362
|
+
}
|
|
363
|
+
const completion = this.#completion;
|
|
364
|
+
if (snapshot.status !== "complete" || completion === undefined) {
|
|
365
|
+
throw taskError("TASK_NOT_ACTIVE", "Completed reverification requires repository context; archived tasks are immutable.");
|
|
366
|
+
}
|
|
367
|
+
const config = await completion.loadConfig();
|
|
368
|
+
const configHash = calculateConfigHash(config);
|
|
369
|
+
if (snapshot.policyConfigHash !== configHash) {
|
|
370
|
+
throw taskError("TASK_REVERIFICATION_CHANGED", "Completed task config changed; create a new task for the new scope.");
|
|
371
|
+
}
|
|
372
|
+
const store = new FileEvidenceStore(completion.root, completion.root);
|
|
373
|
+
const matches = (value, criterionId, commandId) => {
|
|
374
|
+
const validation = validateEvidence(value);
|
|
375
|
+
return validation.ok && validation.value.taskId === snapshot.task.id &&
|
|
376
|
+
validation.value.criterionId === criterionId && validation.value.commandId === commandId &&
|
|
377
|
+
validation.value.configHash === configHash && validation.value.sourceFingerprint === sourceFingerprint
|
|
378
|
+
? validation.value : null;
|
|
379
|
+
};
|
|
380
|
+
for (const criterion of snapshot.task.criteria) {
|
|
381
|
+
const commands = config.verification.commands.filter(({ id, required }) => required && criterion.verifierIds.includes(id));
|
|
382
|
+
if (commands.length === 0)
|
|
383
|
+
throw taskError("TASK_REVERIFICATION_CHANGED", "Completed task criteria lack required coverage.");
|
|
384
|
+
for (const command of commands) {
|
|
385
|
+
let original = false;
|
|
386
|
+
let fresh = false;
|
|
387
|
+
for (const reference of snapshot.evidence[criterion.id] ?? []) {
|
|
388
|
+
if (reference.startsWith("review:"))
|
|
389
|
+
continue;
|
|
390
|
+
const evidence = matches(await store.load(reference), criterion.id, command.id);
|
|
391
|
+
// Legacy counts identify the original scope only; they do not prove fresh PASS.
|
|
392
|
+
if (evidence !== null && isPassingVerificationEvidence({ ...command, evidence: { kind: "exit-code" } }, evidence))
|
|
393
|
+
original = true;
|
|
394
|
+
}
|
|
395
|
+
for (const reference of evidenceInput[criterion.id] ?? []) {
|
|
396
|
+
const evidence = matches(await store.load(reference), criterion.id, command.id);
|
|
397
|
+
if (evidence !== null && evidence.schemaVersion === 3 &&
|
|
398
|
+
!snapshot.evidence[criterion.id]?.includes(reference) && isPassingVerificationEvidence(command, evidence))
|
|
399
|
+
fresh = true;
|
|
400
|
+
}
|
|
401
|
+
if (!original || !fresh)
|
|
402
|
+
throw taskError("TASK_REVERIFICATION_CHANGED", "Completed task source or evidence changed; fresh PASS must cover the original scope and every required verifier.");
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
const appended = normalizeEvidence(snapshot.task, evidenceInput);
|
|
406
|
+
for (const [criterionId, references] of Object.entries(appended)) {
|
|
407
|
+
for (const reference of references) {
|
|
408
|
+
const validation = validateEvidence(await store.load(reference));
|
|
409
|
+
if (!validation.ok || !snapshot.task.criteria.find(({ id }) => id === criterionId)?.verifierIds.includes(validation.value.commandId) || matches(validation.value, criterionId, validation.value.commandId) === null) {
|
|
410
|
+
throw taskError("TASK_EVIDENCE_INVALID", "Fresh verification references must belong to this task, criterion, source and config.");
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
const scope = await resolveReviewScope({ root: completion.root, runner: completion.gitRunner,
|
|
415
|
+
...(completion.base === undefined ? {} : { base: completion.base }) });
|
|
416
|
+
if (sourceFingerprint !== await calculateSourceFingerprint(completion.root, scope, completion.gitRunner) ||
|
|
417
|
+
configHash !== calculateConfigHash(await completion.loadConfig())) {
|
|
418
|
+
throw taskError("TASK_REVERIFICATION_CHANGED", "Source or config changed during reverification.");
|
|
419
|
+
}
|
|
420
|
+
const now = assertTimestamp(this.#now());
|
|
421
|
+
return await this.#store.mutate((state) => {
|
|
422
|
+
if (JSON.stringify(findTask(state, snapshot.task.id)) !== JSON.stringify(snapshot)) {
|
|
423
|
+
throw taskError("TASK_REVERIFICATION_STATE_CHANGED", "Task changed during reverification; retry without discarding the concurrent state.");
|
|
424
|
+
}
|
|
425
|
+
const updated = { ...snapshot, updatedAt: now, failureFingerprint: null,
|
|
426
|
+
evidence: Object.fromEntries(snapshot.task.criteria.map(({ id }) => [id,
|
|
427
|
+
[...new Set([...(snapshot.evidence[id] ?? []), ...appended[id]])]])) };
|
|
428
|
+
replaceTask(state, updated);
|
|
429
|
+
return cloneRecord(updated);
|
|
430
|
+
});
|
|
431
|
+
}
|
|
356
432
|
async archive(taskId) {
|
|
357
433
|
const now = assertTimestamp(this.#now());
|
|
358
434
|
return await this.#store.mutate((state) => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { runVerificationCommand } from "./spawn.js";
|
|
2
|
-
import { evaluateTestCount,
|
|
2
|
+
import { evaluateTestCount, parseTestSummary } from "./test-count.js";
|
|
3
3
|
function classifyTestCountCode(code) {
|
|
4
4
|
const classes = {
|
|
5
5
|
TEST_COUNT_BELOW_MINIMUM: "test-count-below-minimum",
|
|
@@ -43,7 +43,8 @@ function classifyCommand(command, spawned) {
|
|
|
43
43
|
testCount: null
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
|
-
const
|
|
46
|
+
const summary = parseTestSummary(`${spawned.stdout}\n${spawned.stderr}`);
|
|
47
|
+
const testCount = summary.passed;
|
|
47
48
|
if (spawned.status !== "PASS") {
|
|
48
49
|
return {
|
|
49
50
|
status: spawned.status,
|
|
@@ -51,6 +52,9 @@ function classifyCommand(command, spawned) {
|
|
|
51
52
|
testCount
|
|
52
53
|
};
|
|
53
54
|
}
|
|
55
|
+
if (summary.failed > 0) {
|
|
56
|
+
return { status: "FAIL", failureClass: "test-failures", testCount };
|
|
57
|
+
}
|
|
54
58
|
const evaluation = evaluateTestCount(testCount, command.evidence.minimum);
|
|
55
59
|
return {
|
|
56
60
|
status: evaluation.status,
|
|
@@ -54,7 +54,8 @@ export function isPassingVerificationEvidence(command, evidence) {
|
|
|
54
54
|
return false;
|
|
55
55
|
}
|
|
56
56
|
return command.evidence.kind !== "test-count" ||
|
|
57
|
-
|
|
57
|
+
(evidence.schemaVersion === EVIDENCE_SCHEMA_VERSION &&
|
|
58
|
+
evaluateTestCount(evidence.testCount, command.evidence.minimum).status === "PASS");
|
|
58
59
|
}
|
|
59
60
|
export class FileEvidenceStore {
|
|
60
61
|
#root;
|
|
@@ -113,6 +113,10 @@ export class VerificationService {
|
|
|
113
113
|
throw verificationError("VERIFICATION_INPUT_INVALID", validation.errors[0]?.message ??
|
|
114
114
|
"Task and verification configuration are incompatible.");
|
|
115
115
|
}
|
|
116
|
+
const missingCriteria = validation.value.criteria.filter((criterion) => !this.#options.config.verification.commands.some(({ id, required }) => required && criterion.verifierIds.includes(id)));
|
|
117
|
+
if (missingCriteria.length > 0) {
|
|
118
|
+
throw verificationError("VERIFICATION_UNKNOWN", `Criteria without required coverage: ${missingCriteria.map(({ id }) => id).join(", ")}. Configure at least one required verifier for each criterion and recreate the task; no verifier commands were run.`);
|
|
119
|
+
}
|
|
116
120
|
const reviewScope = await resolveReviewScope({
|
|
117
121
|
root: this.#options.root,
|
|
118
122
|
runner: this.#options.gitRunner,
|
|
@@ -167,9 +171,8 @@ export class VerificationService {
|
|
|
167
171
|
}
|
|
168
172
|
}
|
|
169
173
|
if (Object.keys(taskEvidence).length > 0) {
|
|
170
|
-
await this.#options.taskService.
|
|
174
|
+
await this.#options.taskService.recordVerificationEvidence(stored, taskEvidence, sourceFingerprint);
|
|
171
175
|
}
|
|
172
|
-
await this.#options.taskService.clearFailure(taskId);
|
|
173
176
|
}
|
|
174
177
|
else {
|
|
175
178
|
const required = results.filter((result) => result.required);
|
|
@@ -1,108 +1,108 @@
|
|
|
1
1
|
const MAX_SUMMARY_BYTES = 1024 * 1024;
|
|
2
|
-
const NUMBER_SOURCE = String.raw `\d
|
|
2
|
+
const NUMBER_SOURCE = String.raw `\d+`;
|
|
3
3
|
function safeCount(value) {
|
|
4
4
|
const parsed = Number(value);
|
|
5
5
|
return Number.isSafeInteger(parsed) && parsed >= 0
|
|
6
6
|
? parsed
|
|
7
7
|
: null;
|
|
8
8
|
}
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
if (
|
|
12
|
-
|
|
9
|
+
/** Counts outcomes, never discovery totals. Conflicting summaries fail closed. */
|
|
10
|
+
export function parseTestSummary(output) {
|
|
11
|
+
if (output.includes("\0") || Buffer.byteLength(output, "utf8") > MAX_SUMMARY_BYTES) {
|
|
12
|
+
return { passed: null, failed: 0 };
|
|
13
13
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
14
|
+
const summaries = [];
|
|
15
|
+
let nodePassed = null;
|
|
16
|
+
let nodeFailed = 0;
|
|
17
|
+
let nodeSeen = false;
|
|
18
|
+
let invalid = false;
|
|
19
|
+
const nodeCounts = new Map();
|
|
20
|
+
for (const raw of output.replace(/\u001b\[[0-9;]*m/gu, "").split(/\r?\n/u)) {
|
|
21
|
+
const line = raw.trim();
|
|
22
|
+
const node = new RegExp(`^(?:#|ℹ) (tests|pass|fail|cancelled|skipped|todo) (${NUMBER_SOURCE})$`, "u").exec(line);
|
|
23
|
+
if (node !== null) {
|
|
24
|
+
nodeSeen = true;
|
|
25
|
+
const count = safeCount(node[2]);
|
|
26
|
+
if (count === null || (nodeCounts.has(node[1]) && nodeCounts.get(node[1]) !== count))
|
|
27
|
+
invalid = true;
|
|
28
|
+
else
|
|
29
|
+
nodeCounts.set(node[1], count);
|
|
30
|
+
continue;
|
|
27
31
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
total > Number.MAX_SAFE_INTEGER - count)) {
|
|
32
|
-
return null;
|
|
32
|
+
if (/^(?:#|ℹ) (?:tests|pass|fail|cancelled|skipped|todo)\b/u.test(line)) {
|
|
33
|
+
invalid = true;
|
|
34
|
+
continue;
|
|
33
35
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
const rust = new RegExp(`^test result: (ok|FAILED)\\. (${NUMBER_SOURCE}) passed; (${NUMBER_SOURCE}) failed;`, "u").exec(line);
|
|
37
|
+
if (rust !== null) {
|
|
38
|
+
const passed = safeCount(rust[2]);
|
|
39
|
+
const failed = safeCount(rust[3]);
|
|
40
|
+
if (passed === null || failed === null)
|
|
41
|
+
invalid = true;
|
|
42
|
+
summaries.push({ passed, failed: Math.max(failed ?? 0, rust[1] === "FAILED" ? 1 : 0) });
|
|
43
|
+
continue;
|
|
36
44
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
+
const isJest = /^Tests:/u.test(line);
|
|
46
|
+
const isVitest = /^Tests\s+/u.test(line) && /\(\d+\)\s*$/u.test(line);
|
|
47
|
+
const isPytest = /\bin \d+(?:\.\d+)?s(?:\s|=|$)/u.test(line);
|
|
48
|
+
if (isJest || isVitest || isPytest) {
|
|
49
|
+
let passed = 0;
|
|
50
|
+
let failed = 0;
|
|
51
|
+
let matched = false;
|
|
52
|
+
let total = 0;
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
for (const match of line.matchAll(new RegExp(`(${NUMBER_SOURCE}) (passed|failed|skipped|todo|error|errors|cancelled|xfailed|xpassed|deselected)\\b`, "gu"))) {
|
|
55
|
+
matched = true;
|
|
56
|
+
const count = safeCount(match[1]);
|
|
57
|
+
const status = match[2];
|
|
58
|
+
if (count === null || seen.has(status)) {
|
|
59
|
+
invalid = true;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
seen.add(status);
|
|
63
|
+
if (status !== "deselected")
|
|
64
|
+
total += count;
|
|
65
|
+
if (!Number.isSafeInteger(total))
|
|
66
|
+
invalid = true;
|
|
67
|
+
if (status === "passed")
|
|
68
|
+
passed = count;
|
|
69
|
+
if (["failed", "error", "errors", "cancelled"].includes(status)) {
|
|
70
|
+
failed += count;
|
|
71
|
+
if (!Number.isSafeInteger(failed))
|
|
72
|
+
invalid = true;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const totalMatch = isJest ? /\b(\d+) total(?:\s|$)/u.exec(line) : isVitest ? /\((\d+)\)\s*$/u.exec(line) : null;
|
|
76
|
+
if (matched && totalMatch !== null && safeCount(totalMatch[1]) !== total)
|
|
77
|
+
invalid = true;
|
|
78
|
+
// Missing passed in a recognized outcome summary means zero passes;
|
|
79
|
+
// a discovery/total-only line gives no outcome evidence.
|
|
80
|
+
summaries.push({ passed: matched ? passed : null, failed });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (/^collected 0 items?$/u.test(line))
|
|
84
|
+
summaries.push({ passed: 0, failed: 0 });
|
|
45
85
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
86
|
+
if (nodeSeen) {
|
|
87
|
+
nodePassed = nodeCounts.get("pass") ?? (nodeCounts.get("tests") === 0 ? 0 : null);
|
|
88
|
+
nodeFailed = (nodeCounts.get("fail") ?? 0) + (nodeCounts.get("cancelled") ?? 0);
|
|
89
|
+
if (!Number.isSafeInteger(nodeFailed))
|
|
90
|
+
invalid = true;
|
|
91
|
+
const total = nodeCounts.get("tests");
|
|
92
|
+
const outcomes = (nodePassed ?? 0) + nodeFailed + (nodeCounts.get("skipped") ?? 0) + (nodeCounts.get("todo") ?? 0);
|
|
93
|
+
if (!Number.isSafeInteger(outcomes) || (total !== undefined && outcomes > total))
|
|
94
|
+
invalid = true;
|
|
95
|
+
summaries.push({ passed: nodePassed, failed: nodeFailed });
|
|
50
96
|
}
|
|
51
|
-
const
|
|
52
|
-
const failed =
|
|
53
|
-
if (
|
|
54
|
-
|
|
55
|
-
passed > Number.MAX_SAFE_INTEGER - failed) {
|
|
56
|
-
return null;
|
|
97
|
+
const first = summaries[0];
|
|
98
|
+
const failed = summaries.some((summary) => summary.failed > 0) ? 1 : 0;
|
|
99
|
+
if (invalid || first === undefined || summaries.some((summary) => summary.passed !== first.passed || summary.failed !== first.failed)) {
|
|
100
|
+
return { passed: null, failed };
|
|
57
101
|
}
|
|
58
|
-
return passed
|
|
102
|
+
return { passed: first.passed, failed: first.failed };
|
|
59
103
|
}
|
|
60
104
|
export function parseTestCount(output) {
|
|
61
|
-
|
|
62
|
-
Buffer.byteLength(output, "utf8") > MAX_SUMMARY_BYTES) {
|
|
63
|
-
return null;
|
|
64
|
-
}
|
|
65
|
-
const candidates = [];
|
|
66
|
-
const nodePattern = new RegExp(`^# tests (${NUMBER_SOURCE})$`, "u");
|
|
67
|
-
const collectedPattern = new RegExp(`^collected (${NUMBER_SOURCE}) items?$`, "u");
|
|
68
|
-
const jestPattern = new RegExp(`^Tests:\\s+.*\\b(${NUMBER_SOURCE}) total(?:\\s|$)`, "u");
|
|
69
|
-
const vitestPattern = new RegExp(`^Tests\\s+.*\\((${NUMBER_SOURCE})\\)\\s*$`, "u");
|
|
70
|
-
for (const rawLine of output.split(/\r?\n/u)) {
|
|
71
|
-
const line = rawLine.trim();
|
|
72
|
-
if (line.length === 0) {
|
|
73
|
-
continue;
|
|
74
|
-
}
|
|
75
|
-
const node = nodePattern.exec(line);
|
|
76
|
-
if (node?.[1] !== undefined) {
|
|
77
|
-
addCandidate(candidates, node[1]);
|
|
78
|
-
}
|
|
79
|
-
const collected = collectedPattern.exec(line);
|
|
80
|
-
if (collected?.[1] !== undefined) {
|
|
81
|
-
addCandidate(candidates, collected[1]);
|
|
82
|
-
}
|
|
83
|
-
const jest = jestPattern.exec(line);
|
|
84
|
-
if (jest?.[1] !== undefined) {
|
|
85
|
-
addCandidate(candidates, jest[1]);
|
|
86
|
-
}
|
|
87
|
-
const vitest = vitestPattern.exec(line);
|
|
88
|
-
if (vitest?.[1] !== undefined) {
|
|
89
|
-
addCandidate(candidates, vitest[1]);
|
|
90
|
-
}
|
|
91
|
-
const pytest = parsePytestSummary(line);
|
|
92
|
-
if (pytest !== null) {
|
|
93
|
-
candidates.push(pytest);
|
|
94
|
-
}
|
|
95
|
-
const rust = parseRustSummary(line);
|
|
96
|
-
if (rust !== null) {
|
|
97
|
-
candidates.push(rust);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
const first = candidates[0];
|
|
101
|
-
if (first === undefined ||
|
|
102
|
-
candidates.some((candidate) => candidate !== first)) {
|
|
103
|
-
return null;
|
|
104
|
-
}
|
|
105
|
-
return first;
|
|
105
|
+
return parseTestSummary(output).passed;
|
|
106
106
|
}
|
|
107
107
|
export function evaluateTestCount(testCount, minimum = 1) {
|
|
108
108
|
if (!Number.isSafeInteger(minimum) || minimum < 0) {
|