@kylecheng3146/agent-ops 0.11.4 → 0.11.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/packages/cli/src/bin.js +6 -0
- package/dist/packages/cli/src/commands/advance.js +18 -0
- package/dist/packages/cli/src/commands/review.js +1 -0
- package/dist/packages/cli/src/commands/run.js +2 -0
- package/dist/packages/cli/src/context.js +2 -1
- package/dist/packages/cli/src/run-entry.js +66 -10
- package/dist/runtime/src/review/execute.js +92 -13
- package/dist/runtime/src/review/render.js +4 -0
- package/dist/runtime/src/review/runner.js +6 -1
- package/dist/runtime/src/run/proof-recovery.js +167 -0
- package/dist/runtime/src/run/service.js +9 -0
- package/dist/runtime/src/surface/discovery.js +18 -7
- package/docs/en/spec/acceptance-and-evidence.md +8 -3
- package/docs/en/spec/review.md +16 -1
- package/docs/zh-TW/spec/acceptance-and-evidence.md +6 -4
- package/docs/zh-TW/spec/review.md +12 -1
- package/package.json +1 -1
|
@@ -4,6 +4,7 @@ import { renderCriteriaReview } from "../../../runtime/src/review/criteria.js";
|
|
|
4
4
|
import { advancePhaseObserver, productionRunContext } from "./run-deps.js";
|
|
5
5
|
import { AgentOpsError } from "../../../runtime/src/fs/paths.js";
|
|
6
6
|
import { recordRunVerification } from "../../../runtime/src/run/verification.js";
|
|
7
|
+
import { runReviewCheckpoints } from "../../../runtime/src/run/proof-recovery.js";
|
|
7
8
|
import { runOwnedLocalProof } from "./owned-run-step.js";
|
|
8
9
|
import { runRunCommand } from "./commands/run.js";
|
|
9
10
|
import { runFollowupCommand } from "./commands/followup.js";
|
|
@@ -557,6 +558,10 @@ else {
|
|
|
557
558
|
// a `batch` are reviewed identically.
|
|
558
559
|
const buildReviewOptions = async (reviewArgs, signal, preflightTarget = probeTarget, progressPrefix = "") => {
|
|
559
560
|
const reviewSessionId = process.env.AGENT_OPS_SESSION_ID;
|
|
561
|
+
// Only the coordinator's registered run proof group, during a final proof, resumes saved rounds.
|
|
562
|
+
const run = await runPolicyContext(root).catch(() => null);
|
|
563
|
+
const checkpoints = run?.proof === true && run.worker?.workerId === run.state.coordinatorId &&
|
|
564
|
+
run.state.finalProof?.pending === true ? runReviewCheckpoints(run.repository, run.state) : undefined;
|
|
560
565
|
const reviewGit = gitRunner(root);
|
|
561
566
|
const reviewConfig = (await loadEffectiveConfig(root, reviewArgs.scope === "user" ? "user" : "project")).config;
|
|
562
567
|
const reviewRole = resolveReviewRole("independent-review", reviewConfig.reviewRoles ?? []);
|
|
@@ -625,6 +630,7 @@ else {
|
|
|
625
630
|
? {}
|
|
626
631
|
: { timeoutMs: reviewRole.timeoutMs }),
|
|
627
632
|
preflightTarget,
|
|
633
|
+
...(checkpoints === undefined ? {} : { checkpoints }),
|
|
628
634
|
verifySourceFingerprint: async (expected, scope) => {
|
|
629
635
|
const currentScope = await resolveReviewScope({
|
|
630
636
|
root,
|
|
@@ -8,6 +8,9 @@ import { readWorktreeRecord, resolveCheckouts, sessionWorktreeName, worktreePath
|
|
|
8
8
|
import { resolveReviewScope } from "../../../../runtime/src/review/scope.js";
|
|
9
9
|
import { renderReviewDetails, renderReviewHighlights } from "../../../../runtime/src/review/render.js";
|
|
10
10
|
import { calculateSourceFingerprint } from "../../../../runtime/src/verify/source-fingerprint.js";
|
|
11
|
+
import { FileEvidenceStore } from "../../../../runtime/src/verify/evidence.js";
|
|
12
|
+
import { parseArgs } from "../args.js";
|
|
13
|
+
import { hasFreshVerification } from "./review.js";
|
|
11
14
|
import { okEnvelope } from "../output.js";
|
|
12
15
|
import { readFinishedReview } from "./review-show.js";
|
|
13
16
|
import { criteriaProgress } from "../../../../runtime/src/run/phase.js";
|
|
@@ -50,6 +53,17 @@ function runner(deps, cwd) {
|
|
|
50
53
|
return { exitCode: result.exitCode, stdout: Buffer.from(result.stdout, "utf8") };
|
|
51
54
|
} };
|
|
52
55
|
}
|
|
56
|
+
/** Current passing evidence for this candidate, by the rule review's own preflight applies; anything unreadable re-verifies. */
|
|
57
|
+
async function freshlyVerified(deps, cwd, taskId, base) {
|
|
58
|
+
try {
|
|
59
|
+
return await hasFreshVerification({ args: parseArgs(["review", "--task", taskId, "--base", base, "--yes"]), authorized: true,
|
|
60
|
+
tasks: deps.tasks(cwd), root: cwd, gitRunner: runner(deps, cwd), config: await deps.loadConfig(cwd),
|
|
61
|
+
evidenceStore: new FileEvidenceStore(cwd, cwd) }, taskId, base);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
53
67
|
async function git(deps, cwd, args) {
|
|
54
68
|
const result = await deps.git(cwd, args);
|
|
55
69
|
if (result.exitCode !== 0)
|
|
@@ -138,6 +152,10 @@ export async function runAdvanceCommand(options) {
|
|
|
138
152
|
const onPhase = options.onPhase ?? (async () => { });
|
|
139
153
|
await onPhase("verifying");
|
|
140
154
|
for (const task of ordered) {
|
|
155
|
+
if (await freshlyVerified(deps, record.path, task.task.id, target)) {
|
|
156
|
+
await onPhase("verifying", { taskId: task.task.id, update: { verify: "PASS" } });
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
141
159
|
const verified = await step(record.path, ["verify", "--task", task.task.id, "--base", target]);
|
|
142
160
|
await onPhase("verifying", { taskId: task.task.id, update: { verify: verified.status === "ok" ? "PASS" : "FAIL",
|
|
143
161
|
...criteriaProgress(task.task.criteria, verified.data?.report ?? {}) } });
|
|
@@ -317,6 +317,7 @@ const SURFACE_REASONS = {
|
|
|
317
317
|
SURFACE_UNCLASSIFIED: "surface-unclassified",
|
|
318
318
|
SURFACE_QUERY_TOO_BROAD: "surface-query-too-broad",
|
|
319
319
|
SURFACE_EXCLUSION_TOO_BROAD: "surface-exclusion-too-broad",
|
|
320
|
+
SURFACE_INCLUSION_TOO_BROAD: "surface-inclusion-too-broad",
|
|
320
321
|
SCENARIO_MISSING: "scenario-missing",
|
|
321
322
|
SURFACE_BUDGET_EXCEEDED: "surface-budget-exceeded",
|
|
322
323
|
SURFACE_UNASSIGNED: "surface-unassigned",
|
|
@@ -29,6 +29,8 @@ function textFor(action, state, message) {
|
|
|
29
29
|
`Cause: ${recovery.cause === null ? "none recorded" : recovery.cause.code + ": " + recovery.cause.detail}`,
|
|
30
30
|
`Sealed proof: ${recovery.sealedProof}; cleanup: ${recovery.cleanup}; resumable: ${recovery.resumable ? "yes, after reconciliation" : "no"}`,
|
|
31
31
|
`Remediation: ${recovery.remediation}`,
|
|
32
|
+
state.finalProof == null ? "" : `Final proof recovery: ${state.finalProof.recoveries}/1 used; first failure ${state.finalProof.failures[0]?.code ?? "none"}; ` +
|
|
33
|
+
`last failure ${state.finalProof.failures.at(-1)?.code ?? "none"}; automatic recovery ${state.finalProof.stopped ? "stopped" : state.finalProof.pending ? "in progress" : "available"}`,
|
|
32
34
|
`Host: ${state.host}`,
|
|
33
35
|
`Workers: ${active}/${state.jobs}`,
|
|
34
36
|
state.budget.limitMs === null ? `Budget: unlimited; ${state.budget.accumulatedMs}ms active time recorded` : `Budget: ${Math.max(0, state.budget.limitMs - state.budget.accumulatedMs)}ms remaining (legacy; manual resume upgrades)`,
|
|
@@ -263,7 +263,8 @@ export async function runPolicyContext(root, renew = false) {
|
|
|
263
263
|
const policy = await readRunPolicy(state, await runRuntimeHash());
|
|
264
264
|
if (policy === null)
|
|
265
265
|
return null;
|
|
266
|
-
|
|
266
|
+
// `proof`: this process runs inside the supervisor's registered run-level proof group.
|
|
267
|
+
return { repository, state, policy, worker, proof: globalProof };
|
|
267
268
|
}
|
|
268
269
|
/** Only active execution paths call this; status, installation and recovery reads never renew. */
|
|
269
270
|
export async function ensureRunPolicy(repository, state) {
|
|
@@ -8,6 +8,7 @@ import { resolveReviewScope } from "../../../runtime/src/review/scope.js";
|
|
|
8
8
|
import { writeNoChangeDelivery, integrateSessionChildren } from "../../../runtime/src/parallel/integrate.js";
|
|
9
9
|
import { recordNativeRunUsage, recordReviewRunUsage, recordSavedReviewRunUsage } from "../../../runtime/src/run/usage.js";
|
|
10
10
|
import { recordNativeRunAuthorization } from "../../../runtime/src/run/authorization.js";
|
|
11
|
+
import { discardReviewCheckpoints, finalProofAttemptKey, InterruptedStepError, processGroupAlive, runFinalProof } from "../../../runtime/src/run/proof-recovery.js";
|
|
11
12
|
import { observeRunFailure } from "../../../runtime/src/run/verification.js";
|
|
12
13
|
import { criteriaProgress, recordRunPhase } from "../../../runtime/src/run/phase.js";
|
|
13
14
|
import { compareFailureObservations } from "../../../runtime/src/run/convergence.js";
|
|
@@ -435,6 +436,27 @@ export async function superviseNativeRun(root, runId) {
|
|
|
435
436
|
async function advance(saved) {
|
|
436
437
|
return await step(root, ["task", "advance", "--task", saved.rootTaskId, "--session", saved.ownerSessionId, "--yes"], saved);
|
|
437
438
|
}
|
|
439
|
+
async function attemptKey(saved) {
|
|
440
|
+
const coordinator = saved.workers.find(w => w.workerId === saved.coordinatorId);
|
|
441
|
+
if (coordinator?.worktree == null)
|
|
442
|
+
throw new AgentOpsError("RUN_WORKTREE_MISSING", "Final proof requires the coordinator checkout.");
|
|
443
|
+
return finalProofAttemptKey({ candidate: (await deps.git(coordinator.worktree, ["rev-parse", "HEAD"])).stdout.trim(),
|
|
444
|
+
target: (await deps.git(root, ["rev-parse", saved.targetBranch + "^{commit}"])).stdout.trim(),
|
|
445
|
+
contractHash: saved.currentContractHash, configHash: saved.policyBinding?.configHash ?? null,
|
|
446
|
+
runtimeHash: saved.policyBinding?.runtimeHash ?? null });
|
|
447
|
+
}
|
|
448
|
+
async function finalProof(restartedKey) {
|
|
449
|
+
return await runFinalProof(repository, runId, {
|
|
450
|
+
advance: async () => await advance((await repository.read(runId))),
|
|
451
|
+
attemptKey: async () => await attemptKey((await repository.read(runId))),
|
|
452
|
+
untouched: async () => {
|
|
453
|
+
const latest = (await repository.read(runId));
|
|
454
|
+
const coordinator = latest.workers.find(w => w.workerId === latest.coordinatorId);
|
|
455
|
+
return coordinator?.worktree != null && latest.integration === null &&
|
|
456
|
+
(await collectChangeSurface(gitRunner(coordinator.worktree))).paths.length === 0;
|
|
457
|
+
}
|
|
458
|
+
}, restartedKey);
|
|
459
|
+
}
|
|
438
460
|
async function recoverIntegration(saved) {
|
|
439
461
|
const coordinator = saved.workers.find(worker => worker.workerId === saved.coordinatorId);
|
|
440
462
|
if (coordinator?.worktree == null)
|
|
@@ -662,7 +684,9 @@ export async function superviseNativeRun(root, runId) {
|
|
|
662
684
|
await recordReviewRunUsage(repository, runId, review.report.value);
|
|
663
685
|
const finalContract = treeContractHash(receipt.tasks.map(record => record.task));
|
|
664
686
|
await repository.mutate(runId, current => ({ ...current, currentContractHash: finalContract,
|
|
665
|
-
contractRevision: current.contractRevision + (current.currentContractHash === finalContract ? 0 : 1)
|
|
687
|
+
contractRevision: current.contractRevision + (current.currentContractHash === finalContract ? 0 : 1),
|
|
688
|
+
...(current.finalProof == null ? {} : { finalProof: { ...current.finalProof, pending: false, checkpoints: undefined } }) }));
|
|
689
|
+
await discardReviewCheckpoints(current).catch(() => { });
|
|
666
690
|
await supervisor.finalize(runId, { targetCommit: receipt.candidateHead, candidateCommit: receipt.candidateHead,
|
|
667
691
|
verificationPass: true, reviewPass: true, taskStateComplete: true, receiptWritten: true, receiptDigest: sha256(source) });
|
|
668
692
|
try {
|
|
@@ -726,6 +750,32 @@ export async function superviseNativeRun(root, runId) {
|
|
|
726
750
|
else if (state.policyBinding !== undefined)
|
|
727
751
|
await synchronizePolicy();
|
|
728
752
|
state = (await repository.read(runId));
|
|
753
|
+
// A final proof the previous supervisor left pending resumes before any writer starts.
|
|
754
|
+
let proofNote;
|
|
755
|
+
if (state.finalProof?.pending === true && state.rootTaskId !== null) {
|
|
756
|
+
try {
|
|
757
|
+
const result = await finalProof(state.finalProof.attemptKey);
|
|
758
|
+
if (result.status !== "ok")
|
|
759
|
+
throw runStepError(result, "Final proof failed after a supervisor restart");
|
|
760
|
+
if (!plain(result.data) || typeof result.data.receipt !== "string")
|
|
761
|
+
throw new AgentOpsError("RUN_RECEIPT_REQUIRED", "Finalization requires the receipt returned by the existing finish gate.");
|
|
762
|
+
await acceptReceipt(state, result.data.receipt);
|
|
763
|
+
await writePrivateFile(join(directory, runId, "final-response.json"), JSON.stringify({ code: "RUN_COMPLETE", receipt: result.data.receipt }), commonDir);
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
catch (cause) {
|
|
767
|
+
// A sealed transaction keeps its manual reconciliation path.
|
|
768
|
+
if ((await repository.read(runId)).integration !== null)
|
|
769
|
+
throw cause;
|
|
770
|
+
const coordinator = state.workers.find(w => w.workerId === state.coordinatorId);
|
|
771
|
+
await writePrivateFile(join(coordinator.worktree, ".agent-ops", "tasks", "run-last-failure.json"), JSON.stringify({
|
|
772
|
+
code: cause instanceof AgentOpsError ? cause.code : "RUN_PROOF_FAILED",
|
|
773
|
+
error: cause instanceof AgentOpsError ? cause.message : "Final proof failed; inspect local diagnostics."
|
|
774
|
+
}), coordinator.worktree);
|
|
775
|
+
proofNote = "Final proof did not complete after a supervisor restart. Read run-last-failure.json and saved review/verification reports, fix substantive findings, then request a new evidence gate.";
|
|
776
|
+
}
|
|
777
|
+
state = (await repository.read(runId));
|
|
778
|
+
}
|
|
729
779
|
for (const worker of state.workers.filter(w => active(w) || w.status === "stopped")) {
|
|
730
780
|
if (recoverySkips(state, worker))
|
|
731
781
|
continue;
|
|
@@ -733,7 +783,8 @@ export async function superviseNativeRun(root, runId) {
|
|
|
733
783
|
!(await deps.tasks(worker.worktree).list()).some(r => r.task.id === worker.taskId)))
|
|
734
784
|
continue;
|
|
735
785
|
try {
|
|
736
|
-
await start(worker, worker.workerId === state.coordinatorId
|
|
786
|
+
await start(worker, worker.workerId === state.coordinatorId
|
|
787
|
+
? proofNote ?? (yielded(worker) ? coordinatorWakeNote(state, worker) : undefined) : undefined, manualResumeAt);
|
|
737
788
|
if (state.jobs === 1)
|
|
738
789
|
break;
|
|
739
790
|
}
|
|
@@ -964,7 +1015,7 @@ export async function superviseNativeRun(root, runId) {
|
|
|
964
1015
|
}
|
|
965
1016
|
await stopAll("stop", false);
|
|
966
1017
|
await recordRunPhase(repository, runId, worker.workerId, "verifying");
|
|
967
|
-
const result = await
|
|
1018
|
+
const result = await finalProof();
|
|
968
1019
|
if (result.status !== "ok")
|
|
969
1020
|
throw runStepError(result, "Final proof failed; inspect saved verifier and fresh review reports before repair");
|
|
970
1021
|
const data = result.data;
|
|
@@ -1066,7 +1117,10 @@ export async function runControlledStep(repository, cli, cwd, args, saved) {
|
|
|
1066
1117
|
catch { }
|
|
1067
1118
|
}, 3000);
|
|
1068
1119
|
};
|
|
1069
|
-
|
|
1120
|
+
// Why the supervisor itself ended the step; any such end is never replayed automatically.
|
|
1121
|
+
let ended;
|
|
1122
|
+
const end = (why) => { ended ??= why; terminate(); };
|
|
1123
|
+
const timeout = Number.isFinite(remaining) ? setTimeout(() => end("deadline"), remaining) : undefined;
|
|
1070
1124
|
const registration = (async () => {
|
|
1071
1125
|
const identity = child.pid === undefined ? null : await nativeProcessIdentity(child.pid);
|
|
1072
1126
|
if (identity === null || closed) {
|
|
@@ -1095,8 +1149,8 @@ export async function runControlledStep(repository, cli, cwd, args, saved) {
|
|
|
1095
1149
|
checking = true;
|
|
1096
1150
|
void repository.read(runId).then(current => {
|
|
1097
1151
|
if (current === null || current.disableRestart || current.status !== "active")
|
|
1098
|
-
|
|
1099
|
-
}).catch(
|
|
1152
|
+
end("stop");
|
|
1153
|
+
}).catch(() => end("state")).finally(() => { checking = false; });
|
|
1100
1154
|
}, 250);
|
|
1101
1155
|
child.stdout.on("data", (chunk) => {
|
|
1102
1156
|
const room = 4 * 1024 * 1024 - output.length;
|
|
@@ -1104,7 +1158,7 @@ export async function runControlledStep(repository, cli, cwd, args, saved) {
|
|
|
1104
1158
|
output = Buffer.concat([output, chunk.subarray(0, room)]);
|
|
1105
1159
|
if (chunk.length > room) {
|
|
1106
1160
|
outputOverflow = true;
|
|
1107
|
-
|
|
1161
|
+
end("overflow");
|
|
1108
1162
|
}
|
|
1109
1163
|
});
|
|
1110
1164
|
child.stderr.on("data", (chunk) => {
|
|
@@ -1112,7 +1166,7 @@ export async function runControlledStep(repository, cli, cwd, args, saved) {
|
|
|
1112
1166
|
if (room > 0)
|
|
1113
1167
|
diagnostic = Buffer.concat([diagnostic, chunk.subarray(0, room)]);
|
|
1114
1168
|
});
|
|
1115
|
-
child.once("error", cause => { spawnError = cause;
|
|
1169
|
+
child.once("error", cause => { spawnError = cause; end("spawn"); });
|
|
1116
1170
|
child.once("close", async (exitCode, signal) => {
|
|
1117
1171
|
closed = true;
|
|
1118
1172
|
clearTimeout(timeout);
|
|
@@ -1160,12 +1214,14 @@ export async function runControlledStep(repository, cli, cwd, args, saved) {
|
|
|
1160
1214
|
catch (cause) {
|
|
1161
1215
|
const excerpt = (value) => redactSecrets(value)
|
|
1162
1216
|
.replace(/-----BEGIN (?:[A-Z0-9]+ )?PRIVATE KEY-----[\s\S]*$/u, "[REDACTED_PRIVATE_KEY]").slice(0, 3500);
|
|
1163
|
-
const
|
|
1217
|
+
const replayable = ["process", "result"].includes(stage) && ended === undefined && spawnError === undefined &&
|
|
1218
|
+
child.pid !== undefined && !processGroupAlive(child.pid);
|
|
1219
|
+
const detail = JSON.stringify({ exitCode, signal, stage, outputOverflow, ended: ended ?? null, replayable,
|
|
1164
1220
|
cause: excerpt(cause instanceof Error ? cause.message : String(cause)),
|
|
1165
1221
|
stderr: excerpt(diagnostic.toString("utf8")), stdout: excerpt(output.toString("utf8").slice(0, 4096)) }).slice(0, 9500);
|
|
1166
1222
|
await repository.appendEvent(runId, { type: "diagnostic", code: "RUN_CONTROLLED_STEP_FAILED",
|
|
1167
1223
|
workerId: env.AGENT_OPS_WORKER_ID ?? null, taskId: saved.workers.find(w => w.worktree === cwd)?.taskId ?? null, detail }).catch(() => undefined);
|
|
1168
|
-
reject(new
|
|
1224
|
+
reject(new InterruptedStepError("Final gate did not return a complete result; inspect target and receipt before retrying. " + detail, replayable));
|
|
1169
1225
|
}
|
|
1170
1226
|
});
|
|
1171
1227
|
});
|
|
@@ -10,7 +10,7 @@ import { reviewReportResults, reviewReportStatus, validateReviewReport } from ".
|
|
|
10
10
|
import { detectHostTarget, planReviewTargets } from "./roles.js";
|
|
11
11
|
import { detectHostRestriction, isLoginFailure, isNetworkFailure } from "./host-sandbox.js";
|
|
12
12
|
import { buildAdversarialPrompt, buildReviewPrompt } from "./runner.js";
|
|
13
|
-
import { MAX_ADVERSARIAL_PROMPT_BYTES } from "./runner.js";
|
|
13
|
+
import { MAX_ADVERSARIAL_PROMPT_BYTES, redactReviewReport } from "./runner.js";
|
|
14
14
|
import { extractUsage, sumUsage } from "./usage.js";
|
|
15
15
|
/**
|
|
16
16
|
* Full repository reviews need far more headroom than the lightweight auth
|
|
@@ -754,7 +754,7 @@ async function attemptTarget(request, options) {
|
|
|
754
754
|
*/
|
|
755
755
|
async function attemptTargetWithRetry(request, options) {
|
|
756
756
|
const first = await attemptTarget(request, options);
|
|
757
|
-
if (first.kind !== "skip" || first.transient !== true || budgetSpent(options)) {
|
|
757
|
+
if (first.kind !== "skip" || first.transient !== true || budgetSpent(options) || options.checkpoints !== undefined) {
|
|
758
758
|
return first;
|
|
759
759
|
}
|
|
760
760
|
options.onProgress?.(`${request.target}: ${first.diagnostic} → retrying once`);
|
|
@@ -817,9 +817,59 @@ export function createReviewExecutor(options) {
|
|
|
817
817
|
attempts: []
|
|
818
818
|
};
|
|
819
819
|
}
|
|
820
|
+
const expectedCriterionIds = request.invocation.packet.criteria.map((criterion) => criterion.id);
|
|
821
|
+
const primaryTarget = plannedTargets[0];
|
|
822
|
+
const adversarialTarget = plannedTargets[1];
|
|
823
|
+
const primaryPrompt = buildReviewPrompt({ ...request.invocation, harness: primaryTarget });
|
|
824
|
+
const binding = sha256(JSON.stringify({ primaryPrompt, targets: [primaryTarget, adversarialTarget],
|
|
825
|
+
model: options.model ?? null, effort: options.effort ?? null }));
|
|
826
|
+
const journal = options.checkpoints;
|
|
827
|
+
let saved = null;
|
|
828
|
+
if (journal !== undefined) {
|
|
829
|
+
const valid = (round, target) => round !== undefined && round.target === target && typeof round.sessionId === "string" && round.sessionId.length > 0 &&
|
|
830
|
+
validateReviewReport(round.report, expectedCriterionIds, request.invocation.scope?.changedFiles).ok;
|
|
831
|
+
try {
|
|
832
|
+
saved = await journal.load(binding);
|
|
833
|
+
}
|
|
834
|
+
catch {
|
|
835
|
+
report("checkpoint: the saved review checkpoint is unreadable or altered; stopping review");
|
|
836
|
+
return { status: "NOT_RUN", reason: "checkpoint-invalid", attempts: [] };
|
|
837
|
+
}
|
|
838
|
+
if (saved !== null && (saved.binding !== binding || !valid(saved.primary, primaryTarget) ||
|
|
839
|
+
reviewReportStatus(saved.primary.report) !== "PASS" ||
|
|
840
|
+
(saved.adversarial !== undefined && !valid(saved.adversarial, adversarialTarget)))) {
|
|
841
|
+
report("checkpoint: the saved review checkpoint does not match this review; stopping review");
|
|
842
|
+
return { status: "NOT_RUN", reason: "checkpoint-invalid", attempts: [] };
|
|
843
|
+
}
|
|
844
|
+
if (saved?.adversarial !== undefined && reviewReportStatus(saved.adversarial.report) === "FAIL")
|
|
845
|
+
saved = null;
|
|
846
|
+
if (saved?.adversarial !== undefined) {
|
|
847
|
+
report("checkpoint: both rounds already answered for this exact review; no reviewer is called");
|
|
848
|
+
const { primary, adversarial } = saved;
|
|
849
|
+
return {
|
|
850
|
+
status: "PASS",
|
|
851
|
+
results: reviewReportResults(primary.report),
|
|
852
|
+
report: primary.report,
|
|
853
|
+
harness: primaryTarget,
|
|
854
|
+
attempts: [
|
|
855
|
+
{ target: primaryTarget, status: "PASS", sessionId: primary.sessionId, metrics: primary.metrics },
|
|
856
|
+
{ target: adversarialTarget, status: "PASS", sessionId: adversarial.sessionId, metrics: adversarial.metrics }
|
|
857
|
+
],
|
|
858
|
+
preflight: [],
|
|
859
|
+
independence: primaryTarget === adversarialTarget ? "same-target" : "different-target",
|
|
860
|
+
sessionIsolation: "fresh",
|
|
861
|
+
adversarial: { target: adversarialTarget, refuted: false, report: adversarial.report }
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
const signalTransient = async (outcome) => {
|
|
866
|
+
if (outcome.kind === "skip" && outcome.transient === true)
|
|
867
|
+
await journal?.transient().catch(() => { });
|
|
868
|
+
};
|
|
820
869
|
const preflight = [];
|
|
821
870
|
if (options.preflightTarget !== undefined) {
|
|
822
|
-
|
|
871
|
+
// A resumed primary needs no probe: its round is not run again.
|
|
872
|
+
for (const target of new Set(saved === null ? plannedTargets : [adversarialTarget])) {
|
|
823
873
|
if (outOfTime()) {
|
|
824
874
|
const diagnostic = "the review deadline was reached during target " +
|
|
825
875
|
"preflight";
|
|
@@ -859,7 +909,6 @@ export function createReviewExecutor(options) {
|
|
|
859
909
|
};
|
|
860
910
|
}
|
|
861
911
|
}
|
|
862
|
-
const expectedCriterionIds = request.invocation.packet.criteria.map((criterion) => criterion.id);
|
|
863
912
|
const repositoryRoot = await realpath(options.cwd);
|
|
864
913
|
const shared = {
|
|
865
914
|
repositoryRoot,
|
|
@@ -874,20 +923,26 @@ export function createReviewExecutor(options) {
|
|
|
874
923
|
: {})
|
|
875
924
|
};
|
|
876
925
|
const attempts = [];
|
|
877
|
-
const primaryTarget = plannedTargets[0];
|
|
878
|
-
const adversarialTarget = plannedTargets[1];
|
|
879
926
|
if (outOfTime()) {
|
|
880
927
|
report("chain: review deadline reached before the first round");
|
|
881
928
|
return { status: "NOT_RUN", reason: "timeout", attempts: [] };
|
|
882
929
|
}
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
930
|
+
let primaryOutcome;
|
|
931
|
+
if (saved !== null) {
|
|
932
|
+
report(`${primaryTarget}: necessary review already passed for this exact review; resuming at the adversarial round`);
|
|
933
|
+
primaryOutcome = { kind: "verdict", report: saved.primary.report, sessionId: saved.primary.sessionId, metrics: saved.primary.metrics };
|
|
934
|
+
}
|
|
935
|
+
else {
|
|
936
|
+
announceRound(options, 1, primaryTarget, plannedTargets);
|
|
937
|
+
primaryOutcome = await attemptTargetWithRetry({
|
|
938
|
+
...shared,
|
|
939
|
+
target: primaryTarget,
|
|
940
|
+
label: `${primaryTarget}-primary`,
|
|
941
|
+
prompt: primaryPrompt
|
|
942
|
+
}, staged());
|
|
943
|
+
}
|
|
890
944
|
if (primaryOutcome.kind === "skip") {
|
|
945
|
+
await signalTransient(primaryOutcome);
|
|
891
946
|
attempts.push({
|
|
892
947
|
target: primaryTarget,
|
|
893
948
|
status: "NOT_RUN",
|
|
@@ -949,6 +1004,17 @@ export function createReviewExecutor(options) {
|
|
|
949
1004
|
};
|
|
950
1005
|
}
|
|
951
1006
|
}
|
|
1007
|
+
if (journal !== undefined && saved === null) {
|
|
1008
|
+
try {
|
|
1009
|
+
await journal.save({ binding, primary: { target: primaryTarget, sessionId: primaryOutcome.sessionId,
|
|
1010
|
+
report: redactReviewReport(primaryReport), metrics: primaryOutcome.metrics } });
|
|
1011
|
+
}
|
|
1012
|
+
catch {
|
|
1013
|
+
report("checkpoint: the necessary review PASS could not be saved; stopping before the adversarial round");
|
|
1014
|
+
return { status: "NOT_RUN", reason: "evidence-write-failed", report: primaryReport, preflight,
|
|
1015
|
+
independence, sessionIsolation: "fresh", attempts };
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
952
1018
|
const adversarialPrompt = buildAdversarialPrompt({ ...request.invocation, harness: adversarialTarget }, primaryReport);
|
|
953
1019
|
if (Buffer.byteLength(adversarialPrompt, "utf8") > MAX_ADVERSARIAL_PROMPT_BYTES) {
|
|
954
1020
|
const diagnostic = `the redacted primary report exceeds ${MAX_ADVERSARIAL_PROMPT_BYTES} bytes`;
|
|
@@ -998,6 +1064,7 @@ export function createReviewExecutor(options) {
|
|
|
998
1064
|
prompt: adversarialPrompt
|
|
999
1065
|
}, staged());
|
|
1000
1066
|
if (adversarialOutcome.kind === "skip") {
|
|
1067
|
+
await signalTransient(adversarialOutcome);
|
|
1001
1068
|
attempts.push({
|
|
1002
1069
|
target: adversarialTarget,
|
|
1003
1070
|
status: "NOT_RUN",
|
|
@@ -1039,6 +1106,18 @@ export function createReviewExecutor(options) {
|
|
|
1039
1106
|
attempts
|
|
1040
1107
|
};
|
|
1041
1108
|
}
|
|
1109
|
+
if (journal !== undefined) {
|
|
1110
|
+
try {
|
|
1111
|
+
await journal.save({ binding, primary: { target: primaryTarget, sessionId: primaryOutcome.sessionId,
|
|
1112
|
+
report: redactReviewReport(primaryReport), metrics: primaryOutcome.metrics }, adversarial: { target: adversarialTarget,
|
|
1113
|
+
sessionId: adversarialOutcome.sessionId, report: redactReviewReport(adversarialReport), metrics: adversarialOutcome.metrics } });
|
|
1114
|
+
}
|
|
1115
|
+
catch {
|
|
1116
|
+
report("checkpoint: the answered pair could not be saved; its verdict is not accepted");
|
|
1117
|
+
return { status: "NOT_RUN", reason: "evidence-write-failed", report: primaryReport, preflight,
|
|
1118
|
+
independence, sessionIsolation: "fresh", attempts };
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1042
1121
|
report(`${adversarialTarget}: adversarial re-check ${refuted ? "refuted the PASS" : "upheld the PASS"}`);
|
|
1043
1122
|
return {
|
|
1044
1123
|
status: refuted ? "FAIL" : "PASS",
|
|
@@ -121,6 +121,10 @@ export function renderReviewResult(result) {
|
|
|
121
121
|
lines.push("Three configured targets require AGENT_OPS_HOST=agy|claude|codex; " +
|
|
122
122
|
"the host identity was not guessed.");
|
|
123
123
|
}
|
|
124
|
+
if (result.reason === "checkpoint-invalid") {
|
|
125
|
+
lines.push("The saved final-proof review checkpoint is unreadable or does not match " +
|
|
126
|
+
"this review, so no round was resumed or repeated automatically.");
|
|
127
|
+
}
|
|
124
128
|
if (result.reason === "evidence-write-failed") {
|
|
125
129
|
lines.push("Reviewers may have completed, but the required evidence could not be " +
|
|
126
130
|
"written; the completion gate remains blocked.");
|
|
@@ -108,6 +108,8 @@ function taskDataBlock(invocation) {
|
|
|
108
108
|
function surfaceBlock(invocation, round) {
|
|
109
109
|
if (invocation.packet.surface === undefined)
|
|
110
110
|
return [];
|
|
111
|
+
const discovery = invocation.packet.surface.discovery;
|
|
112
|
+
const skipped = round === "adversarial" ? discovery?.skipped ?? [] : [];
|
|
111
113
|
return [
|
|
112
114
|
"",
|
|
113
115
|
"Surface coverage (narrow exception to \"Do not survey\"): task data carries the surface audit, its revision " +
|
|
@@ -121,7 +123,10 @@ function surfaceBlock(invocation, round) {
|
|
|
121
123
|
"and indirect consumer. Do not run other searches beyond callers a specific question needs."
|
|
122
124
|
: "Round 2: check the five fixed scenarios (initialState, redo, timeBoundary, identitySwitch, concurrency) against " +
|
|
123
125
|
"the code, and hunt unlisted surfaces with at most 3 new `git grep -F` queries per axis (dependents, duplicates, " +
|
|
124
|
-
"otherPaths, sharedState); cite each query you ran in evidence."
|
|
126
|
+
"otherPaths, sharedState); cite each query you ran in evidence.",
|
|
127
|
+
...(skipped.length === 0 ? [] : ["Skipped derived queries (over 10 files outside the diff, so discovery never gated them; " +
|
|
128
|
+
`fewest first): ${skipped.map(row => `\`${row.query}\` (${row.files})`).join(", ")}. Beyond the per-axis budget, run at most 3 ` +
|
|
129
|
+
"extra `git grep -F` queries in total over these to find copied siblings the change missed; cite each in evidence."])
|
|
125
130
|
];
|
|
126
131
|
}
|
|
127
132
|
/** Escape Loop: both rounds adjudicate the earliest-gate label of each declared escape. */
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { rm } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { sha256 } from "../fs/hash.js";
|
|
5
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
6
|
+
import { readPrivateFile, writePrivateFile } from "../security/permissions.js";
|
|
7
|
+
const MAX_CHECKPOINT_BYTES = 4 * 1024 * 1024;
|
|
8
|
+
const BINDING = /^[a-f0-9]{64}$/u;
|
|
9
|
+
/** Matches the run state validator; the oldest record is dropped first. */
|
|
10
|
+
const MAX_CHECKPOINTS = 64;
|
|
11
|
+
/** One immutable final attempt: a new candidate, target, contract or policy starts a new allowance. */
|
|
12
|
+
export function finalProofAttemptKey(parts) {
|
|
13
|
+
return sha256(JSON.stringify([parts.candidate, parts.target, parts.contractHash, parts.configHash, parts.runtimeHash]));
|
|
14
|
+
}
|
|
15
|
+
function checkpointDirectory(state) {
|
|
16
|
+
return join(state.commonDir, "agent-ops", "runs", state.runId, "final-review");
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Saved rounds live beside the run state, and each file is trusted only
|
|
20
|
+
* through the digest the proof recorded in the locked run state, like run
|
|
21
|
+
* answers and plans: a file nobody recorded is ignored, so a writer that can
|
|
22
|
+
* reach the directory cannot plant a PASS. `transient` marks the same record.
|
|
23
|
+
*/
|
|
24
|
+
export function runReviewCheckpoints(repository, state) {
|
|
25
|
+
const path = (binding) => {
|
|
26
|
+
if (!BINDING.test(binding))
|
|
27
|
+
throw new AgentOpsError("RUN_REVIEW_CHECKPOINT_INVALID", "Checkpoint binding must be a sha256 digest.");
|
|
28
|
+
return join(checkpointDirectory(state), binding + ".json");
|
|
29
|
+
};
|
|
30
|
+
return {
|
|
31
|
+
async load(binding) {
|
|
32
|
+
const file = path(binding);
|
|
33
|
+
const recorded = (await repository.read(state.runId))?.finalProof?.checkpoints?.[binding];
|
|
34
|
+
if (recorded === undefined)
|
|
35
|
+
return null;
|
|
36
|
+
const source = await readPrivateFile(file, state.commonDir);
|
|
37
|
+
const saved = source === null || Buffer.byteLength(source) > MAX_CHECKPOINT_BYTES || sha256(source) !== recorded ? null
|
|
38
|
+
: JSON.parse(source);
|
|
39
|
+
if (saved === null || typeof saved !== "object" || saved.schemaVersion !== 1 ||
|
|
40
|
+
typeof saved.checkpoint !== "object" || saved.checkpoint === null)
|
|
41
|
+
throw new AgentOpsError("RUN_REVIEW_CHECKPOINT_INVALID", "Saved review checkpoint is missing, altered or does not match its run state record.");
|
|
42
|
+
return saved.checkpoint;
|
|
43
|
+
},
|
|
44
|
+
async save(checkpoint) {
|
|
45
|
+
const file = path(checkpoint.binding);
|
|
46
|
+
const content = JSON.stringify({ schemaVersion: 1, checkpoint });
|
|
47
|
+
if (Buffer.byteLength(content) > MAX_CHECKPOINT_BYTES)
|
|
48
|
+
throw new AgentOpsError("RUN_REVIEW_CHECKPOINT_TOO_LARGE", "Review checkpoint exceeds 4 MiB.");
|
|
49
|
+
await writePrivateFile(file, content, state.commonDir);
|
|
50
|
+
await repository.mutate(state.runId, current => {
|
|
51
|
+
if (current.finalProof == null)
|
|
52
|
+
throw new AgentOpsError("RUN_REVIEW_CHECKPOINT_UNBOUND", "A review checkpoint needs the run's final-proof record.");
|
|
53
|
+
const kept = Object.entries(current.finalProof.checkpoints ?? {}).filter(([binding]) => binding !== checkpoint.binding)
|
|
54
|
+
.slice(-(MAX_CHECKPOINTS - 1));
|
|
55
|
+
return { ...current, finalProof: { ...current.finalProof,
|
|
56
|
+
checkpoints: Object.fromEntries([...kept, [checkpoint.binding, sha256(content)]]) } };
|
|
57
|
+
});
|
|
58
|
+
},
|
|
59
|
+
async transient() {
|
|
60
|
+
await repository.mutate(state.runId, current => current.finalProof == null ? current
|
|
61
|
+
: { ...current, finalProof: { ...current.finalProof, reviewTransient: true } });
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** A delivered run keeps no resumable review rounds. */
|
|
66
|
+
export async function discardReviewCheckpoints(state) {
|
|
67
|
+
await rm(checkpointDirectory(state), { recursive: true, force: true });
|
|
68
|
+
}
|
|
69
|
+
/** Starts a proof step for `attemptKey`. The same key keeps its spent allowance; restarts never refund it. */
|
|
70
|
+
export async function beginFinalProof(repository, runId, attemptKey) {
|
|
71
|
+
const state = await repository.mutate(runId, current => ({ ...current, finalProof: current.finalProof?.attemptKey === attemptKey
|
|
72
|
+
? { ...current.finalProof, pending: true, reviewTransient: false }
|
|
73
|
+
: { attemptKey, recoveries: 0, pending: true, reviewTransient: false, failures: [], stopped: false } }));
|
|
74
|
+
return state.finalProof;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Records a failed or interrupted proof step and decides under the run lock
|
|
78
|
+
* whether the one automatic recovery is spent on it. Only an eligible failure
|
|
79
|
+
* of the same attempt in an active run consumes it; anything else stops
|
|
80
|
+
* automatic recovery for this attempt. The first failure is never overwritten.
|
|
81
|
+
*/
|
|
82
|
+
export async function settleFinalProofFailure(repository, runId, attemptKey, code, eligible) {
|
|
83
|
+
let decision = "stop";
|
|
84
|
+
await repository.mutate(runId, current => {
|
|
85
|
+
const record = current.finalProof;
|
|
86
|
+
if (record == null || record.attemptKey !== attemptKey)
|
|
87
|
+
return current;
|
|
88
|
+
const replay = eligible && record.recoveries === 0 && !record.stopped && current.status === "active" && !current.disableRestart;
|
|
89
|
+
decision = replay ? "replay" : "stop";
|
|
90
|
+
return { ...current, finalProof: { ...record, recoveries: replay ? 1 : record.recoveries, pending: replay, reviewTransient: false,
|
|
91
|
+
stopped: !replay, failures: [...record.failures.slice(0, 1), { code: code.slice(0, 128), at: new Date().toISOString() }] } };
|
|
92
|
+
});
|
|
93
|
+
return decision;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* A controlled step that ended without a complete result. Replayable only when
|
|
97
|
+
* no stop, deadline, limit or spawn failure ended it and nothing of its
|
|
98
|
+
* process group is still running.
|
|
99
|
+
*/
|
|
100
|
+
export class InterruptedStepError extends AgentOpsError {
|
|
101
|
+
replayable;
|
|
102
|
+
constructor(message, replayable) {
|
|
103
|
+
super("RUN_FINAL_PROOF_INTERRUPTED", message);
|
|
104
|
+
this.replayable = replayable;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Coordinator final proof with at most one automatic recovery per immutable
|
|
109
|
+
* attempt. `restartedKey` resumes a proof a previous supervisor left pending;
|
|
110
|
+
* the crash itself is the failure that may spend the allowance.
|
|
111
|
+
*/
|
|
112
|
+
export async function runFinalProof(repository, runId, steps, restartedKey) {
|
|
113
|
+
const key = restartedKey ?? await steps.attemptKey();
|
|
114
|
+
const replay = async (code, replayable) => {
|
|
115
|
+
let eligible = false;
|
|
116
|
+
try {
|
|
117
|
+
eligible = replayable && await steps.attemptKey() === key && await steps.untouched();
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
eligible = false;
|
|
121
|
+
}
|
|
122
|
+
const replaying = await settleFinalProofFailure(repository, runId, key, code, eligible) === "replay";
|
|
123
|
+
const latest = (await repository.read(runId));
|
|
124
|
+
await repository.appendEvent(runId, { type: "diagnostic", code: replaying ? "RUN_FINAL_PROOF_RECOVERING" : "RUN_FINAL_PROOF_RECOVERY_STOPPED",
|
|
125
|
+
workerId: latest.coordinatorId, taskId: latest.rootTaskId, detail: replaying
|
|
126
|
+
? `${code}: replaying final proof once with no writer started; a valid saved primary review and current verification are reused.`
|
|
127
|
+
: `${code}: automatic recovery ${replayable ? "is spent or no longer applies to this candidate" : "does not apply to this failure"}; the checkout is preserved.` });
|
|
128
|
+
return replaying;
|
|
129
|
+
};
|
|
130
|
+
if (restartedKey === undefined)
|
|
131
|
+
await beginFinalProof(repository, runId, key);
|
|
132
|
+
else if (!await replay("RUN_SUPERVISOR_RESTARTED", true))
|
|
133
|
+
throw new AgentOpsError("RUN_FINAL_PROOF_INTERRUPTED", "Final proof was interrupted by a supervisor restart and cannot be replayed automatically.");
|
|
134
|
+
for (;;) {
|
|
135
|
+
let result;
|
|
136
|
+
let failure;
|
|
137
|
+
try {
|
|
138
|
+
result = await steps.advance();
|
|
139
|
+
if (result.status === "ok")
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
catch (cause) {
|
|
143
|
+
failure = cause;
|
|
144
|
+
}
|
|
145
|
+
const replayable = failure === undefined ? (await repository.read(runId)).finalProof?.reviewTransient === true
|
|
146
|
+
: failure instanceof InterruptedStepError && failure.replayable;
|
|
147
|
+
const code = failure instanceof AgentOpsError ? failure.code : result?.code ?? "RUN_FINAL_PROOF_FAILED";
|
|
148
|
+
if (await replay(code, replayable))
|
|
149
|
+
continue;
|
|
150
|
+
if (failure !== undefined)
|
|
151
|
+
throw failure;
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Whether any process still belongs to the group: a proof group can outlive
|
|
157
|
+
* its leader. An unreadable process table counts as alive.
|
|
158
|
+
*/
|
|
159
|
+
export function processGroupAlive(group) {
|
|
160
|
+
try {
|
|
161
|
+
return execFileSync("ps", ["-axo", "pgid="], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })
|
|
162
|
+
.split("\n").some(line => Number(line.trim()) === group);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -159,6 +159,15 @@ export function assertRunState(value) {
|
|
|
159
159
|
state.landing.processId < 1 || typeof state.landing.hostname !== "string" || state.landing.hostname.length > 256 ||
|
|
160
160
|
!Number.isFinite(Date.parse(String(state.landing.startedAt)))))
|
|
161
161
|
return invalid("Invalid run landing marker.");
|
|
162
|
+
if (state.finalProof != null && (!plain(state.finalProof) || !SHA256.test(String(state.finalProof.attemptKey)) ||
|
|
163
|
+
![0, 1].includes(state.finalProof.recoveries) || typeof state.finalProof.pending !== "boolean" ||
|
|
164
|
+
typeof state.finalProof.reviewTransient !== "boolean" || typeof state.finalProof.stopped !== "boolean" ||
|
|
165
|
+
!boundedArray(state.finalProof.failures, 2) || state.finalProof.failures.some(f => !plain(f) ||
|
|
166
|
+
typeof f.code !== "string" || f.code.length === 0 || f.code.length > 128 || !Number.isFinite(Date.parse(String(f.at)))) ||
|
|
167
|
+
(state.finalProof.checkpoints !== undefined && (!plain(state.finalProof.checkpoints) ||
|
|
168
|
+
Object.keys(state.finalProof.checkpoints).length > 64 ||
|
|
169
|
+
Object.entries(state.finalProof.checkpoints).some(([binding, digest]) => !SHA256.test(binding) || !SHA256.test(String(digest)))))))
|
|
170
|
+
return invalid("Invalid final proof recovery record.");
|
|
162
171
|
if (state.proofProcess != null && (!plain(state.proofProcess) || !Number.isSafeInteger(state.proofProcess.processId) ||
|
|
163
172
|
state.proofProcess.processId < 1 || typeof state.proofProcess.processIdentity !== "string" ||
|
|
164
173
|
state.proofProcess.processIdentity.length === 0 || state.proofProcess.processIdentity.length > 256))
|
|
@@ -11,8 +11,8 @@ import { globMatcher, isCovers, missingScenarios, parseSurface, SURFACE_AXES } f
|
|
|
11
11
|
/** Cheapest first: a budget overrun stops before the long tail of diff tokens. */
|
|
12
12
|
export const DISCOVERY_SOURCES = ["agent", "hunk-header", "template-fragment", "diff-token"];
|
|
13
13
|
export const SURFACE_LIMITS = {
|
|
14
|
-
// ponytail: derived queries over 10 files are skipped as generic; incidental ones under that are dismissed by query.
|
|
15
|
-
agentQueryFiles: 200, derivedQueryFiles: 10, exclusionFiles: 10, dismissals: 128, dismissMinFiles: 4, budgetMs: 120_000, queryTimeoutMs: 30_000,
|
|
14
|
+
// ponytail: derived queries over 10 files outside the diff are skipped as generic; incidental ones under that are dismissed by query.
|
|
15
|
+
agentQueryFiles: 200, derivedQueryFiles: 10, exclusionFiles: 10, inclusionFiles: 10, dismissals: 128, dismissMinFiles: 4, budgetMs: 120_000, queryTimeoutMs: 30_000,
|
|
16
16
|
tokenLength: 6, fragmentLength: 4, derivedQueries: 400
|
|
17
17
|
};
|
|
18
18
|
/** argv only: queries are task text and must never meet a shell or a regex engine. */
|
|
@@ -142,14 +142,23 @@ export async function runSurfaceGate(input) {
|
|
|
142
142
|
const tracked = await git(input.root, ["ls-files", "-z"], Math.min(SURFACE_LIMITS.queryTimeoutMs, Math.max(1, remaining())));
|
|
143
143
|
if (tracked.timedOut || remaining() <= 0)
|
|
144
144
|
return notRun("SURFACE_BUDGET_EXCEEDED", "Discovery budget exhausted listing files.", []);
|
|
145
|
+
const trackedFiles = zList(tracked.stdout);
|
|
145
146
|
const exclusions = surface.excluded.map(row => ({ ...row, match: globMatcher(row.glob) }));
|
|
146
147
|
for (const row of exclusions) {
|
|
147
|
-
const files =
|
|
148
|
+
const files = trackedFiles.filter(row.match);
|
|
148
149
|
const diffFiles = [...changed].filter(row.match);
|
|
149
150
|
if (files.length > SURFACE_LIMITS.exclusionFiles || diffFiles.length > 0)
|
|
150
151
|
return notRun("SURFACE_EXCLUSION_TOO_BROAD", diffFiles.length > 0 ? "Files in the diff cannot be excluded."
|
|
151
152
|
: `One exclusion may hide at most ${SURFACE_LIMITS.exclusionFiles} files.`, [{ glob: row.glob, files: files.length, diffFiles }]);
|
|
152
153
|
}
|
|
154
|
+
// An included or UNKNOWN glob classifies every hit it matches, so it is capped like an exclusion.
|
|
155
|
+
for (const glob of [...surface.included.map(row => row.path), ...surface.unknown.flatMap(row => row.path === undefined ? [] : [row.path])]) {
|
|
156
|
+
const match = globMatcher(glob);
|
|
157
|
+
const files = trackedFiles.filter(path => match(path) && !changed.has(path)).length;
|
|
158
|
+
if (files > SURFACE_LIMITS.inclusionFiles)
|
|
159
|
+
return notRun("SURFACE_INCLUSION_TOO_BROAD", `One included or UNKNOWN glob may cover at most ${SURFACE_LIMITS.inclusionFiles} ` +
|
|
160
|
+
"files outside the diff; list narrower paths, each with its own behavior.", [{ glob, files }]);
|
|
161
|
+
}
|
|
153
162
|
const head = (await git(input.root, ["rev-parse", "HEAD"], SURFACE_LIMITS.queryTimeoutMs)).stdout.toString("utf8").trim();
|
|
154
163
|
const diff = (await git(input.root, ["diff", "-U0", "--no-color", "--no-ext-diff", input.base ?? "HEAD", "--"], SURFACE_LIMITS.queryTimeoutMs)).stdout.toString("utf8");
|
|
155
164
|
const derived = extractDiffQueries(diff);
|
|
@@ -217,7 +226,7 @@ export async function runSurfaceGate(input) {
|
|
|
217
226
|
return notRun("SURFACE_DISMISSAL_INVALID", "A recorded axis query cannot be dismissed; classify its hits.", [{ query }]);
|
|
218
227
|
const hit = hits.find(row => row.query === query && row.source !== "agent");
|
|
219
228
|
const outside = hit === undefined ? [] : hit.files.filter(path => !changed.has(path));
|
|
220
|
-
if (hit !== undefined && outside.length < SURFACE_LIMITS.dismissMinFiles
|
|
229
|
+
if (hit !== undefined && outside.length < SURFACE_LIMITS.dismissMinFiles)
|
|
221
230
|
return notRun("SURFACE_DISMISSAL_INVALID", `A query reaching fewer than ${SURFACE_LIMITS.dismissMinFiles} files outside the diff ` +
|
|
222
231
|
"is the likeliest sibling signal and cannot be dismissed; classify its files.", [{ query, files: outside }]);
|
|
223
232
|
if (hit !== undefined)
|
|
@@ -227,8 +236,9 @@ export async function runSurfaceGate(input) {
|
|
|
227
236
|
sources[hit.source].queries += 1;
|
|
228
237
|
if (hit.source === "agent" && hit.files.length > SURFACE_LIMITS.agentQueryFiles)
|
|
229
238
|
return notRun("SURFACE_QUERY_TOO_BROAD", `Query hits more than ${SURFACE_LIMITS.agentQueryFiles} files; record a more specific fixed string.`, [{ query: hit.query, files: hit.files.length }]);
|
|
230
|
-
|
|
231
|
-
|
|
239
|
+
const outside = hit.files.filter(path => !changed.has(path)).length;
|
|
240
|
+
if (hit.source !== "agent" && outside > SURFACE_LIMITS.derivedQueryFiles) {
|
|
241
|
+
skipped.push({ source: hit.source, query: hit.query, files: outside });
|
|
232
242
|
continue;
|
|
233
243
|
}
|
|
234
244
|
if (hit.source !== "agent" && dismissals.has(hit.query))
|
|
@@ -284,7 +294,8 @@ export async function runSurfaceGate(input) {
|
|
|
284
294
|
return notRun("ESCAPE_RULE_NO_EXCLUDE", "A learned escape rule marks these places noExclude: include them or mark them UNKNOWN.", noExclude);
|
|
285
295
|
for (const source of DISCOVERY_SOURCES)
|
|
286
296
|
sources[source].files = metrics.get(source).hits.size;
|
|
287
|
-
const discovery = { cached, sources, skipped: skipped
|
|
297
|
+
const discovery = { cached, sources, skipped: skipped
|
|
298
|
+
.sort((a, b) => a.files - b.files || (a.query < b.query ? -1 : a.query > b.query ? 1 : 0)).slice(0, 50), postBlockExclusions, dismissed };
|
|
288
299
|
if (input.metrics === true)
|
|
289
300
|
await logMetrics(input.logHome ?? process.env.AGENT_OPS_HOME ?? homedir(), DISCOVERY_SOURCES.map(source => ({ source,
|
|
290
301
|
hits: metrics.get(source).hits.size, included: metrics.get(source).included.size, excluded: metrics.get(source).excluded.size,
|
|
@@ -99,6 +99,9 @@ Every new task MUST record a surface audit of where else its change lands before
|
|
|
99
99
|
`glob` and a reason saying why the surface is unaffected ("out of scope" is
|
|
100
100
|
not a reason); `unknown` rows name a reader the repository cannot see.
|
|
101
101
|
`contracts` declare `compatible` or `breaking`. A single surface is allowed.
|
|
102
|
+
An `included` or `unknown` path glob may cover at most 10 tracked files
|
|
103
|
+
outside the diff, like an exclusion; list narrower paths, each with its own
|
|
104
|
+
behavior.
|
|
102
105
|
- `valueChanges` `[{producer, kind, change, consumers, indirectConsumers}]`
|
|
103
106
|
list two hops whenever a value's semantics change (clear, redact, default,
|
|
104
107
|
normalize, unit/format).
|
|
@@ -114,8 +117,9 @@ Every new task MUST record a surface audit of where else its change lands before
|
|
|
114
117
|
template-literal fixed fragments (at least 4 characters, separator-bounded,
|
|
115
118
|
such as `order:`, `user_`, `menu.`); compound identifiers (snake_case,
|
|
116
119
|
camelCase or containing digits, so prose words are not queries) and string
|
|
117
|
-
literals from added and removed lines (at least 6 characters
|
|
118
|
-
|
|
120
|
+
literals from added and removed lines (at least 6 characters). A derived
|
|
121
|
+
query is skipped as too generic above 10 hit files outside the diff; skipped
|
|
122
|
+
queries, fewest hits first, go to the second reviewer. Every hit outside the diff and the surface lists stops review.
|
|
119
123
|
- A derived query (diff token, template fragment or hunk-header name) whose hits
|
|
120
124
|
are incidental may be dismissed once in the surface's optional `dismissed`
|
|
121
125
|
list (`{query, reason}`, reason at most 256 characters, "out of scope" is
|
|
@@ -132,7 +136,8 @@ Every new task MUST record a surface audit of where else its change lands before
|
|
|
132
136
|
also refused at create), `SURFACE_UNCLASSIFIED`,
|
|
133
137
|
`SURFACE_QUERY_TOO_BROAD` (one recorded query over 200 files),
|
|
134
138
|
`SURFACE_EXCLUSION_TOO_BROAD` (one glob over 10 files, or any file in the
|
|
135
|
-
diff), `
|
|
139
|
+
diff), `SURFACE_INCLUSION_TOO_BROAD` (one included or UNKNOWN glob over 10
|
|
140
|
+
files outside the diff), `SURFACE_DISMISSAL_TOO_BROAD` (over 128 dismissals),
|
|
136
141
|
`SURFACE_DISMISSAL_INVALID` (a recorded or rare query), `SCENARIO_MISSING`, `SURFACE_BUDGET_EXCEEDED` (120 s total, never
|
|
137
142
|
silently skipped) or `SURFACE_UNASSIGNED` (children's covers differ from the
|
|
138
143
|
parent's included list; checked by every review, including advance and batch).
|
package/docs/en/spec/review.md
CHANGED
|
@@ -108,7 +108,9 @@ successful refutation MUST make the run FAIL.
|
|
|
108
108
|
session. A FAIL is already terminal and is never re-checked.
|
|
109
109
|
- Note: For a surfaced task the second round also hunts unlisted surfaces and
|
|
110
110
|
scenarios under `surface-coverage`: it checks the five fixed scenario
|
|
111
|
-
dimensions and may run at most 3 new `git grep -F` queries per axis
|
|
111
|
+
dimensions and may run at most 3 new `git grep -F` queries per axis, plus at
|
|
112
|
+
most 3 in total over the derived queries discovery skipped as too generic,
|
|
113
|
+
which the prompt names with their hit counts. Round 1
|
|
112
114
|
replays the recorded queries and reads every listed `valueChanges` consumer;
|
|
113
115
|
this is the only exception to "do not survey the repository".
|
|
114
116
|
|
|
@@ -278,3 +280,16 @@ A batch result MUST report every task's outcome and MUST exit 0 only when every
|
|
|
278
280
|
- Evidence: The envelope code and the exit code agree with the per-task outcomes.
|
|
279
281
|
- Positive: `One FAIL and one NOT_RUN exit 1, so the defect is handled first.`
|
|
280
282
|
- Negative: `Exit 0 while a task is still NOT_RUN.`
|
|
283
|
+
|
|
284
|
+
## REVIEW-RESUME-001
|
|
285
|
+
|
|
286
|
+
A native run's coordinator final proof MUST NOT pay again for a review round
|
|
287
|
+
that already answered the exact same review, and MUST retry automatically at
|
|
288
|
+
most once per immutable final attempt.
|
|
289
|
+
|
|
290
|
+
- Trigger: A run's final proof is interrupted: its proof process group was killed or returned a broken result (not by a stop, the run deadline, the output limit or a spawn failure, and with no process of the group left), its review chain ended on a transient attempt (a dropped call or a lost agy turn), or the supervisor restarted with the proof pending.
|
|
291
|
+
- Action: Inside the supervisor's registered proof group, review saves the necessary PASS after the source recheck and before the adversarial round, then the answered pair, under the run's private directory, bound to the primary prompt, the planned pair, model and effort, with each file's digest recorded in the locked run state like run answers and plans: a file without that record is ignored, so a planted PASS pair cannot skip review; the reviewer's in-process retry is off there. The supervisor keys each attempt by candidate, target, tree contract and policy config and runtime, and spends its one recovery only while the coordinator checkout is clean, no integration transaction exists and the run is active. The replay starts no writer, resumes a saved PASS with a fresh adversarial session, rebuilds a saved PASS pair without any model call, and still needs the standard attestation, finish and receipt. `task advance`, for every caller, skips `verify` for a task whose passing evidence still holds for the candidate by the rule review's preflight applies.
|
|
292
|
+
- Evidence: `run status` prints `Final proof recovery: <n>/1 used; first failure …; last failure …; automatic recovery stopped|in progress|available`, and the run events carry `RUN_FINAL_PROOF_RECOVERING` or `RUN_FINAL_PROOF_RECOVERY_STOPPED`. A recorded checkpoint that is corrupt, missing or mismatched ends the review NOT_RUN `checkpoint-invalid` without calling any reviewer. A refuted pair is never resumed. Checkpoints are deleted once the run's receipt is accepted.
|
|
293
|
+
- Positive: `The second round lost its connection; the supervisor replayed once, the necessary reviewer was not called again, and the run finished with a receipt.`
|
|
294
|
+
- Negative: `Retry until the adversarial reviewer passes, or replay after a stop, a timeout or a substantive FAIL.`
|
|
295
|
+
- Note: Standalone `review`, `batch` and child handoff keep their in-process retry and never read or write checkpoints. An uncommitted coordinator checkout is never replayed; it is left untouched for the existing repair.
|
|
@@ -62,7 +62,8 @@ English source version: 2026-10-20. Revalidate: when the English specification c
|
|
|
62
62
|
- `included` 列含 `id`、`path` glob 與 `behavior`;`excluded` 列含 `glob` 與說明
|
|
63
63
|
該 surface 為何不受影響的理由(「out of scope」不是理由);`unknown` 列記錄
|
|
64
64
|
repository 看不到的讀取者。`contracts` 宣告 `compatible` 或 `breaking`。只有單一
|
|
65
|
-
surface
|
|
65
|
+
surface 也可以。`included` 或 `unknown` 的 path glob 與排除一樣,至多涵蓋 diff
|
|
66
|
+
以外 10 個 tracked 檔案;請改列更窄的路徑,各自寫出 behavior。
|
|
66
67
|
- 值的語意改變(clear、redact、default、normalize、unit/format)時,
|
|
67
68
|
`valueChanges` `[{producer, kind, change, consumers, indirectConsumers}]` 列出兩層
|
|
68
69
|
consumer。
|
|
@@ -75,8 +76,8 @@ English source version: 2026-10-20. Revalidate: when the English specification c
|
|
|
75
76
|
不以 task 文字組 regex),由便宜的來源先跑:記錄的查詢;diff hunk header 的外層
|
|
76
77
|
函式名;template literal 固定片段(至少 4 字元且以分隔符界定,例如 `order:`、
|
|
77
78
|
`user_`、`menu.`);新增與刪除行的複合識別字(snake_case、camelCase 或含數字,
|
|
78
|
-
因此一般文字單字不會成為查詢)與字串常值(至少 6
|
|
79
|
-
|
|
79
|
+
因此一般文字單字不會成為查詢)與字串常值(至少 6 字元)。衍生查詢在 diff 以外命中
|
|
80
|
+
超過 10 個檔案時視為太通用而略過;略過的查詢依命中數由少到多交給第二輪 reviewer。diff 與 surface 清單之外的任何命中都會擋下 review。
|
|
80
81
|
- 衍生查詢(diff 字詞、template 片段或 hunk header 函式名)的命中若只是巧合,可在
|
|
81
82
|
surface 選用的 `dismissed` 清單中以一筆 `{query, reason}` 排除(理由至多 256
|
|
82
83
|
字元,「out of scope」不是理由),不必逐檔排除。記錄的軸查詢與 escape rule 命中
|
|
@@ -88,7 +89,8 @@ English source version: 2026-10-20. Revalidate: when the English specification c
|
|
|
88
89
|
- Review 以 NOT_RUN 回報:`SURFACE_MISSING`(沒有或無法解析的 surface;
|
|
89
90
|
task 建立時也會拒絕)、`SURFACE_UNCLASSIFIED`、`SURFACE_QUERY_TOO_BROAD`(單一
|
|
90
91
|
記錄查詢超過 200 個檔案)、`SURFACE_EXCLUSION_TOO_BROAD`(單一 glob 超過 10 個
|
|
91
|
-
檔案,或包含 diff 內的檔案)、`
|
|
92
|
+
檔案,或包含 diff 內的檔案)、`SURFACE_INCLUSION_TOO_BROAD`(單一 included 或
|
|
93
|
+
UNKNOWN glob 涵蓋 diff 以外超過 10 個檔案)、`SURFACE_DISMISSAL_TOO_BROAD`(超過 128 筆排除)、
|
|
92
94
|
`SURFACE_DISMISSAL_INVALID`(記錄的查詢或稀有查詢)、`SCENARIO_MISSING`、`SURFACE_BUDGET_EXCEEDED`
|
|
93
95
|
(總計 120 秒,絕不默默略過)或 `SURFACE_UNASSIGNED`(子 task covers 與父 task
|
|
94
96
|
included 不一致;每次 review 都檢查,包含 advance 與 batch)。
|
|
@@ -98,7 +98,7 @@ PASS MUST 交給第二個規劃的 target 嘗試反駁,且反駁成立時 MUST
|
|
|
98
98
|
- Positive: `codex 判 PASS,claude 找到 blocking 缺陷,整體判定 FAIL。`
|
|
99
99
|
- Negative: `為了讓複查看起來有效而編造反駁。`
|
|
100
100
|
- Note: 只有一個設定 target 時,會在全新 session 再呼叫同一 target。FAIL 已是終局,不再複查。
|
|
101
|
-
- Note: 有 surface 的 task,第二輪也會在 `surface-coverage` 下搜尋未列出的 surface 與情境:檢查五個固定情境維度,每個軸向最多可新增 3 個 `git grep -F`
|
|
101
|
+
- Note: 有 surface 的 task,第二輪也會在 `surface-coverage` 下搜尋未列出的 surface 與情境:檢查五個固定情境維度,每個軸向最多可新增 3 個 `git grep -F` 查詢,另外可針對 discovery 因太通用而略過的衍生查詢(prompt 會列出它們與命中數)總計最多再跑 3 個。第一輪重放記錄的查詢並閱讀每個列出的 `valueChanges` consumer;這是「不要普查 repository」唯一的例外。
|
|
102
102
|
|
|
103
103
|
## REVIEW-ORIGIN-001
|
|
104
104
|
|
|
@@ -250,3 +250,14 @@ permission bypass。外部 host runner 不可用時,結果 MUST 保持 NOT_RUN
|
|
|
250
250
|
- Evidence: envelope code 與 exit code 與各 task 的結果一致。
|
|
251
251
|
- Positive: `一個 FAIL 與一個 NOT_RUN 以 1 結束,先處理缺陷。`
|
|
252
252
|
- Negative: `仍有 task 是 NOT_RUN 卻以 0 結束。`
|
|
253
|
+
|
|
254
|
+
## REVIEW-RESUME-001
|
|
255
|
+
|
|
256
|
+
native run 的 coordinator 最終驗收 MUST NOT 為同一份審查中已經回答過的輪次再付一次費用,且每個不可變的最終驗收 attempt MUST 至多自動重試一次。
|
|
257
|
+
|
|
258
|
+
- Trigger: run 的最終驗收中斷:proof process group 被終止或回傳損壞的結果(不是 stop、run 期限、輸出上限或啟動失敗造成,且該 group 已沒有任何程序),審查鏈以 transient attempt(連線中斷或 agy 遺失回合)結束,或 supervisor 在驗收 pending 時重啟。
|
|
259
|
+
- Action: 在 supervisor 已註冊的 proof group 內,審查於來源再檢查之後、adversarial 輪之前保存必要 PASS,之後保存已回答的雙輪,存於 run 的私有目錄,綁定 primary prompt、規劃的雙 target、model 與 effort,並像 run 的 answers 與 plans 一樣把每個檔案的 digest 記在加鎖的 run state:沒有紀錄的檔案一律忽略,植入的 PASS 雙輪無法略過審查;此情境關閉 reviewer 的程序內 retry。supervisor 以 candidate、target、tree contract、policy config 與 runtime 作為 attempt key,只在 coordinator checkout 乾淨、沒有 integration transaction 且 run 仍 active 時花掉那一次恢復。重放不啟動任何 writer,以全新 adversarial session 接續已保存的 PASS,已保存的 PASS 雙輪不呼叫任何模型直接重建,且仍須標準 attestation、finish 與 receipt。`task advance` 對所有呼叫者,若某 task 的 PASS 證據依審查 preflight 的同一規則對 candidate 仍有效,就略過 `verify`。
|
|
260
|
+
- Evidence: `run status` 顯示 `Final proof recovery: <n>/1 used; first failure …; last failure …; automatic recovery stopped|in progress|available`,run 事件記錄 `RUN_FINAL_PROOF_RECOVERING` 或 `RUN_FINAL_PROOF_RECOVERY_STOPPED`。已記錄但損壞、遺失或不相符的 checkpoint 使審查以 NOT_RUN `checkpoint-invalid` 結束,且不呼叫任何 reviewer。被反駁的雙輪永不續用。run 的 receipt 被接受後即刪除 checkpoint。
|
|
261
|
+
- Positive: `第二輪連線中斷;supervisor 重放一次,必要 reviewer 沒有再被呼叫,run 以 receipt 完成。`
|
|
262
|
+
- Negative: `一直重試直到 adversarial reviewer 通過,或在 stop、timeout、實質 FAIL 之後重放。`
|
|
263
|
+
- Note: standalone `review`、`batch` 與 child handoff 保留程序內 retry,永不讀寫 checkpoint。有未提交修改的 coordinator checkout 永不重放,原樣保留給既有 repair 處理。
|
package/package.json
CHANGED