@kylecheng3146/agent-ops 0.4.0 → 0.4.1
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/README.md +35 -0
- package/dist/packages/cli/src/args.js +46 -6
- package/dist/packages/cli/src/bin.js +135 -70
- package/dist/packages/cli/src/cli.js +27 -3
- package/dist/packages/cli/src/commands/batch.js +106 -0
- package/dist/packages/cli/src/commands/review.js +22 -0
- package/dist/runtime/src/install/harness.js +1 -1
- package/dist/runtime/src/install/preauth.js +15 -4
- package/dist/runtime/src/review/batch-guard.js +11 -0
- package/dist/runtime/src/review/batch-select.js +37 -0
- package/dist/runtime/src/review/batch.js +202 -0
- package/dist/runtime/src/review/invocation.js +15 -2
- package/dist/runtime/src/review/probe.js +13 -0
- package/docs/en/spec/review.md +70 -0
- package/docs/zh-TW/spec/review.md +71 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -345,6 +345,41 @@ agent-ops doctor # presence only: no tokens, no network
|
|
|
345
345
|
agent-ops doctor --check-auth # one real print call per configured target
|
|
346
346
|
```
|
|
347
347
|
|
|
348
|
+
### Batch review
|
|
349
|
+
|
|
350
|
+
A change split into subtasks needs one verify and one review per subtask.
|
|
351
|
+
`agent-ops batch --parent <task-id> --yes` runs them together for the parent's
|
|
352
|
+
active subtasks and the parent itself, instead of one `review` at a time:
|
|
353
|
+
|
|
354
|
+
```bash
|
|
355
|
+
agent-ops batch --parent <task-id> --yes --output batch.json
|
|
356
|
+
agent-ops batch --parent <task-id> --yes --base <ref> --parent-base <ref> --width 3
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
- Each task is verified and reviewed against its own base: `--base` for the
|
|
360
|
+
subtasks and `--parent-base` for the parent, otherwise the base recorded by
|
|
361
|
+
its last PASS review, otherwise the worktree base.
|
|
362
|
+
- A task that already has fresh PASS verification evidence skips verify. The
|
|
363
|
+
rest are verified one at a time, and each task's review starts as soon as its
|
|
364
|
+
own verify passes, so the next verify overlaps it. A task whose verify fails
|
|
365
|
+
gets no review; the others continue.
|
|
366
|
+
- At most `--width` reviews run at once (default 2). A transient `NOT_RUN`
|
|
367
|
+
(`probe-failed`, `stalled`, `timeout`, `quota-exhausted`,
|
|
368
|
+
`network-unreachable`) lowers the width to 1 for the rest and earns that task
|
|
369
|
+
one retry, alone, after the others finish. Other results are final.
|
|
370
|
+
- Each review target is probed once per batch while the probe succeeds; a
|
|
371
|
+
failed probe is forgotten so the retry probes again.
|
|
372
|
+
- Nothing may commit or edit the worktree while it runs. HEAD is part of every
|
|
373
|
+
fingerprint, so a change aborts the batch and reports the unfinished tasks
|
|
374
|
+
as `aborted`.
|
|
375
|
+
- Exit code 0 means every task passed, 1 means a review or verify failed, and 2
|
|
376
|
+
means the rest could not run and may be retried. The JSON envelope lists each
|
|
377
|
+
task; the full reports stay under `.agent-ops/reviews/`.
|
|
378
|
+
|
|
379
|
+
`batch` is started exactly like `review`: through the trusted outer host runner
|
|
380
|
+
as its first and only invocation, with `--output` instead of a shell redirect.
|
|
381
|
+
`agent-ops init` and `update` pre-authorize it beside `review`.
|
|
382
|
+
|
|
348
383
|
See [Configuration](docs/en/guides/configuration.md) for the full contract.
|
|
349
384
|
|
|
350
385
|
## Project principles
|
|
@@ -9,6 +9,7 @@ export const COMMAND_NAMES = [
|
|
|
9
9
|
"task",
|
|
10
10
|
"verify",
|
|
11
11
|
"review",
|
|
12
|
+
"batch",
|
|
12
13
|
"allow-stop",
|
|
13
14
|
"worktree"
|
|
14
15
|
];
|
|
@@ -71,6 +72,8 @@ export function parseArgs(argv) {
|
|
|
71
72
|
let title;
|
|
72
73
|
let sessionId;
|
|
73
74
|
let base;
|
|
75
|
+
let parentBase;
|
|
76
|
+
let width;
|
|
74
77
|
const profiles = [];
|
|
75
78
|
const reviewTargets = [];
|
|
76
79
|
const criteria = [];
|
|
@@ -210,6 +213,26 @@ export function parseArgs(argv) {
|
|
|
210
213
|
index += 1;
|
|
211
214
|
break;
|
|
212
215
|
}
|
|
216
|
+
case "--parent-base": {
|
|
217
|
+
if (parentBase !== undefined) {
|
|
218
|
+
duplicate(token);
|
|
219
|
+
}
|
|
220
|
+
parentBase = readOptionValue(argv, index, token);
|
|
221
|
+
index += 1;
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
case "--width": {
|
|
225
|
+
if (width !== undefined) {
|
|
226
|
+
duplicate(token);
|
|
227
|
+
}
|
|
228
|
+
const value = readOptionValue(argv, index, token);
|
|
229
|
+
if (!/^[1-9][0-9]{0,2}$/u.test(value)) {
|
|
230
|
+
invalidValue(token, value);
|
|
231
|
+
}
|
|
232
|
+
width = Number(value);
|
|
233
|
+
index += 1;
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
213
236
|
case "--check-auth":
|
|
214
237
|
if (checkAuth) {
|
|
215
238
|
duplicate(token);
|
|
@@ -416,6 +439,8 @@ export function parseArgs(argv) {
|
|
|
416
439
|
completionGate !== undefined ||
|
|
417
440
|
sessionId !== undefined ||
|
|
418
441
|
base !== undefined ||
|
|
442
|
+
parentBase !== undefined ||
|
|
443
|
+
width !== undefined ||
|
|
419
444
|
checkAuth ||
|
|
420
445
|
checkAuthTargets.length > 0 ||
|
|
421
446
|
dryRun ||
|
|
@@ -465,15 +490,18 @@ export function parseArgs(argv) {
|
|
|
465
490
|
(hasTaskTargetOptions || hasTaskMutationOptions)) {
|
|
466
491
|
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Task target options may be used only with task or verify.");
|
|
467
492
|
}
|
|
468
|
-
if (command !== "task" && parentTaskId !== undefined) {
|
|
469
|
-
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--parent may be used only with task create
|
|
493
|
+
if (command !== "task" && command !== "batch" && parentTaskId !== undefined) {
|
|
494
|
+
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--parent may be used only with task create, task status or batch.");
|
|
470
495
|
}
|
|
471
496
|
if (command !== "update" && targetVersion !== undefined) {
|
|
472
497
|
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--target-version may be used only with update.");
|
|
473
498
|
}
|
|
474
499
|
if (base !== undefined && command !== "verify" && command !== "review" &&
|
|
475
|
-
!(command === "task" && action === "complete")) {
|
|
476
|
-
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--base may be used only with verify, review or task complete.");
|
|
500
|
+
command !== "batch" && !(command === "task" && action === "complete")) {
|
|
501
|
+
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--base may be used only with verify, review, batch or task complete.");
|
|
502
|
+
}
|
|
503
|
+
if ((parentBase !== undefined || width !== undefined) && command !== "batch") {
|
|
504
|
+
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", `${parentBase !== undefined ? "--parent-base" : "--width"} may be used only with batch.`);
|
|
477
505
|
}
|
|
478
506
|
if (hookTargets.length > 0 &&
|
|
479
507
|
command !== "init" &&
|
|
@@ -566,6 +594,16 @@ export function parseArgs(argv) {
|
|
|
566
594
|
if (command === "review" && (criteria.length > 0 || evidence.length > 0)) {
|
|
567
595
|
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Complete review covers every task criterion; omit --criterion and --evidence.");
|
|
568
596
|
}
|
|
597
|
+
if (command === "batch" && parentTaskId === undefined) {
|
|
598
|
+
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Batch requires --parent to name the task whose subtasks it covers.");
|
|
599
|
+
}
|
|
600
|
+
if (command === "batch" && !yes) {
|
|
601
|
+
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Batch requires --yes to authorize the reviewer sessions.");
|
|
602
|
+
}
|
|
603
|
+
if (command === "batch" &&
|
|
604
|
+
(harness !== undefined || profiles.length > 0 || dryRun || rerun)) {
|
|
605
|
+
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Batch accepts parent, base, parent-base, width, json, output and yes options.");
|
|
606
|
+
}
|
|
569
607
|
if (command === "allow-stop" &&
|
|
570
608
|
(sessionId === undefined ||
|
|
571
609
|
scope !== undefined ||
|
|
@@ -589,8 +627,8 @@ export function parseArgs(argv) {
|
|
|
589
627
|
rerun)) {
|
|
590
628
|
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "worktree accepts only a name, --session, --from, --target-branch, --message and --json.");
|
|
591
629
|
}
|
|
592
|
-
if (output !== undefined && command !== "review" && command !== "doctor") {
|
|
593
|
-
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--output may be used only with review or doctor.", "--output");
|
|
630
|
+
if (output !== undefined && command !== "review" && command !== "batch" && command !== "doctor") {
|
|
631
|
+
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--output may be used only with review, batch or doctor.", "--output");
|
|
594
632
|
}
|
|
595
633
|
if (output !== undefined && (output.trim() === "" || output.includes("\0"))) {
|
|
596
634
|
throw new CliArgumentError("CLI_INVALID_VALUE", "Invalid value for --output.", "--output");
|
|
@@ -636,6 +674,8 @@ export function parseArgs(argv) {
|
|
|
636
674
|
...(evidence.length === 0 ? {} : { evidence }),
|
|
637
675
|
...(sessionId === undefined ? {} : { sessionId }),
|
|
638
676
|
...(base === undefined ? {} : { base }),
|
|
677
|
+
...(parentBase === undefined ? {} : { parentBase }),
|
|
678
|
+
...(width === undefined ? {} : { width }),
|
|
639
679
|
...(worktreeFrom === undefined ? {} : { worktreeFrom }),
|
|
640
680
|
...(worktreeMessage === undefined ? {} : { worktreeMessage }),
|
|
641
681
|
...(output === undefined ? {} : { output }),
|
|
@@ -18,6 +18,7 @@ import { FileEvidenceStore } from "../../../runtime/src/verify/evidence.js";
|
|
|
18
18
|
import { calculateSourceFingerprint } from "../../../runtime/src/verify/source-fingerprint.js";
|
|
19
19
|
import { VerificationService } from "../../../runtime/src/verify/service.js";
|
|
20
20
|
import { NodeVerificationProcessRunner } from "../../../runtime/src/verify/spawn.js";
|
|
21
|
+
import { COMMAND_NAMES, parseArgs } from "./args.js";
|
|
21
22
|
import { runCli } from "./cli.js";
|
|
22
23
|
import { loadEffectiveConfig, repositoryTrust, repositoryTrustBinding } from "./context.js";
|
|
23
24
|
import { runHookProcess } from "./hook-process.js";
|
|
@@ -30,7 +31,10 @@ import { runDoctorCommand } from "./commands/doctor.js";
|
|
|
30
31
|
import { formatInstallPlan, runInitCommand } from "./commands/init.js";
|
|
31
32
|
import { formatUninstallPlan, runUninstallCommand } from "./commands/uninstall.js";
|
|
32
33
|
import { runTaskCommand } from "./commands/task.js";
|
|
33
|
-
import { runReviewCommand } from "./commands/review.js";
|
|
34
|
+
import { hasFreshVerification, runReviewCommand } from "./commands/review.js";
|
|
35
|
+
import { runBatchCommand } from "./commands/batch.js";
|
|
36
|
+
import { createSourceGuard } from "../../../runtime/src/review/batch-guard.js";
|
|
37
|
+
import { memoizePreflight } from "../../../runtime/src/review/batch.js";
|
|
34
38
|
import { createReviewExecutor, ReviewInterruptedError } from "../../../runtime/src/review/execute.js";
|
|
35
39
|
import { probeReviewTargetDetailed } from "../../../runtime/src/review/probe.js";
|
|
36
40
|
import { resolveReviewScope } from "../../../runtime/src/review/scope.js";
|
|
@@ -43,7 +47,7 @@ import { formatUpdatePlan, runUpdateCommand } from "./commands/update.js";
|
|
|
43
47
|
import { errorEnvelope } from "./output.js";
|
|
44
48
|
import { runAgyHeadless } from "./agy-headless.js";
|
|
45
49
|
import { runWorktreeCommand } from "./commands/parallel.js";
|
|
46
|
-
import { ensureSessionWorktree, resolveCheckouts } from "../../../runtime/src/parallel/service.js";
|
|
50
|
+
import { ensureSessionWorktree, readWorktreeRecord, resolveCheckouts } from "../../../runtime/src/parallel/service.js";
|
|
47
51
|
import { listWorktrees, branchLockDoctorResult, worktreeDoctorResult } from "../../../runtime/src/parallel/manage.js";
|
|
48
52
|
import { detectGhostFiles, ghostFilesDoctorResult } from "../../../runtime/src/install/doctor.js";
|
|
49
53
|
const HOOK_RUNTIME_PATH = fileURLToPath(new URL("./hook-entry.js", import.meta.url));
|
|
@@ -207,19 +211,10 @@ else {
|
|
|
207
211
|
writeStderr: (value) => process.stderr.write(value)
|
|
208
212
|
}, {
|
|
209
213
|
version: CLI_VERSION,
|
|
210
|
-
registry: createCommandRegistry(Object.fromEntries(
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
"doctor",
|
|
215
|
-
"update",
|
|
216
|
-
"uninstall",
|
|
217
|
-
"task",
|
|
218
|
-
"verify",
|
|
219
|
-
"review",
|
|
220
|
-
"allow-stop",
|
|
221
|
-
"worktree"
|
|
222
|
-
].map((command) => [command, async (args) => {
|
|
214
|
+
registry: createCommandRegistry(Object.fromEntries(
|
|
215
|
+
// Every command the parser knows is registered: a hand-kept list here
|
|
216
|
+
// once left a parsed command answering CLI_COMMAND_UNAVAILABLE.
|
|
217
|
+
COMMAND_NAMES.map((command) => [command, async (args) => {
|
|
223
218
|
const root = args.scope === "user"
|
|
224
219
|
? process.env.AGENT_OPS_HOME ?? homedir()
|
|
225
220
|
: process.cwd();
|
|
@@ -442,12 +437,68 @@ else {
|
|
|
442
437
|
: {})
|
|
443
438
|
});
|
|
444
439
|
}
|
|
445
|
-
|
|
440
|
+
const probeTarget = async (target, budget) => await probeReviewTargetDetailed(target, {
|
|
441
|
+
cwd: root,
|
|
442
|
+
deep: true,
|
|
443
|
+
// The probe answers within the chain's remaining budget
|
|
444
|
+
// or not at all; its own default would outlast it.
|
|
445
|
+
...(budget === undefined ? {} : { timeoutMs: budget.timeoutMs })
|
|
446
|
+
});
|
|
447
|
+
// One place builds a review's options, so `review` and each task of
|
|
448
|
+
// a `batch` are reviewed identically.
|
|
449
|
+
const buildReviewOptions = async (reviewArgs, signal, preflightTarget = probeTarget, progressPrefix = "") => {
|
|
446
450
|
const reviewSessionId = process.env.AGENT_OPS_SESSION_ID;
|
|
447
451
|
const reviewGit = gitRunner(root);
|
|
448
|
-
const reviewConfig = (await loadEffectiveConfig(root,
|
|
452
|
+
const reviewConfig = (await loadEffectiveConfig(root, reviewArgs.scope === "user" ? "user" : "project")).config;
|
|
449
453
|
const reviewRole = resolveReviewRole("independent-review", reviewConfig.reviewRoles ?? []);
|
|
450
454
|
const configuredReviewTargets = reviewRole?.targets ?? [];
|
|
455
|
+
return {
|
|
456
|
+
args: reviewArgs,
|
|
457
|
+
authorized: reviewArgs.yes,
|
|
458
|
+
tasks: taskService,
|
|
459
|
+
...(reviewArgs.taskId === undefined ? {} : { taskId: reviewArgs.taskId }),
|
|
460
|
+
...(reviewSessionId === undefined
|
|
461
|
+
? {}
|
|
462
|
+
: { sessionId: reviewSessionId }),
|
|
463
|
+
...(reviewConfig.reviewRoles === undefined
|
|
464
|
+
? {}
|
|
465
|
+
: { roles: reviewConfig.reviewRoles }),
|
|
466
|
+
targets: configuredReviewTargets,
|
|
467
|
+
root,
|
|
468
|
+
gitRunner: reviewGit,
|
|
469
|
+
policyConfigHash: calculateConfigHash(reviewConfig),
|
|
470
|
+
currentPolicyConfigHash: async () => calculateConfigHash((await loadEffectiveConfig(root, reviewArgs.scope === "user" ? "user" : "project")).config),
|
|
471
|
+
config: reviewConfig,
|
|
472
|
+
evidenceStore: new FileEvidenceStore(root, root),
|
|
473
|
+
execute: createReviewExecutor({
|
|
474
|
+
targets: configuredReviewTargets,
|
|
475
|
+
cwd: root,
|
|
476
|
+
...(reviewRole?.model === undefined
|
|
477
|
+
? {}
|
|
478
|
+
: { model: reviewRole.model }),
|
|
479
|
+
...(reviewRole?.effort === undefined
|
|
480
|
+
? {}
|
|
481
|
+
: { effort: reviewRole.effort }),
|
|
482
|
+
...(reviewRole?.timeoutMs === undefined
|
|
483
|
+
? {}
|
|
484
|
+
: { timeoutMs: reviewRole.timeoutMs }),
|
|
485
|
+
preflightTarget,
|
|
486
|
+
verifySourceFingerprint: async (expected) => {
|
|
487
|
+
const currentScope = await resolveReviewScope({
|
|
488
|
+
root,
|
|
489
|
+
runner: reviewGit,
|
|
490
|
+
...(reviewArgs.base === undefined ? {} : { base: reviewArgs.base })
|
|
491
|
+
});
|
|
492
|
+
return await calculateSourceFingerprint(root, currentScope, reviewGit) === expected;
|
|
493
|
+
},
|
|
494
|
+
signal,
|
|
495
|
+
onProgress: (line) => {
|
|
496
|
+
process.stderr.write(`${progressPrefix}${line}\n`);
|
|
497
|
+
}
|
|
498
|
+
})
|
|
499
|
+
};
|
|
500
|
+
};
|
|
501
|
+
if (args.command === "review") {
|
|
451
502
|
const controller = new AbortController();
|
|
452
503
|
let interruptedBy;
|
|
453
504
|
const interrupt = (signal) => {
|
|
@@ -459,59 +510,7 @@ else {
|
|
|
459
510
|
process.once("SIGINT", onSigint);
|
|
460
511
|
process.once("SIGTERM", onSigterm);
|
|
461
512
|
try {
|
|
462
|
-
return await runReviewCommand(
|
|
463
|
-
args,
|
|
464
|
-
authorized: args.yes,
|
|
465
|
-
tasks: taskService,
|
|
466
|
-
...(args.taskId === undefined ? {} : { taskId: args.taskId }),
|
|
467
|
-
...(reviewSessionId === undefined
|
|
468
|
-
? {}
|
|
469
|
-
: { sessionId: reviewSessionId }),
|
|
470
|
-
...(reviewConfig.reviewRoles === undefined
|
|
471
|
-
? {}
|
|
472
|
-
: { roles: reviewConfig.reviewRoles }),
|
|
473
|
-
targets: configuredReviewTargets,
|
|
474
|
-
root,
|
|
475
|
-
gitRunner: reviewGit,
|
|
476
|
-
policyConfigHash: calculateConfigHash(reviewConfig),
|
|
477
|
-
currentPolicyConfigHash: async () => calculateConfigHash((await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config),
|
|
478
|
-
config: reviewConfig,
|
|
479
|
-
evidenceStore: new FileEvidenceStore(root, root),
|
|
480
|
-
execute: createReviewExecutor({
|
|
481
|
-
targets: configuredReviewTargets,
|
|
482
|
-
cwd: root,
|
|
483
|
-
...(reviewRole?.model === undefined
|
|
484
|
-
? {}
|
|
485
|
-
: { model: reviewRole.model }),
|
|
486
|
-
...(reviewRole?.effort === undefined
|
|
487
|
-
? {}
|
|
488
|
-
: { effort: reviewRole.effort }),
|
|
489
|
-
...(reviewRole?.timeoutMs === undefined
|
|
490
|
-
? {}
|
|
491
|
-
: { timeoutMs: reviewRole.timeoutMs }),
|
|
492
|
-
preflightTarget: async (target, budget) => await probeReviewTargetDetailed(target, {
|
|
493
|
-
cwd: root,
|
|
494
|
-
deep: true,
|
|
495
|
-
// The probe answers within the chain's remaining budget
|
|
496
|
-
// or not at all; its own default would outlast it.
|
|
497
|
-
...(budget === undefined
|
|
498
|
-
? {}
|
|
499
|
-
: { timeoutMs: budget.timeoutMs })
|
|
500
|
-
}),
|
|
501
|
-
verifySourceFingerprint: async (expected) => {
|
|
502
|
-
const currentScope = await resolveReviewScope({
|
|
503
|
-
root,
|
|
504
|
-
runner: reviewGit,
|
|
505
|
-
...(args.base === undefined ? {} : { base: args.base })
|
|
506
|
-
});
|
|
507
|
-
return await calculateSourceFingerprint(root, currentScope, reviewGit) === expected;
|
|
508
|
-
},
|
|
509
|
-
signal: controller.signal,
|
|
510
|
-
onProgress: (line) => {
|
|
511
|
-
process.stderr.write(`${line}\n`);
|
|
512
|
-
}
|
|
513
|
-
})
|
|
514
|
-
});
|
|
513
|
+
return await runReviewCommand(await buildReviewOptions(args, controller.signal));
|
|
515
514
|
}
|
|
516
515
|
catch (error) {
|
|
517
516
|
if (error instanceof ReviewInterruptedError &&
|
|
@@ -525,6 +524,72 @@ else {
|
|
|
525
524
|
process.removeListener("SIGTERM", onSigterm);
|
|
526
525
|
}
|
|
527
526
|
}
|
|
527
|
+
if (args.command === "batch") {
|
|
528
|
+
const controller = new AbortController();
|
|
529
|
+
let interruptedBy;
|
|
530
|
+
const interrupt = (signal) => {
|
|
531
|
+
interruptedBy ??= signal;
|
|
532
|
+
controller.abort(signal);
|
|
533
|
+
};
|
|
534
|
+
const onSigint = () => interrupt("SIGINT");
|
|
535
|
+
const onSigterm = () => interrupt("SIGTERM");
|
|
536
|
+
process.once("SIGINT", onSigint);
|
|
537
|
+
process.once("SIGTERM", onSigterm);
|
|
538
|
+
try {
|
|
539
|
+
const batchScope = args.scope === "user" ? "user" : "project";
|
|
540
|
+
const batchConfig = (await loadEffectiveConfig(root, batchScope)).config;
|
|
541
|
+
const batchGit = gitRunner(root);
|
|
542
|
+
const trusted = await repositoryTrust(root, batchConfig, CLI_VERSION) === "TRUSTED";
|
|
543
|
+
const guard = await createSourceGuard(batchGit);
|
|
544
|
+
const preflight = memoizePreflight(probeTarget);
|
|
545
|
+
const taskArgs = (taskId, base) => parseArgs(["review", "--task", taskId, "--base", base, "--yes"]);
|
|
546
|
+
const worktreeRecord = await readWorktreeRecord(root);
|
|
547
|
+
const envelope = await runBatchCommand({
|
|
548
|
+
args,
|
|
549
|
+
tasks: taskService,
|
|
550
|
+
...(worktreeRecord === null ? {} : { worktreeBase: worktreeRecord.base }),
|
|
551
|
+
guard,
|
|
552
|
+
isVerified: async (taskId, base) => await hasFreshVerification(await buildReviewOptions(taskArgs(taskId, base), controller.signal, preflight), taskId, base),
|
|
553
|
+
verify: async (taskId, base) => {
|
|
554
|
+
const report = await new VerificationService({
|
|
555
|
+
root,
|
|
556
|
+
scope: batchScope,
|
|
557
|
+
config: batchConfig,
|
|
558
|
+
gitRunner: batchGit,
|
|
559
|
+
processRunner: new NodeVerificationProcessRunner(),
|
|
560
|
+
taskService,
|
|
561
|
+
evidenceStore: new FileEvidenceStore(root, root),
|
|
562
|
+
trusted,
|
|
563
|
+
base
|
|
564
|
+
}).verify(taskId);
|
|
565
|
+
return report.status === "PASS" ? "PASS" : "FAIL";
|
|
566
|
+
},
|
|
567
|
+
review: async (taskId, base, signal) => {
|
|
568
|
+
const reviewed = await runReviewCommand(await buildReviewOptions(taskArgs(taskId, base), signal, preflight, `${taskId.slice(5, 13)}: `));
|
|
569
|
+
const result = reviewed.data?.result;
|
|
570
|
+
return result === undefined
|
|
571
|
+
? { status: "NOT_RUN", reason: reviewed.errors[0]?.code ?? "review-error" }
|
|
572
|
+
: {
|
|
573
|
+
status: result.status,
|
|
574
|
+
...(result.reason === undefined ? {} : { reason: result.reason }),
|
|
575
|
+
...(result.reused === true ? { reused: true } : {})
|
|
576
|
+
};
|
|
577
|
+
},
|
|
578
|
+
signal: controller.signal,
|
|
579
|
+
onProgress: (line) => {
|
|
580
|
+
process.stderr.write(`batch: ${line}\n`);
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
if (interruptedBy !== undefined) {
|
|
584
|
+
process.exit(interruptedBy === "SIGINT" ? 130 : 143);
|
|
585
|
+
}
|
|
586
|
+
return envelope;
|
|
587
|
+
}
|
|
588
|
+
finally {
|
|
589
|
+
process.removeListener("SIGINT", onSigint);
|
|
590
|
+
process.removeListener("SIGTERM", onSigterm);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
528
593
|
if (args.command === "config") {
|
|
529
594
|
return explainConfigCommand(await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project"));
|
|
530
595
|
}
|
|
@@ -29,6 +29,7 @@ Commands:
|
|
|
29
29
|
Manage independent task acceptance state
|
|
30
30
|
verify Run configured verification
|
|
31
31
|
review Run an independent review
|
|
32
|
+
batch Verify and review a parent task's subtasks together
|
|
32
33
|
allow-stop Grant one fingerprint-bound completion-gate Stop permit (requires --session)
|
|
33
34
|
agy-run Run headless agy with a process-exit completion recheck
|
|
34
35
|
worktree <add|finish|list|resume|remove>
|
|
@@ -47,13 +48,16 @@ Options:
|
|
|
47
48
|
--check-auth-target <target> Repeatable doctor filter with --check-auth
|
|
48
49
|
--task <id>
|
|
49
50
|
--parent <task-id> Task create: record a subtask of this
|
|
50
|
-
task; task status: list its subtasks
|
|
51
|
+
task; task status: list its subtasks;
|
|
52
|
+
batch: the task whose subtasks it covers
|
|
53
|
+
--parent-base <git-ref> Batch only: base for the parent task
|
|
54
|
+
--width <n> Batch only: reviews allowed at once (default 2)
|
|
51
55
|
--target-version <version> Update target version (offline-capable)
|
|
52
56
|
--title <text>
|
|
53
57
|
--criterion <json> Repeatable
|
|
54
58
|
--evidence <criterion-id=reference> Repeatable
|
|
55
59
|
--session <id>
|
|
56
|
-
--base <git-ref> Verify/review/complete a clean committed range
|
|
60
|
+
--base <git-ref> Verify/review/batch/complete a clean committed range
|
|
57
61
|
--dry-run
|
|
58
62
|
--json
|
|
59
63
|
--yes
|
|
@@ -181,6 +185,26 @@ Options:
|
|
|
181
185
|
one summary line, so no shell redirect is needed
|
|
182
186
|
--json
|
|
183
187
|
--yes Required authorization for both reviewer sessions
|
|
188
|
+
`,
|
|
189
|
+
batch: `Usage: agent-ops batch --parent <task-id> --yes [options]
|
|
190
|
+
|
|
191
|
+
Verify and review the active subtasks of a parent task, and the parent itself,
|
|
192
|
+
together. Verify runs one task at a time and skips a task that already has
|
|
193
|
+
fresh PASS evidence; each review starts as soon as its own verify passes.
|
|
194
|
+
Nothing may commit or edit the worktree while it runs.
|
|
195
|
+
|
|
196
|
+
Options:
|
|
197
|
+
--parent <task-id> Required task whose active subtasks and itself are covered
|
|
198
|
+
--base <git-ref> Base for the subtasks (default: each task's recorded
|
|
199
|
+
review base, then the worktree base)
|
|
200
|
+
--parent-base <git-ref>
|
|
201
|
+
Base for the parent task (same default)
|
|
202
|
+
--width <n> Reviews allowed at once (default 2); a transient
|
|
203
|
+
NOT_RUN lowers it to 1 and earns one retry
|
|
204
|
+
--output <file> Write the JSON envelope to <file> (mode 0600) and print
|
|
205
|
+
one summary line, so no shell redirect is needed
|
|
206
|
+
--json
|
|
207
|
+
--yes Required authorization for the reviewer sessions
|
|
184
208
|
`,
|
|
185
209
|
"allow-stop": `Usage: agent-ops allow-stop --session <id> [options]
|
|
186
210
|
|
|
@@ -289,7 +313,7 @@ export async function runCli(argv, io, services) {
|
|
|
289
313
|
const result = await execute(args);
|
|
290
314
|
const exitCode = result.status === "ok"
|
|
291
315
|
? 0
|
|
292
|
-
: result.code === "REVIEW_NOT_RUN"
|
|
316
|
+
: result.code === "REVIEW_NOT_RUN" || result.code === "BATCH_NOT_RUN"
|
|
293
317
|
? 2
|
|
294
318
|
: 1;
|
|
295
319
|
if (args.output !== undefined) {
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { AgentOpsError } from "../../../../runtime/src/fs/paths.js";
|
|
2
|
+
import { DEFAULT_BATCH_WIDTH, runBatch } from "../../../../runtime/src/review/batch.js";
|
|
3
|
+
import { resolveBatchBase, selectBatchTasks } from "../../../../runtime/src/review/batch-select.js";
|
|
4
|
+
import { redactSecrets } from "../../../../runtime/src/security/redact.js";
|
|
5
|
+
import { safeTaskText } from "../../../../runtime/src/task/render.js";
|
|
6
|
+
import { errorEnvelope } from "../output.js";
|
|
7
|
+
function outcomeLabel(task) {
|
|
8
|
+
if (task.aborted) {
|
|
9
|
+
return "aborted";
|
|
10
|
+
}
|
|
11
|
+
if (task.review === null) {
|
|
12
|
+
return task.verify === "FAIL" ? "no review (verify failed)" : "not reviewed";
|
|
13
|
+
}
|
|
14
|
+
const reason = task.review.reason === undefined ? "" : ` ${task.review.reason}`;
|
|
15
|
+
return `${task.review.status}${reason}${task.review.reused === true ? " (reused)" : ""}${task.retried ? " (retried)" : ""}`;
|
|
16
|
+
}
|
|
17
|
+
function renderBatch(data) {
|
|
18
|
+
const lines = [
|
|
19
|
+
`Batch of ${data.tasks.length} task(s) under ${data.parentTaskId}, width ${data.width}.`,
|
|
20
|
+
"",
|
|
21
|
+
...data.tasks.map((task) => `- ${task.taskId} verify ${task.verify} review ${outcomeLabel(task)} base ${task.base.slice(0, 12)} (${task.baseSource}) ${safeTaskText(redactSecrets(task.title))}`)
|
|
22
|
+
];
|
|
23
|
+
if (data.tasks.some((task) => task.aborted)) {
|
|
24
|
+
lines.push("", "The source changed or the run was interrupted; unfinished tasks were aborted. Run the batch again.");
|
|
25
|
+
}
|
|
26
|
+
return lines.join("\n");
|
|
27
|
+
}
|
|
28
|
+
function isPass(task) {
|
|
29
|
+
return !task.aborted && task.review?.status === "PASS";
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The envelope's code follows the worst outcome that needs a human: a failed
|
|
33
|
+
* verify or review (fix the code) before a run that could not finish (run it
|
|
34
|
+
* again), so one defect is never hidden behind an unrelated NOT_RUN.
|
|
35
|
+
*/
|
|
36
|
+
function classify(tasks) {
|
|
37
|
+
if (tasks.every(isPass)) {
|
|
38
|
+
return "BATCH_RESULT";
|
|
39
|
+
}
|
|
40
|
+
return tasks.some((task) => task.verify === "FAIL" || task.review?.status === "FAIL")
|
|
41
|
+
? "BATCH_FAILED"
|
|
42
|
+
: "BATCH_NOT_RUN";
|
|
43
|
+
}
|
|
44
|
+
export async function runBatchCommand(options) {
|
|
45
|
+
const { args } = options;
|
|
46
|
+
const parentTaskId = args.parentTaskId;
|
|
47
|
+
if (parentTaskId === undefined) {
|
|
48
|
+
throw new AgentOpsError("BATCH_PARENT_REQUIRED", "Batch requires --parent.");
|
|
49
|
+
}
|
|
50
|
+
const selected = selectBatchTasks(await options.tasks.list(), parentTaskId);
|
|
51
|
+
const plan = selected.map((record) => ({
|
|
52
|
+
record,
|
|
53
|
+
...resolveBatchBase(record, record.task.id === parentTaskId, {
|
|
54
|
+
...(args.base === undefined ? {} : { base: args.base }),
|
|
55
|
+
...(args.parentBase === undefined ? {} : { parentBase: args.parentBase }),
|
|
56
|
+
...(options.worktreeBase === undefined ? {} : { worktreeBase: options.worktreeBase })
|
|
57
|
+
})
|
|
58
|
+
}));
|
|
59
|
+
const baseOf = new Map(plan.map((item) => [item.record.task.id, item.base]));
|
|
60
|
+
const base = (id) => baseOf.get(id);
|
|
61
|
+
// The first answer is the clean-tree check: a batch reviews committed ranges.
|
|
62
|
+
if (!await options.guard()) {
|
|
63
|
+
return {
|
|
64
|
+
...errorEnvelope("BATCH_DIRTY_WORKTREE", "Batch needs a clean working tree. Commit or discard your changes first."),
|
|
65
|
+
data: null
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const width = args.width ?? DEFAULT_BATCH_WIDTH;
|
|
69
|
+
const outcomes = await runBatch(plan.map((item) => item.record.task.id), {
|
|
70
|
+
isVerified: (id) => options.isVerified(id, base(id)),
|
|
71
|
+
verify: (id, signal) => options.verify(id, base(id), signal),
|
|
72
|
+
review: (id, signal) => options.review(id, base(id), signal),
|
|
73
|
+
guard: options.guard
|
|
74
|
+
}, {
|
|
75
|
+
width,
|
|
76
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
77
|
+
...(options.onProgress === undefined ? {} : { onProgress: options.onProgress })
|
|
78
|
+
});
|
|
79
|
+
const byId = new Map(outcomes.map((outcome) => [outcome.id, outcome]));
|
|
80
|
+
const tasks = plan.map((item) => {
|
|
81
|
+
const outcome = byId.get(item.record.task.id);
|
|
82
|
+
return {
|
|
83
|
+
taskId: item.record.task.id,
|
|
84
|
+
title: item.record.task.title,
|
|
85
|
+
base: item.base,
|
|
86
|
+
baseSource: item.source,
|
|
87
|
+
verify: outcome.verify,
|
|
88
|
+
review: outcome.review,
|
|
89
|
+
retried: outcome.retried,
|
|
90
|
+
aborted: outcome.aborted
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
const code = classify(tasks);
|
|
94
|
+
const passed = tasks.filter(isPass).length;
|
|
95
|
+
const message = code === "BATCH_RESULT"
|
|
96
|
+
? `Batch passed: ${passed} of ${tasks.length} task(s).`
|
|
97
|
+
: code === "BATCH_FAILED"
|
|
98
|
+
? `Batch failed: ${passed} of ${tasks.length} task(s) passed.`
|
|
99
|
+
: `Batch was not completed: ${passed} of ${tasks.length} task(s) passed.`;
|
|
100
|
+
const summary = { message, parentTaskId, width, tasks };
|
|
101
|
+
const data = { ...summary, text: `${message}\n\n${renderBatch(summary)}` };
|
|
102
|
+
if (code === "BATCH_RESULT") {
|
|
103
|
+
return { code, status: "ok", data, errors: [] };
|
|
104
|
+
}
|
|
105
|
+
return { code, status: "error", data, errors: [{ code, message }] };
|
|
106
|
+
}
|
|
@@ -196,6 +196,28 @@ async function preflightReview(options, context, sourceFingerprint) {
|
|
|
196
196
|
summary: { status: "PASS", sourceFingerprint, commands }
|
|
197
197
|
};
|
|
198
198
|
}
|
|
199
|
+
/**
|
|
200
|
+
* Whether the task already holds current PASS verification evidence for the
|
|
201
|
+
* source a review of `base` (or the working tree) would measure. This is the
|
|
202
|
+
* same preflight `review` runs before it spawns, so a batch that skips verify
|
|
203
|
+
* on a true answer never reaches a review that then reports stale evidence.
|
|
204
|
+
*/
|
|
205
|
+
export async function hasFreshVerification(options, taskId, base) {
|
|
206
|
+
if (options.root === undefined || options.gitRunner === undefined) {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
const context = await taskContext({ ...options, taskId });
|
|
210
|
+
if (context === undefined) {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
const scope = await resolveReviewScope({
|
|
214
|
+
root: options.root,
|
|
215
|
+
runner: options.gitRunner,
|
|
216
|
+
...(base === undefined ? {} : { base })
|
|
217
|
+
});
|
|
218
|
+
const fingerprint = await calculateSourceFingerprint(options.root, scope, options.gitRunner);
|
|
219
|
+
return (await preflightReview(options, context, fingerprint)).ok;
|
|
220
|
+
}
|
|
199
221
|
function scopeReason(error) {
|
|
200
222
|
if (!(error instanceof AgentOpsError)) {
|
|
201
223
|
return undefined;
|
|
@@ -322,7 +322,7 @@ export function managedRules(descriptor, context) {
|
|
|
322
322
|
lines.push("For every change:", "", "1. Define two to five mechanically verifiable acceptance criteria.", "2. Inspect the smallest relevant scope and preserve unrelated changes.", "3. Apply the smallest safe change.", "4. Run evidence-producing verification for every criterion.", "5. Obtain complete independent review before claiming completion, via", " `agent-ops review --task <task-id> --yes` (or the CLI's equivalent invocation).", " Never call a", " review-target CLI (agy, codex, claude) directly — direct calls skip", " the enforced read-only sandbox flags and can hang or fail on command", " permission prompts.", " Start this command through the trusted outer host runner as its first", " and only invocation. On Codex, the outer tool request must use", " `sandbox_permissions: \"require_escalated\"` so network and loopback", " are available; in that same request, unset", " `CODEX_SANDBOX_NETWORK_DISABLED` and set `AGENT_OPS_HOST` when three", " targets are configured. The Codex shell form is", " `env -u CODEX_SANDBOX_NETWORK_DISABLED AGENT_OPS_HOST=<current-host> agent-ops review --task <task-id> --yes`.", " Keep it a plain command line: to keep the output add `--output <file>`", " (the JSON envelope is written there and one line is printed) instead of", " a shell redirect, which the host can only allow by matching the whole", " command string, task id included.", " Do not run it once in the restricted sandbox and retry; changing the", " variable inside that sandbox is not elevation.", " Review runs exactly two fresh sessions: with three configured targets,", " set `AGENT_OPS_HOST` to the current CLI id so it is excluded; with two", " targets both run in configured order; with one target it runs twice.", " The first session is necessary; a PASS then requires an adversarial", " re-check carrying the first report. A first FAIL or NOT_RUN stops.", "", "Treat `.agent-ops/config.json` as verifier authority. Discovery output is", "only a proposal until a user confirms it. Repository commands require an", "exact matching trust record. Confirmed project init/update grants it", "automatically when verification commands are configured.", "");
|
|
323
323
|
}
|
|
324
324
|
if (context.capabilities.includes("task")) {
|
|
325
|
-
lines.push("Split a change that exceeds five acceptance criteria into subtasks:", "`agent-ops task create --parent <task-id>` records one, and", "`agent-ops task status --parent <task-id>` lists them. Each subtask", "carries its own criteria, verification, and independent review;", "completing one never completes its parent.", "", "Delegate a subtask whose scope spans unknown files, broad scans, or an", "independent workstream instead of doing it inline. Name the bounded", "scope, the artifact to return, and the acceptance criteria it serves.", subagentInstruction(descriptor), "Read every returned artifact back yourself: integration, verification,", "and review stay with the coordinator.", "", "One writer at a time. `agent-ops verify` and `agent-ops review`", "fingerprint the working tree and report UNKNOWN when anything else", "writes it during the run, so delegate edits to one subagent at a time,", "or give each writer its own Git worktree and merge before verifying.", "Read-only delegation has no such limit and may run in parallel.", "", "Parallel conversations in one repository each need their own worktree.", "When `.agent-ops/config.json` sets `worktree.mode` to `auto`, run", "`agent-ops task create` from the main checkout before your first edit:", "it creates this session's worktree and puts the task there (`agent-ops", "worktree add <name> --session <session-id>` does the same without a", "task). Work only inside the printed path", "(Claude Code: EnterWorktree with that path; otherwise use it as every", "command's working directory). Commit, then run verify and review for", "each task with the printed `--base`; do not run `task complete` in the", "worktree. Leave it (Claude Code: ExitWorktree with action keep) and run", "`agent-ops worktree finish <name>` from the main checkout: it completes", "every task in the worktree, subtasks first, then merges. A finish that", "reports an incomplete task names it; verify or review that task again", "in the worktree and finish again. A finish that reports a conflict is", "resolved once, as a new task whose criteria cover both intents; if its", "verification or review fails, reset as instructed and report.", "");
|
|
325
|
+
lines.push("Split a change that exceeds five acceptance criteria into subtasks:", "`agent-ops task create --parent <task-id>` records one, and", "`agent-ops task status --parent <task-id>` lists them. Each subtask", "carries its own criteria, verification, and independent review;", "completing one never completes its parent.", "", "To review every active subtask of a parent together, run", "`agent-ops batch --parent <task-id> --yes` instead of one review per", "subtask. It needs what review needs: start it through the trusted outer", "host runner as its first and only invocation, with the same Codex", "elevation, and keep `--output <file>` instead of a shell redirect. It", "verifies, one task at a time, each task that lacks fresh evidence, and", "reviews a few tasks at once. Do not commit or edit the worktree while it", "runs: a moved HEAD voids every verify and review in the batch.", "", "Delegate a subtask whose scope spans unknown files, broad scans, or an", "independent workstream instead of doing it inline. Name the bounded", "scope, the artifact to return, and the acceptance criteria it serves.", subagentInstruction(descriptor), "Read every returned artifact back yourself: integration, verification,", "and review stay with the coordinator.", "", "One writer at a time. `agent-ops verify` and `agent-ops review`", "fingerprint the working tree and report UNKNOWN when anything else", "writes it during the run, so delegate edits to one subagent at a time,", "or give each writer its own Git worktree and merge before verifying.", "Read-only delegation has no such limit and may run in parallel.", "", "Parallel conversations in one repository each need their own worktree.", "When `.agent-ops/config.json` sets `worktree.mode` to `auto`, run", "`agent-ops task create` from the main checkout before your first edit:", "it creates this session's worktree and puts the task there (`agent-ops", "worktree add <name> --session <session-id>` does the same without a", "task). Work only inside the printed path", "(Claude Code: EnterWorktree with that path; otherwise use it as every", "command's working directory). Commit, then run verify and review for", "each task with the printed `--base`; do not run `task complete` in the", "worktree. Leave it (Claude Code: ExitWorktree with action keep) and run", "`agent-ops worktree finish <name>` from the main checkout: it completes", "every task in the worktree, subtasks first, then merges. A finish that", "reports an incomplete task names it; verify or review that task again", "in the worktree and finish again. A finish that reports a conflict is", "resolved once, as a new task whose criteria cover both intents; if its", "verification or review fails, reset as instructed and report.", "");
|
|
326
326
|
}
|
|
327
327
|
if (context.capabilities.includes("lifecycle-summary")) {
|
|
328
328
|
lines.push("Advisory lifecycle summaries and local logs are informational. Advisory", "failures must remain fail-open and cannot become verification evidence.", "");
|
|
@@ -15,13 +15,16 @@ export const CLAUDE_PREAUTH_ALLOW = [
|
|
|
15
15
|
"Bash(agent-ops task *)",
|
|
16
16
|
"Bash(agent-ops verify *)",
|
|
17
17
|
"Bash(agent-ops review *)",
|
|
18
|
+
"Bash(agent-ops batch *)",
|
|
18
19
|
"Bash(agent-ops worktree *)",
|
|
19
20
|
"Bash(agent-ops doctor *)"
|
|
20
21
|
];
|
|
21
|
-
/** review
|
|
22
|
+
/** review and batch talk to the reviewer's API, which the sandbox withholds. */
|
|
22
23
|
export const CLAUDE_PREAUTH_UNSANDBOXED = [
|
|
23
24
|
"agent-ops review",
|
|
24
|
-
"agent-ops review *"
|
|
25
|
+
"agent-ops review *",
|
|
26
|
+
"agent-ops batch",
|
|
27
|
+
"agent-ops batch *"
|
|
25
28
|
];
|
|
26
29
|
export const CODEX_RULES_PATH = "rules/agent-ops.rules";
|
|
27
30
|
export const CODEX_RULES_MARKER = "# agent-ops managed: regenerated by agent-ops init and update; do not edit.";
|
|
@@ -51,8 +54,9 @@ function rule(options) {
|
|
|
51
54
|
}
|
|
52
55
|
/**
|
|
53
56
|
* Prefix rules for the exact shapes `.agent-ops/AGENTS.md` tells Codex to run,
|
|
54
|
-
* with and without AGENT_OPS_HOST, for review and the auth probe.
|
|
55
|
-
* by argv prefix, so a shell redirect would defeat it: use
|
|
57
|
+
* with and without AGENT_OPS_HOST, for review, batch and the auth probe.
|
|
58
|
+
* Matching is by argv prefix, so a shell redirect would defeat it: use
|
|
59
|
+
* `--output`.
|
|
56
60
|
*/
|
|
57
61
|
export const CODEX_RULES_CONTENT = `${[
|
|
58
62
|
CODEX_RULES_MARKER,
|
|
@@ -67,6 +71,13 @@ export const CODEX_RULES_CONTENT = `${[
|
|
|
67
71
|
match: `${env} review --task task-x --yes --output review.json`,
|
|
68
72
|
notMatch: `${env} trust grant --scope project --yes`
|
|
69
73
|
}),
|
|
74
|
+
rule({
|
|
75
|
+
withHost,
|
|
76
|
+
command: ["batch"],
|
|
77
|
+
justification: "agent-ops batch runs reviews, which need network and loopback outside the sandbox.",
|
|
78
|
+
match: `${env} batch --parent task-x --yes --output batch.json`,
|
|
79
|
+
notMatch: `${env} trust grant --scope project --yes`
|
|
80
|
+
}),
|
|
70
81
|
rule({
|
|
71
82
|
withHost,
|
|
72
83
|
command: ["doctor", "--check-auth"],
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { collectChangeSurface, resolveGitCommit } from "../verify/change-surface.js";
|
|
2
|
+
/**
|
|
3
|
+
* Remembers HEAD now and answers whether the batch may go on: HEAD is still
|
|
4
|
+
* that commit and the working tree has no change. The first answer doubles as
|
|
5
|
+
* the clean-tree check, since a batch reviews committed ranges only.
|
|
6
|
+
*/
|
|
7
|
+
export async function createSourceGuard(runner) {
|
|
8
|
+
const head = await resolveGitCommit(runner, "HEAD");
|
|
9
|
+
return async () => await resolveGitCommit(runner, "HEAD") === head &&
|
|
10
|
+
(await collectChangeSurface(runner)).paths.length === 0;
|
|
11
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
2
|
+
/**
|
|
3
|
+
* The tasks one `batch --parent` covers: the parent's active subtasks in
|
|
4
|
+
* creation order, then the parent itself. Archived and completed tasks, and
|
|
5
|
+
* tasks under another parent, are left out.
|
|
6
|
+
*/
|
|
7
|
+
export function selectBatchTasks(records, parentId) {
|
|
8
|
+
const parent = records.find((record) => record.task.id === parentId);
|
|
9
|
+
if (parent === undefined) {
|
|
10
|
+
throw new AgentOpsError("BATCH_PARENT_NOT_FOUND", `Parent task not found: ${parentId}`);
|
|
11
|
+
}
|
|
12
|
+
if (parent.status !== "active") {
|
|
13
|
+
throw new AgentOpsError("BATCH_PARENT_NOT_ACTIVE", `Parent task is not active: ${parentId}`);
|
|
14
|
+
}
|
|
15
|
+
const subtasks = records
|
|
16
|
+
.filter((record) => record.task.parentTaskId === parentId && record.status === "active")
|
|
17
|
+
.sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.task.id.localeCompare(right.task.id));
|
|
18
|
+
return [...subtasks, parent];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* An explicit flag wins, then the commit the task's last PASS review measured
|
|
22
|
+
* against (so a rerun reproduces the same fingerprint), then the worktree base.
|
|
23
|
+
* A parent's base is a human choice that cannot be derived from the repository.
|
|
24
|
+
*/
|
|
25
|
+
export function resolveBatchBase(record, isParent, input) {
|
|
26
|
+
const flag = isParent ? input.parentBase : input.base;
|
|
27
|
+
if (flag !== undefined) {
|
|
28
|
+
return { base: flag, source: "flag" };
|
|
29
|
+
}
|
|
30
|
+
if (record.reviewBase !== undefined) {
|
|
31
|
+
return { base: record.reviewBase, source: "recorded" };
|
|
32
|
+
}
|
|
33
|
+
if (input.worktreeBase !== undefined) {
|
|
34
|
+
return { base: input.worktreeBase, source: "worktree" };
|
|
35
|
+
}
|
|
36
|
+
throw new AgentOpsError("BATCH_BASE_UNKNOWN", `No base for task ${record.task.id}: pass ${isParent ? "--parent-base" : "--base"} or run from an agent-ops worktree.`);
|
|
37
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
2
|
+
/**
|
|
3
|
+
* NOT_RUN reasons that mean the reviewer was briefly unavailable. Anything
|
|
4
|
+
* else (a sandboxed host, stale verification, a changed policy) fails the same
|
|
5
|
+
* way on a second attempt, so it is reported instead of retried.
|
|
6
|
+
*/
|
|
7
|
+
export const TRANSIENT_REVIEW_REASONS = new Set([
|
|
8
|
+
"probe-failed",
|
|
9
|
+
"stalled",
|
|
10
|
+
"timeout",
|
|
11
|
+
"quota-exhausted",
|
|
12
|
+
"network-unreachable"
|
|
13
|
+
]);
|
|
14
|
+
/** Reviews allowed at once when the caller names no width. */
|
|
15
|
+
export const DEFAULT_BATCH_WIDTH = 2;
|
|
16
|
+
/**
|
|
17
|
+
* Verify runs one task at a time (a full test run per task; several at once
|
|
18
|
+
* starve each other of CPU and fake a failure). Each task's review starts as
|
|
19
|
+
* soon as its own verify has passed, so the next verify overlaps it. Reviews
|
|
20
|
+
* run up to `width` at a time; a transient NOT_RUN drops the width to 1 for
|
|
21
|
+
* good and that task gets one more try alone after everything else finished.
|
|
22
|
+
*/
|
|
23
|
+
export async function runBatch(ids, deps, options) {
|
|
24
|
+
if (!Number.isInteger(options.width) || options.width < 1) {
|
|
25
|
+
throw new AgentOpsError("BATCH_INVALID_WIDTH", "Batch width must be a positive integer.");
|
|
26
|
+
}
|
|
27
|
+
const progress = options.onProgress ?? (() => { });
|
|
28
|
+
const controller = new AbortController();
|
|
29
|
+
const outer = options.signal;
|
|
30
|
+
if (outer?.aborted === true) {
|
|
31
|
+
controller.abort();
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
outer?.addEventListener("abort", () => controller.abort(), { once: true });
|
|
35
|
+
}
|
|
36
|
+
const slots = new Map(ids.map((id) => [
|
|
37
|
+
id,
|
|
38
|
+
{ id, verify: "not-run", review: null, retried: false }
|
|
39
|
+
]));
|
|
40
|
+
const slot = (id) => slots.get(id);
|
|
41
|
+
const stop = (why) => {
|
|
42
|
+
if (!controller.signal.aborted) {
|
|
43
|
+
progress(why);
|
|
44
|
+
controller.abort();
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
const guard = async () => {
|
|
48
|
+
try {
|
|
49
|
+
return await deps.guard();
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
let width = options.width;
|
|
56
|
+
let running = 0;
|
|
57
|
+
let verifying = true;
|
|
58
|
+
const ready = [];
|
|
59
|
+
const retry = [];
|
|
60
|
+
let finish;
|
|
61
|
+
const done = new Promise((resolve) => {
|
|
62
|
+
finish = resolve;
|
|
63
|
+
});
|
|
64
|
+
const review = async (id, final) => {
|
|
65
|
+
if (!await guard()) {
|
|
66
|
+
stop("source changed; aborting the batch");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const entry = slot(id);
|
|
70
|
+
if (final) {
|
|
71
|
+
entry.retried = true;
|
|
72
|
+
}
|
|
73
|
+
let result;
|
|
74
|
+
try {
|
|
75
|
+
result = await deps.review(id, controller.signal);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
progress(`review ${id} failed to run: ${error instanceof Error ? error.message : String(error)}`);
|
|
79
|
+
result = { status: "NOT_RUN", reason: "review-error" };
|
|
80
|
+
}
|
|
81
|
+
if (controller.signal.aborted) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
entry.review = result;
|
|
85
|
+
if (!final &&
|
|
86
|
+
result.status === "NOT_RUN" &&
|
|
87
|
+
result.reason !== undefined &&
|
|
88
|
+
TRANSIENT_REVIEW_REASONS.has(result.reason)) {
|
|
89
|
+
if (width > 1) {
|
|
90
|
+
width = 1;
|
|
91
|
+
progress(`degrade: review width -> 1 (${id}: ${result.reason})`);
|
|
92
|
+
}
|
|
93
|
+
retry.push(id);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
const pump = () => {
|
|
97
|
+
while (!controller.signal.aborted && running < width && ready.length > 0) {
|
|
98
|
+
const id = ready.shift();
|
|
99
|
+
running += 1;
|
|
100
|
+
void review(id, false).finally(() => {
|
|
101
|
+
running -= 1;
|
|
102
|
+
pump();
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
if (!verifying && running === 0 && (ready.length === 0 || controller.signal.aborted)) {
|
|
106
|
+
finish();
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
const fresh = await Promise.all(ids.map((id) => deps.isVerified(id).catch(() => false)));
|
|
110
|
+
const stale = [];
|
|
111
|
+
ids.forEach((id, index) => {
|
|
112
|
+
if (fresh[index] === true) {
|
|
113
|
+
slot(id).verify = "skipped";
|
|
114
|
+
ready.push(id);
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
stale.push(id);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
const verifyAll = async () => {
|
|
121
|
+
for (const id of stale) {
|
|
122
|
+
if (controller.signal.aborted) {
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
if (!await guard()) {
|
|
126
|
+
stop("source changed; aborting the batch");
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
let status;
|
|
130
|
+
try {
|
|
131
|
+
status = await deps.verify(id, controller.signal);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
progress(`verify ${id} failed to run: ${error instanceof Error ? error.message : String(error)}`);
|
|
135
|
+
status = "FAIL";
|
|
136
|
+
}
|
|
137
|
+
if (controller.signal.aborted) {
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
slot(id).verify = status;
|
|
141
|
+
if (status === "PASS") {
|
|
142
|
+
ready.push(id);
|
|
143
|
+
pump();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
verifying = false;
|
|
147
|
+
pump();
|
|
148
|
+
};
|
|
149
|
+
void verifyAll();
|
|
150
|
+
pump();
|
|
151
|
+
await done;
|
|
152
|
+
if (!controller.signal.aborted) {
|
|
153
|
+
for (const id of retry) {
|
|
154
|
+
if (controller.signal.aborted) {
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
await review(id, true);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const interrupted = controller.signal.aborted;
|
|
161
|
+
return ids.map((id) => {
|
|
162
|
+
const entry = slot(id);
|
|
163
|
+
const pendingRetry = retry.includes(id) && !entry.retried;
|
|
164
|
+
const unfinished = entry.review === null
|
|
165
|
+
? entry.verify !== "FAIL"
|
|
166
|
+
: pendingRetry;
|
|
167
|
+
return {
|
|
168
|
+
id,
|
|
169
|
+
verify: entry.verify,
|
|
170
|
+
review: entry.review,
|
|
171
|
+
retried: entry.retried,
|
|
172
|
+
aborted: interrupted && unfinished
|
|
173
|
+
};
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* One probe per target for a whole batch. Concurrent callers share the probe
|
|
178
|
+
* in flight and a success is kept, but a failure is forgotten so the retry of
|
|
179
|
+
* a task re-probes instead of inheriting the failure.
|
|
180
|
+
*/
|
|
181
|
+
export function memoizePreflight(probe) {
|
|
182
|
+
const cache = new Map();
|
|
183
|
+
return (target, budget) => {
|
|
184
|
+
const cached = cache.get(target);
|
|
185
|
+
if (cached !== undefined) {
|
|
186
|
+
return cached;
|
|
187
|
+
}
|
|
188
|
+
const pending = probe(target, budget);
|
|
189
|
+
cache.set(target, pending);
|
|
190
|
+
const forget = () => {
|
|
191
|
+
if (cache.get(target) === pending) {
|
|
192
|
+
cache.delete(target);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
pending.then((answer) => {
|
|
196
|
+
if ((typeof answer === "string" ? answer : answer.result) !== "ok") {
|
|
197
|
+
forget();
|
|
198
|
+
}
|
|
199
|
+
}, forget);
|
|
200
|
+
return pending;
|
|
201
|
+
};
|
|
202
|
+
}
|
|
@@ -161,11 +161,24 @@ export function buildTargetInvocation(request) {
|
|
|
161
161
|
};
|
|
162
162
|
}
|
|
163
163
|
/** A deep doctor probe uses stdin but keeps its simple text response contract. */
|
|
164
|
+
/**
|
|
165
|
+
* What the probe runs with where it differs from a review. The probe asks for
|
|
166
|
+
* one word, not a review, and agy's plan mode tells the model to research the
|
|
167
|
+
* task first: on a real project it explores, runs a command the headless user
|
|
168
|
+
* never allowed, is soft-denied, and prints nothing, which reads as
|
|
169
|
+
* `probe-failed`. In 40 replays, plan mode failed 4 of 20 and without it all 20
|
|
170
|
+
* answered in exactly two model calls. Only the probe drops it; a review keeps
|
|
171
|
+
* `READ_ONLY_ARGS`, and the sandbox stays on for both.
|
|
172
|
+
*/
|
|
173
|
+
const PROBE_ARGS = {
|
|
174
|
+
agy: ["--sandbox"]
|
|
175
|
+
};
|
|
164
176
|
export function buildProbeInvocation(request) {
|
|
165
|
-
const
|
|
166
|
-
if (
|
|
177
|
+
const eligible = READ_ONLY_ARGS[request.target];
|
|
178
|
+
if (eligible === undefined || eligible.length === 0) {
|
|
167
179
|
return undefined;
|
|
168
180
|
}
|
|
181
|
+
const readOnly = PROBE_ARGS[request.target] ?? eligible;
|
|
169
182
|
if (request.target === "codex") {
|
|
170
183
|
return {
|
|
171
184
|
command: "codex",
|
|
@@ -37,7 +37,20 @@ export function probeTimeoutMs(requested) {
|
|
|
37
37
|
export async function probeReviewTarget(target, options) {
|
|
38
38
|
return (await probeReviewTargetDetailed(target, options)).result;
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* A deep probe is one model call, and a model call can fail for reasons that
|
|
42
|
+
* say nothing about the install. `probe-failed` is the answer for "it did not
|
|
43
|
+
* work and did not say why", so it gets one more try; every other answer is
|
|
44
|
+
* specific (not logged in, no network, timed out, not installed) and repeating
|
|
45
|
+
* it would only spend more of the budget on the same result.
|
|
46
|
+
*/
|
|
40
47
|
export async function probeReviewTargetDetailed(target, options) {
|
|
48
|
+
const first = await probeOnce(target, options);
|
|
49
|
+
return options.deep === true && first.result === "probe-failed"
|
|
50
|
+
? await probeOnce(target, options)
|
|
51
|
+
: first;
|
|
52
|
+
}
|
|
53
|
+
async function probeOnce(target, options) {
|
|
41
54
|
const deep = options.deep === true;
|
|
42
55
|
const directory = deep
|
|
43
56
|
? await mkdtemp(join(tmpdir(), "agent-ops-review-probe-"))
|
package/docs/en/spec/review.md
CHANGED
|
@@ -125,3 +125,73 @@ FAIL.
|
|
|
125
125
|
- Evidence: The result reason distinguishes a protocol violation from a verdict.
|
|
126
126
|
- Positive: `NOT_RUN: unparseable-output; one criterion was missing.`
|
|
127
127
|
- Negative: `Record a failed review because the model's JSON was malformed.`
|
|
128
|
+
|
|
129
|
+
## REVIEW-BATCH-001
|
|
130
|
+
|
|
131
|
+
A batch review MUST cover exactly the active subtasks of the named parent and the parent itself.
|
|
132
|
+
|
|
133
|
+
- Trigger: Running `batch --parent <task-id>`.
|
|
134
|
+
- Action: Select the parent's active subtasks in creation order, then the parent. Leave out archived and completed tasks and tasks under another parent. Reject an unknown parent with `BATCH_PARENT_NOT_FOUND` and an inactive one with `BATCH_PARENT_NOT_ACTIVE`.
|
|
135
|
+
- Evidence: `data.tasks` lists exactly those task ids, in that order.
|
|
136
|
+
- Positive: `Two active subtasks and the parent are covered; the archived subtask is not.`
|
|
137
|
+
- Negative: `Cover every task in the worktree whatever its parent.`
|
|
138
|
+
|
|
139
|
+
## REVIEW-BATCH-002
|
|
140
|
+
|
|
141
|
+
Each task's base MUST come from an explicit flag, then its recorded review base, then the worktree base.
|
|
142
|
+
|
|
143
|
+
- Trigger: Resolving the range a batch task is verified and reviewed against.
|
|
144
|
+
- Action: Use `--base` for subtasks and `--parent-base` for the parent; otherwise the task's recorded `reviewBase`; otherwise the worktree base. Report `BATCH_BASE_UNKNOWN` when none exists.
|
|
145
|
+
- Evidence: `data.tasks[].base` names the base each task used.
|
|
146
|
+
- Positive: `A rerun reuses each PASSed task's recorded base, so its fingerprint and attestation still match.`
|
|
147
|
+
- Negative: `Apply --base to the parent, or fall back to HEAD when no base is known.`
|
|
148
|
+
|
|
149
|
+
## REVIEW-BATCH-003
|
|
150
|
+
|
|
151
|
+
Verification MUST run one task at a time, each task's review MUST start as soon as its own verification passes, and no more than `--width` reviews (default 2) MUST run at once.
|
|
152
|
+
|
|
153
|
+
- Trigger: Tasks in a batch need verification and review.
|
|
154
|
+
- Action: Skip verification for a task that already holds fresh PASS evidence, using the same check review preflight makes. Verify the rest serially. Start a task's review right after its verification passes. Give a task whose verification fails no review, and continue with the others.
|
|
155
|
+
- Evidence: `data.tasks[].verify` is `skipped`, `PASS` or `FAIL`, and a task that failed verification has no review.
|
|
156
|
+
- Positive: `Task B is verified while task A is being reviewed.`
|
|
157
|
+
- Negative: `Run every task's test suite at once.`
|
|
158
|
+
|
|
159
|
+
## REVIEW-BATCH-004
|
|
160
|
+
|
|
161
|
+
A transient NOT_RUN MUST lower the review width to 1 for the rest of the batch and earn exactly one retry, and every other result MUST be final.
|
|
162
|
+
|
|
163
|
+
- Trigger: A review in a batch returns NOT_RUN.
|
|
164
|
+
- Action: When the reason is `probe-failed`, `stalled`, `timeout`, `quota-exhausted` or `network-unreachable`, set the width to 1 for every review not yet started and retry that task once, alone, after the rest have finished. Never retry another reason, never retry twice, and never raise the width again.
|
|
165
|
+
- Evidence: `review.retried` is true for a retried task, and a second transient NOT_RUN stays NOT_RUN.
|
|
166
|
+
- Positive: `probe-failed on task A drops the width to 1, and A runs once more after the others.`
|
|
167
|
+
- Negative: `Retry host-sandboxed until it passes.`
|
|
168
|
+
|
|
169
|
+
## REVIEW-BATCH-005
|
|
170
|
+
|
|
171
|
+
A batch MUST stop when the source it started on changes or the process is interrupted.
|
|
172
|
+
|
|
173
|
+
- Trigger: HEAD or the working tree differs from the start of the batch, or the process receives SIGINT or SIGTERM.
|
|
174
|
+
- Action: Require a clean working tree before starting and check HEAD and the tree before every step. On a change, abort the reviews in flight, start nothing new, and report each unfinished task as `aborted`. A signal exits 130 or 143.
|
|
175
|
+
- Evidence: `data.tasks[].aborted` is true for every task that did not finish.
|
|
176
|
+
- Positive: `A commit during the batch aborts it and the unfinished tasks say aborted.`
|
|
177
|
+
- Negative: `Let in-flight reviews finish against a HEAD that no longer matches.`
|
|
178
|
+
|
|
179
|
+
## REVIEW-BATCH-006
|
|
180
|
+
|
|
181
|
+
A batch MUST probe each review target at most once while the probe succeeds and MUST NOT cache a failed probe.
|
|
182
|
+
|
|
183
|
+
- Trigger: Reviews in a batch need target preflight.
|
|
184
|
+
- Action: Share the probe in flight between concurrent reviews and keep a successful answer for the rest of the batch. Forget a failed answer, so the retry of a task probes again.
|
|
185
|
+
- Evidence: A batch of several reviews makes one probe per target, and a retry after a failure makes a new one.
|
|
186
|
+
- Positive: `Five reviews share one successful agy probe.`
|
|
187
|
+
- Negative: `Cache probe-failed and refuse every remaining task.`
|
|
188
|
+
|
|
189
|
+
## REVIEW-BATCH-007
|
|
190
|
+
|
|
191
|
+
A batch result MUST report every task's outcome and MUST exit 0 only when every task passed.
|
|
192
|
+
|
|
193
|
+
- Trigger: A batch ends.
|
|
194
|
+
- Action: Return `BATCH_RESULT`, `BATCH_FAILED` or `BATCH_NOT_RUN` with `data.tasks[]` holding each task's id, base, verify status, review status and reason, `reused`, `retried` and `aborted`, without the full reports, which stay under `.agent-ops/reviews/`. Exit 0 when every task passed, 1 when any review or verification failed, and 2 otherwise. `batch` is started like `review`: it needs `--yes` and trust, and REVIEW-HOST-001 applies to it.
|
|
195
|
+
- Evidence: The envelope code and the exit code agree with the per-task outcomes.
|
|
196
|
+
- Positive: `One FAIL and one NOT_RUN exit 1, so the defect is handled first.`
|
|
197
|
+
- Negative: `Exit 0 while a task is still NOT_RUN.`
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 審查
|
|
2
2
|
|
|
3
|
-
English source version: 2026-
|
|
3
|
+
English source version: 2026-09-30. Revalidate: when the English specification changes.
|
|
4
4
|
|
|
5
5
|
## REVIEW-INDEPENDENT-001
|
|
6
6
|
|
|
@@ -116,3 +116,73 @@ permission bypass。外部 host runner 不可用時,結果 MUST 保持 NOT_RUN
|
|
|
116
116
|
- Evidence: 結果的 reason 能區分協議違規與判定結果。
|
|
117
117
|
- Positive: `NOT_RUN:unparseable-output;缺少一條 criterion。`
|
|
118
118
|
- Negative: `因為模型的 JSON 格式錯誤就記錄一次失敗的審查。`
|
|
119
|
+
|
|
120
|
+
## REVIEW-BATCH-001
|
|
121
|
+
|
|
122
|
+
批次審查 MUST 只涵蓋指定 parent 的 active 子 task 以及 parent 本身。
|
|
123
|
+
|
|
124
|
+
- Trigger: 執行 `batch --parent <task-id>`。
|
|
125
|
+
- Action: 依建立順序選出 parent 的 active 子 task,再加上 parent。排除 archived、已完成的 task 與屬於其他 parent 的 task。未知 parent 以 `BATCH_PARENT_NOT_FOUND` 拒絕,非 active 的以 `BATCH_PARENT_NOT_ACTIVE` 拒絕。
|
|
126
|
+
- Evidence: `data.tasks` 依序列出的正是這些 task id。
|
|
127
|
+
- Positive: `兩個 active 子 task 與 parent 被涵蓋;archived 的子 task 不在內。`
|
|
128
|
+
- Negative: `不論 parent 為何,涵蓋 worktree 內所有 task。`
|
|
129
|
+
|
|
130
|
+
## REVIEW-BATCH-002
|
|
131
|
+
|
|
132
|
+
每個 task 的 base MUST 依序取自明確旗標、該 task 已記錄的 review base、worktree base。
|
|
133
|
+
|
|
134
|
+
- Trigger: 決定批次中某個 task 驗證與審查所用的範圍。
|
|
135
|
+
- Action: 子 task 用 `--base`,parent 用 `--parent-base`;否則用該 task 已記錄的 `reviewBase`;再否則用 worktree base。都沒有時回報 `BATCH_BASE_UNKNOWN`。
|
|
136
|
+
- Evidence: `data.tasks[].base` 指出每個 task 使用的 base。
|
|
137
|
+
- Positive: `重跑時沿用每個已 PASS task 記錄的 base,fingerprint 與 attestation 仍然吻合。`
|
|
138
|
+
- Negative: `把 --base 套到 parent,或在不知道 base 時退回 HEAD。`
|
|
139
|
+
|
|
140
|
+
## REVIEW-BATCH-003
|
|
141
|
+
|
|
142
|
+
驗證 MUST 一次只跑一個 task,每個 task 的審查 MUST 在它自己的驗證通過後立刻開始,同時進行的審查 MUST NOT 超過 `--width`(預設 2)。
|
|
143
|
+
|
|
144
|
+
- Trigger: 批次中的 task 需要驗證與審查。
|
|
145
|
+
- Action: 已有 fresh PASS evidence 的 task 略過驗證,判斷方式與 review 預檢相同。其餘串行驗證。task 驗證通過後立刻開始它的審查。驗證失敗的 task 不開審查,其他 task 繼續。
|
|
146
|
+
- Evidence: `data.tasks[].verify` 為 `skipped`、`PASS` 或 `FAIL`,驗證失敗的 task 沒有 review。
|
|
147
|
+
- Positive: `task A 審查的同時驗證 task B。`
|
|
148
|
+
- Negative: `同時跑每個 task 的完整測試。`
|
|
149
|
+
|
|
150
|
+
## REVIEW-BATCH-004
|
|
151
|
+
|
|
152
|
+
暫時性的 NOT_RUN MUST 讓其餘審查的 width 降為 1 並只重試一次,其他結果 MUST 為最終結果。
|
|
153
|
+
|
|
154
|
+
- Trigger: 批次中的某個審查回傳 NOT_RUN。
|
|
155
|
+
- Action: 原因為 `probe-failed`、`stalled`、`timeout`、`quota-exhausted` 或 `network-unreachable` 時,讓所有尚未開始的審查 width 降為 1,並在其餘審查結束後,單獨重試該 task 一次。其他原因不重試,不重試第二次,width 也不再升回。
|
|
156
|
+
- Evidence: 被重試的 task `review.retried` 為 true,第二次暫時性 NOT_RUN 仍是 NOT_RUN。
|
|
157
|
+
- Positive: `task A 的 probe-failed 使 width 降為 1,A 在其他 task 之後再跑一次。`
|
|
158
|
+
- Negative: `對 host-sandboxed 不斷重試直到通過。`
|
|
159
|
+
|
|
160
|
+
## REVIEW-BATCH-005
|
|
161
|
+
|
|
162
|
+
來源變動或程序被中斷時,批次 MUST 停止。
|
|
163
|
+
|
|
164
|
+
- Trigger: HEAD 或工作樹與批次開始時不同,或程序收到 SIGINT 或 SIGTERM。
|
|
165
|
+
- Action: 開始前要求工作樹乾淨,每個步驟前檢查 HEAD 與工作樹。發現變動時,中止進行中的審查,不再開始新步驟,並把每個未完成的 task 回報為 `aborted`。訊號以 130 或 143 結束。
|
|
166
|
+
- Evidence: 每個未完成的 task 其 `data.tasks[].aborted` 為 true。
|
|
167
|
+
- Positive: `批次進行中發生 commit 就中止,未完成的 task 標示 aborted。`
|
|
168
|
+
- Negative: `讓進行中的審查對已不吻合的 HEAD 跑完。`
|
|
169
|
+
|
|
170
|
+
## REVIEW-BATCH-006
|
|
171
|
+
|
|
172
|
+
批次 MUST 在探測成功時對每個審查 target 最多探測一次,且 MUST NOT 快取失敗的探測。
|
|
173
|
+
|
|
174
|
+
- Trigger: 批次中的審查需要 target 預檢。
|
|
175
|
+
- Action: 同時進行的審查共用進行中的探測,成功的答案在批次剩餘期間保留。失敗的答案丟棄,讓 task 的重試再探測一次。
|
|
176
|
+
- Evidence: 多個審查的批次每個 target 只探測一次,失敗後的重試會產生新的探測。
|
|
177
|
+
- Positive: `五個審查共用一次成功的 agy 探測。`
|
|
178
|
+
- Negative: `快取 probe-failed 並拒絕其餘所有 task。`
|
|
179
|
+
|
|
180
|
+
## REVIEW-BATCH-007
|
|
181
|
+
|
|
182
|
+
批次結果 MUST 回報每個 task 的結果,且 MUST 僅在每個 task 都通過時以 0 結束。
|
|
183
|
+
|
|
184
|
+
- Trigger: 批次結束。
|
|
185
|
+
- Action: 回傳 `BATCH_RESULT`、`BATCH_FAILED` 或 `BATCH_NOT_RUN`,`data.tasks[]` 含每個 task 的 id、base、驗證狀態、審查狀態與原因、`reused`、`retried` 與 `aborted`,不含完整報告(報告留在 `.agent-ops/reviews/`)。全數通過以 0 結束,任一審查或驗證失敗以 1 結束,其餘以 2 結束。`batch` 的啟動方式與 `review` 相同:需要 `--yes` 與 trust,且適用 REVIEW-HOST-001。
|
|
186
|
+
- Evidence: envelope code 與 exit code 與各 task 的結果一致。
|
|
187
|
+
- Positive: `一個 FAIL 與一個 NOT_RUN 以 1 結束,先處理缺陷。`
|
|
188
|
+
- Negative: `仍有 task 是 NOT_RUN 卻以 0 結束。`
|