@kylecheng3146/agent-ops 0.11.1 → 0.11.2
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 +6 -0
- package/dist/packages/cli/src/args.js +68 -2
- package/dist/packages/cli/src/bin.js +9 -0
- package/dist/packages/cli/src/cli.js +29 -0
- package/dist/packages/cli/src/commands/advance.js +3 -2
- package/dist/packages/cli/src/commands/followup.js +74 -0
- package/dist/packages/cli/src/commands/parallel.js +4 -0
- package/dist/packages/cli/src/commands/run.js +1 -1
- package/dist/packages/cli/src/context.js +13 -9
- package/dist/packages/cli/src/followup-deps.js +56 -0
- package/dist/packages/cli/src/run-deps.js +16 -5
- package/dist/packages/cli/src/run-entry.js +159 -64
- package/dist/runtime/src/followup/intake.js +75 -0
- package/dist/runtime/src/followup/process.js +273 -0
- package/dist/runtime/src/followup/receipt.js +116 -0
- package/dist/runtime/src/followup/store.js +133 -0
- package/dist/runtime/src/install/preauth.js +11 -4
- package/dist/runtime/src/parallel/finish.js +10 -2
- package/dist/runtime/src/review/attestation.js +10 -0
- package/dist/runtime/src/review/followup.js +233 -0
- package/dist/runtime/src/review/runner.js +20 -0
- package/dist/runtime/src/run/service.js +59 -6
- package/dist/runtime/src/run/usage.js +5 -2
- package/dist/runtime/src/security/permissions.js +16 -1
- package/dist/runtime/src/verify/acceptance-coverage.js +2 -2
- package/dist/runtime/src/verify/service.js +62 -2
- package/dist/runtime/src/verify/spawn.js +64 -2
- package/docs/en/guides/configuration.md +5 -0
- package/docs/en/guides/task-debug.md +36 -0
- package/docs/en/spec/acceptance-and-evidence.md +54 -0
- package/docs/en/spec/loop-engineering.md +36 -0
- package/docs/zh-TW/guides/configuration.md +3 -0
- package/docs/zh-TW/guides/task-debug.md +31 -0
- package/docs/zh-TW/spec/acceptance-and-evidence.md +39 -0
- package/docs/zh-TW/spec/loop-engineering.md +21 -0
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { criteriaReviewStatus } from "./criteria-review.js";
|
|
2
|
+
import { collectFollowupReceipt } from "../../../runtime/src/followup/intake.js";
|
|
2
3
|
import { renderCriteriaReview } from "../../../runtime/src/review/criteria.js";
|
|
3
|
-
import { stat } from "node:fs/promises";
|
|
4
|
+
import { rm, stat } from "node:fs/promises";
|
|
4
5
|
import { recoverRunIntegrationAfterCleanup, validateRunIntegrationReceipt } from "../../../runtime/src/run/integration.js";
|
|
5
6
|
import { pendingFindingPins } from "../../../runtime/src/run/ratchet.js";
|
|
6
7
|
import { resolveReviewScope } from "../../../runtime/src/review/scope.js";
|
|
@@ -14,6 +15,7 @@ import { RunControlService, nativeResumeTime } from "../../../runtime/src/run/co
|
|
|
14
15
|
import { beginPolicyTransition, markPolicyTransitionStage, completePolicyTransition, discoverPendingPolicyTransition } from "../../../runtime/src/run/policy-transition.js";
|
|
15
16
|
import { readReviewReportArtifact } from "../../../runtime/src/review/attestation.js";
|
|
16
17
|
import { execFile, spawn } from "node:child_process";
|
|
18
|
+
import { redactSecrets } from "../../../runtime/src/security/redact.js";
|
|
17
19
|
import { promisify } from "node:util";
|
|
18
20
|
import { join } from "node:path";
|
|
19
21
|
import { fileURLToPath } from "node:url";
|
|
@@ -108,6 +110,84 @@ export function coordinatorWakeNote(state, coordinator) {
|
|
|
108
110
|
return (coordinator.nativeSessionId === null ? "Fresh native session after wait; the earlier conversation is unavailable. " : "") +
|
|
109
111
|
`Children: ${children.join("; ")}. Review deliveries, then plan follow-up workers and wait again, or request finalization.`;
|
|
110
112
|
}
|
|
113
|
+
export function workerInstructionLines() {
|
|
114
|
+
return ["Keep the original goal fixed. Read this repository's AGENTS.md and use its existing verifier authority.",
|
|
115
|
+
"Plan 2–5 substantive acceptance criteria. Submit a plan request before changing product files; the supervisor creates the task with the fixed goal.",
|
|
116
|
+
"Read the criteria advisory before implementation. Decide which suggestions to adopt and use task revise with reasons (including rejected advice). A revision does not automatically rerun criteria review; formal final proof remains required.",
|
|
117
|
+
"For every mechanically testable requirement use a configured acceptance runner; explicit review-only fallback requires a recorded reason and fresh goal review.",
|
|
118
|
+
"Implement, run targeted fast checks on changed code, commit and submit action=handoff so the supervisor runs full verification outside your context window. Do not poll full-suite verification in a loop. Repair failed checks and pin mechanically testable review findings into regression criteria. Never disable a mandatory policy verifier.",
|
|
119
|
+
"Only registered run workers may write. Use isolated worktrees and supervisor-issued leases; do not launch an unregistered writing subagent.",
|
|
120
|
+
"Commit product changes before revising the contract. A contract revision stops existing generations; dirty recovery preserves the checkout and blocks takeover.",
|
|
121
|
+
"Write one request atomically to .agent-ops/tasks/run-request.json; wait for matching run-response.json before another request.",
|
|
122
|
+
"Requests require requestId (unique alphanumeric or hyphen, max 64), workerId, generation and current contractHash.",
|
|
123
|
+
"Plan fields: action=plan,title,intent,criteria,surface (the EVIDENCE-SURFACE-001 audit object, same shape as task create --surface-file). Worker fields: action=worker,title,intent,criteria,dependencies (known worker task IDs; omit all acceptance baselines until dependencies are delivered),covers (parent included surface IDs this worker delivers; every included surface needs one worker).",
|
|
124
|
+
"Only the coordinator may change execution policy: submit action=policy, configFile (relative proposed JSON), expectedPolicyHash, approvals (each changed capabilityId command:<id>/runner:<id>/setup:<index>, classification=non-dangerous, reason assessing the entire fixed definition including setup/build). Commit source changes and wait until every writer is clean before requesting policy synchronization. Never use init/update/trust grant to create permanent execution authorization. Explicit native or organization denial must not be bypassed.",
|
|
125
|
+
"Worker task baselines are frozen after dependency commits enter its checkout. Handoff fields: action=handoff,sourceFiles (explicit existing source paths when unchanged). Question fields: action=question,prompt. Coordinator wait fields: action=wait, sent once every worker is planned instead of polling run status; the supervisor stops the coordinator and starts it again, in a fresh session with a child-status diagnostic, after every child is delivered, complete or blocked. Coordinator finalize fields: action=finalize.",
|
|
126
|
+
"The supervisor, after stopping writers, runs final verification, two fresh reviews and finish. A native completed goal is never final proof."];
|
|
127
|
+
}
|
|
128
|
+
export function workerGoalPrompt() {
|
|
129
|
+
return "Read .agent-ops/tasks/run-instructions.json and carry its immutable original goal through the evidence loop. Submit a plan if rootTaskId is null; otherwise implement and repair the assigned task. A child runs targeted fast checks, commits, and requests handoff so the supervisor executes full verification; the coordinator submits action=wait after planning its children, then requests finalization once all have delivered. Never substitute native goal completion for agent-ops evidence.";
|
|
130
|
+
}
|
|
131
|
+
/** Drop the old native thread for a stopped or fenced writer on a clean checkout so repair starts without context bloat. */
|
|
132
|
+
export async function resetStoppedRepairThread(repository, runId, workerId, generation, cleanCheckout = true) {
|
|
133
|
+
const state = await repository.mutate(runId, current => ({
|
|
134
|
+
...current,
|
|
135
|
+
workers: current.workers.map(w => w.workerId === workerId && w.generation === generation && ["stopped", "fenced"].includes(w.status) && cleanCheckout
|
|
136
|
+
? { ...w, nativeSessionId: null, nativeJobId: null }
|
|
137
|
+
: w)
|
|
138
|
+
}));
|
|
139
|
+
const worker = state.workers.find(w => w.workerId === workerId);
|
|
140
|
+
if (worker === undefined)
|
|
141
|
+
throw new AgentOpsError("RUN_WORKER_NOT_FOUND", `Unknown worker ${workerId}.`);
|
|
142
|
+
return worker;
|
|
143
|
+
}
|
|
144
|
+
/** Check persisted requests/<sha256(requestId)>.json completion artifacts before rejecting across generations. */
|
|
145
|
+
export async function reconcileWorkerRequest(options) {
|
|
146
|
+
const { repository, directory, commonDir, runId, worker, source, requestPath } = options;
|
|
147
|
+
async function rejectRequest(code) {
|
|
148
|
+
const digest = sha256(source);
|
|
149
|
+
const diagnostic = join(directory, runId, "invalid-requests", worker.workerId + "-" + worker.generation + "-" + digest + ".json");
|
|
150
|
+
if (await readPrivateFile(diagnostic, commonDir) === null) {
|
|
151
|
+
await writePrivateFile(diagnostic, canonicalJson({ code, workerId: worker.workerId, generation: worker.generation, requestDigest: digest }), commonDir);
|
|
152
|
+
await repository.appendEvent(runId, { type: "diagnostic", code, workerId: worker.workerId, taskId: worker.taskId,
|
|
153
|
+
detail: "Supervisor request is malformed or stale; a diagnostic response is available in the current checkout." });
|
|
154
|
+
}
|
|
155
|
+
await writePrivateFile(join(worker.worktree, ".agent-ops/tasks/run-response.json"), JSON.stringify({ code,
|
|
156
|
+
workerId: worker.workerId, generation: worker.generation, error: "Read current run-instructions.json and replace the malformed or stale request atomically." }), worker.worktree);
|
|
157
|
+
return { kind: "rejected", code };
|
|
158
|
+
}
|
|
159
|
+
let request;
|
|
160
|
+
try {
|
|
161
|
+
request = JSON.parse(source);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return await rejectRequest("RUN_REQUEST_INVALID");
|
|
165
|
+
}
|
|
166
|
+
if (!plain(request) || typeof request.requestId !== "string" || !/^[A-Za-z0-9-]{1,64}$/u.test(request.requestId))
|
|
167
|
+
return await rejectRequest("RUN_REQUEST_INVALID");
|
|
168
|
+
const donePath = join(directory, runId, "requests", sha256(request.requestId) + ".json");
|
|
169
|
+
const requestDigest = sha256(canonicalJson(request));
|
|
170
|
+
const completed = await readPrivateFile(donePath, commonDir);
|
|
171
|
+
if (completed !== null && request.workerId === worker.workerId) {
|
|
172
|
+
const cached = JSON.parse(completed);
|
|
173
|
+
const sameGeneration = request.generation === worker.generation;
|
|
174
|
+
if (plain(cached) && cached.requestDigest === requestDigest) {
|
|
175
|
+
if (sameGeneration)
|
|
176
|
+
await writePrivateFile(join(worker.worktree, ".agent-ops", "tasks", "run-response.json"), JSON.stringify(cached.response), worker.worktree);
|
|
177
|
+
await rm(requestPath, { force: true }).catch(() => { });
|
|
178
|
+
return { kind: "completed", sameGeneration };
|
|
179
|
+
}
|
|
180
|
+
if (sameGeneration) {
|
|
181
|
+
const conflict = { requestId: request.requestId, code: "RUN_REQUEST_ID_CONFLICT", error: "A request ID cannot be reused for different content." };
|
|
182
|
+
await writePrivateFile(join(worker.worktree, ".agent-ops", "tasks", "run-response.json"), JSON.stringify(conflict), worker.worktree);
|
|
183
|
+
await rm(requestPath, { force: true }).catch(() => { });
|
|
184
|
+
return { kind: "completed", sameGeneration: true };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (request.workerId !== worker.workerId || request.generation !== worker.generation)
|
|
188
|
+
return await rejectRequest("RUN_REQUEST_STALE");
|
|
189
|
+
return { kind: "active", request, donePath, requestDigest };
|
|
190
|
+
}
|
|
111
191
|
/** Retain the structured child failure instead of hiding it behind recovery text. */
|
|
112
192
|
export function runStepError(result, fallback) {
|
|
113
193
|
const details = Array.isArray(result.errors) ? result.errors.filter(plain)
|
|
@@ -238,22 +318,10 @@ export async function superviseNativeRun(root, runId) {
|
|
|
238
318
|
const content = { criteriaAdvisory: criteriaAdvice === null ? null : renderCriteriaReview(criteriaAdvice), runId, workerId: worker.workerId, generation: worker.generation, contractHash: current.currentContractHash,
|
|
239
319
|
originalGoal: current.goal, answers, rootTaskId: current.rootTaskId, taskId: worker.taskId, cli, ownerSessionId: worker.ownerSessionId,
|
|
240
320
|
policyConfigHash: current.policyBinding?.configHash,
|
|
241
|
-
instructions:
|
|
242
|
-
"Plan 2–5 substantive acceptance criteria. Submit a plan request before changing product files; the supervisor creates the task with the fixed goal.",
|
|
243
|
-
"Read the criteria advisory before implementation. Decide which suggestions to adopt and use task revise with reasons (including rejected advice). A revision does not automatically rerun criteria review; formal final proof remains required.",
|
|
244
|
-
"For every mechanically testable requirement use a configured acceptance runner; explicit review-only fallback requires a recorded reason and fresh goal review.",
|
|
245
|
-
"Implement, commit and verify. Repair failed checks and pin mechanically testable review findings into regression criteria. Never disable a mandatory policy verifier.",
|
|
246
|
-
"Only registered run workers may write. Use isolated worktrees and supervisor-issued leases; do not launch an unregistered writing subagent.",
|
|
247
|
-
"Commit product changes before revising the contract. A contract revision stops existing generations; dirty recovery preserves the checkout and blocks takeover.",
|
|
248
|
-
"Write one request atomically to .agent-ops/tasks/run-request.json; wait for matching run-response.json before another request.",
|
|
249
|
-
"Requests require requestId (unique alphanumeric or hyphen, max 64), workerId, generation and current contractHash.",
|
|
250
|
-
"Plan fields: action=plan,title,intent,criteria,surface (the EVIDENCE-SURFACE-001 audit object, same shape as task create --surface-file). Worker fields: action=worker,title,intent,criteria,dependencies (known worker task IDs; omit all acceptance baselines until dependencies are delivered),covers (parent included surface IDs this worker delivers; every included surface needs one worker).",
|
|
251
|
-
"Only the coordinator may change execution policy: submit action=policy, configFile (relative proposed JSON), expectedPolicyHash, approvals (each changed capabilityId command:<id>/runner:<id>/setup:<index>, classification=non-dangerous, reason assessing the entire fixed definition including setup/build). Commit source changes and wait until every writer is clean before requesting policy synchronization. Never use init/update/trust grant to create permanent execution authorization. Explicit native or organization denial must not be bypassed.",
|
|
252
|
-
"Worker task baselines are frozen after dependency commits enter its checkout. Handoff fields: action=handoff,sourceFiles (explicit existing source paths when unchanged). Question fields: action=question,prompt. Coordinator wait fields: action=wait, sent once every worker is planned instead of polling run status; the supervisor stops the coordinator and starts it again, in a fresh session with a child-status diagnostic, after every child is delivered, complete or blocked. Coordinator finalize fields: action=finalize.",
|
|
253
|
-
"The supervisor, after stopping writers, runs final verification, two fresh reviews and finish. A native completed goal is never final proof."], diagnostic };
|
|
321
|
+
instructions: workerInstructionLines(), diagnostic };
|
|
254
322
|
const path = join(worker.worktree, ".agent-ops", "tasks", "run-instructions.json");
|
|
255
323
|
await writePrivateFile(path, JSON.stringify(content, null, 2), worker.worktree);
|
|
256
|
-
return
|
|
324
|
+
return workerGoalPrompt();
|
|
257
325
|
}
|
|
258
326
|
async function start(worker, diagnostic, resumeAt) {
|
|
259
327
|
if (await discoverPendingPolicyTransition(repository, runId) !== null)
|
|
@@ -559,8 +627,11 @@ export async function superviseNativeRun(root, runId) {
|
|
|
559
627
|
return;
|
|
560
628
|
}
|
|
561
629
|
const latest = result.state.workers.find(w => w.workerId === worker.workerId);
|
|
562
|
-
if (["fenced", "stopped"].includes(latest.status))
|
|
563
|
-
await
|
|
630
|
+
if (["fenced", "stopped"].includes(latest.status)) {
|
|
631
|
+
const clean = latest.worktree !== null && (await collectChangeSurface(gitRunner(latest.worktree))).paths.length === 0;
|
|
632
|
+
const reset = await resetStoppedRepairThread(repository, runId, latest.workerId, latest.generation, clean);
|
|
633
|
+
await start(reset, message);
|
|
634
|
+
}
|
|
564
635
|
else
|
|
565
636
|
await supervisor.sendRepair(runId, worker.workerId, worker.generation, message);
|
|
566
637
|
}
|
|
@@ -594,6 +665,17 @@ export async function superviseNativeRun(root, runId) {
|
|
|
594
665
|
contractRevision: current.contractRevision + (current.currentContractHash === finalContract ? 0 : 1) }));
|
|
595
666
|
await supervisor.finalize(runId, { targetCommit: receipt.candidateHead, candidateCommit: receipt.candidateHead,
|
|
596
667
|
verificationPass: true, reviewPass: true, taskStateComplete: true, receiptWritten: true, receiptDigest: sha256(source) });
|
|
668
|
+
try {
|
|
669
|
+
const followup = await collectFollowupReceipt({ git: deps.git, mainRoot: root, commonDir,
|
|
670
|
+
path, config: await deps.loadConfig(root), loadConfig: async () => await deps.loadConfig(root) });
|
|
671
|
+
for (const warning of followup.warnings)
|
|
672
|
+
await repository.appendEvent(runId, { type: "diagnostic",
|
|
673
|
+
code: "FOLLOWUP_RECONCILIATION_PENDING", workerId: null, taskId: null, detail: warning });
|
|
674
|
+
}
|
|
675
|
+
catch {
|
|
676
|
+
await repository.appendEvent(runId, { type: "diagnostic", code: "FOLLOWUP_RECONCILIATION_PENDING",
|
|
677
|
+
workerId: null, taskId: null, detail: "Completion remains valid; followup reconciliation needs retry." }).catch(() => { });
|
|
678
|
+
}
|
|
597
679
|
try {
|
|
598
680
|
await stopAll("stop");
|
|
599
681
|
await launchd.disableRestart(runDescriptor(current), "verified final integration completed");
|
|
@@ -728,47 +810,18 @@ export async function superviseNativeRun(root, runId) {
|
|
|
728
810
|
await repair(worker, "RUN_NATIVE_COMPLETION_UNPROVEN", "Native completion has no agent-ops delivery or final receipt. Read run-instructions.json, complete the evidence loop and submit handoff or finalize.");
|
|
729
811
|
continue;
|
|
730
812
|
}
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
const diagnostic = join(directory, runId, "invalid-requests", worker.workerId + "-" + worker.generation + "-" + digest + ".json");
|
|
734
|
-
if (await readPrivateFile(diagnostic, commonDir) === null) {
|
|
735
|
-
await writePrivateFile(diagnostic, canonicalJson({ code, workerId: worker.workerId, generation: worker.generation, requestDigest: digest }), commonDir);
|
|
736
|
-
await repository.appendEvent(runId, { type: "diagnostic", code, workerId: worker.workerId, taskId: worker.taskId,
|
|
737
|
-
detail: "Supervisor request is malformed or stale; a diagnostic response is available in the current checkout." });
|
|
738
|
-
}
|
|
739
|
-
await writePrivateFile(join(worker.worktree, ".agent-ops/tasks/run-response.json"), JSON.stringify({ code,
|
|
740
|
-
workerId: worker.workerId, generation: worker.generation, error: "Read current run-instructions.json and replace the malformed or stale request atomically." }), worker.worktree);
|
|
813
|
+
const reconciled = await reconcileWorkerRequest({ repository, directory, commonDir, runId, worker, source, requestPath });
|
|
814
|
+
if (reconciled.kind === "rejected") {
|
|
741
815
|
if (observation.nativeGoalState === "complete")
|
|
742
|
-
await repair(worker, code, "A malformed or stale request cannot establish completion. Read current run-instructions.json and replace run-request.json atomically.");
|
|
743
|
-
}
|
|
744
|
-
let request;
|
|
745
|
-
try {
|
|
746
|
-
request = JSON.parse(source);
|
|
747
|
-
}
|
|
748
|
-
catch {
|
|
749
|
-
await rejectRequest("RUN_REQUEST_INVALID");
|
|
816
|
+
await repair(worker, reconciled.code, "A malformed or stale request cannot establish completion. Read current run-instructions.json and replace run-request.json atomically.");
|
|
750
817
|
continue;
|
|
751
818
|
}
|
|
752
|
-
if (
|
|
753
|
-
|
|
754
|
-
continue;
|
|
755
|
-
}
|
|
756
|
-
if (request.workerId !== worker.workerId || request.generation !== worker.generation) {
|
|
757
|
-
await rejectRequest("RUN_REQUEST_STALE");
|
|
758
|
-
continue;
|
|
759
|
-
}
|
|
760
|
-
const donePath = join(directory, runId, "requests", sha256(request.requestId) + ".json");
|
|
761
|
-
const requestDigest = sha256(canonicalJson(request));
|
|
762
|
-
const completed = await readPrivateFile(donePath, commonDir);
|
|
763
|
-
if (completed !== null) {
|
|
764
|
-
const cached = JSON.parse(completed);
|
|
765
|
-
const response = plain(cached) && cached.requestDigest === requestDigest ? cached.response :
|
|
766
|
-
{ requestId: request.requestId, code: "RUN_REQUEST_ID_CONFLICT", error: "A request ID cannot be reused for different content." };
|
|
767
|
-
await writePrivateFile(join(worker.worktree, ".agent-ops", "tasks", "run-response.json"), JSON.stringify(response), worker.worktree);
|
|
768
|
-
if (observation.nativeGoalState === "complete")
|
|
819
|
+
if (reconciled.kind === "completed") {
|
|
820
|
+
if (reconciled.sameGeneration && observation.nativeGoalState === "complete")
|
|
769
821
|
await repair(worker, "RUN_NATIVE_COMPLETION_UNPROVEN", "The previous request response is available, but current delivery or final proof is still missing. Continue the assigned goal and submit a new request.");
|
|
770
822
|
continue;
|
|
771
823
|
}
|
|
824
|
+
const { request, donePath, requestDigest } = reconciled;
|
|
772
825
|
const current = (await repository.read(runId));
|
|
773
826
|
let response;
|
|
774
827
|
try {
|
|
@@ -946,6 +999,7 @@ export async function superviseNativeRun(root, runId) {
|
|
|
946
999
|
}
|
|
947
1000
|
}
|
|
948
1001
|
await writePrivateFile(donePath, JSON.stringify({ requestDigest, response }), commonDir);
|
|
1002
|
+
await rm(requestPath, { force: true }).catch(() => { });
|
|
949
1003
|
const after = (await repository.read(runId));
|
|
950
1004
|
if (after.status === "complete") {
|
|
951
1005
|
await writePrivateFile(join(directory, runId, "final-response.json"), JSON.stringify(response), commonDir);
|
|
@@ -992,7 +1046,9 @@ export async function runControlledStep(repository, cli, cwd, args, saved) {
|
|
|
992
1046
|
delete env.AGENT_OPS_AGENT_ID;
|
|
993
1047
|
delete env.CODEX_SANDBOX_NETWORK_DISABLED;
|
|
994
1048
|
const child = spawn(process.execPath, [fileURLToPath(new URL("./run-step-entry.js", import.meta.url)), cli, ...args, "--json"], { cwd, env, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"] });
|
|
995
|
-
let output =
|
|
1049
|
+
let output = Buffer.alloc(0), diagnostic = Buffer.alloc(0);
|
|
1050
|
+
let outputOverflow = false;
|
|
1051
|
+
let spawnError;
|
|
996
1052
|
let closed = false;
|
|
997
1053
|
let escalation;
|
|
998
1054
|
const terminate = () => {
|
|
@@ -1015,7 +1071,7 @@ export async function runControlledStep(repository, cli, cwd, args, saved) {
|
|
|
1015
1071
|
const identity = child.pid === undefined ? null : await nativeProcessIdentity(child.pid);
|
|
1016
1072
|
if (identity === null || closed) {
|
|
1017
1073
|
terminate();
|
|
1018
|
-
|
|
1074
|
+
throw new Error("Controlled process identity unavailable.");
|
|
1019
1075
|
}
|
|
1020
1076
|
await repository.mutate(runId, current => {
|
|
1021
1077
|
if (current.status !== "active" || current.disableRestart) {
|
|
@@ -1042,22 +1098,54 @@ export async function runControlledStep(repository, cli, cwd, args, saved) {
|
|
|
1042
1098
|
terminate();
|
|
1043
1099
|
}).catch(terminate).finally(() => { checking = false; });
|
|
1044
1100
|
}, 250);
|
|
1045
|
-
child.stdout.on("data", chunk => {
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1101
|
+
child.stdout.on("data", (chunk) => {
|
|
1102
|
+
const room = 4 * 1024 * 1024 - output.length;
|
|
1103
|
+
if (room > 0)
|
|
1104
|
+
output = Buffer.concat([output, chunk.subarray(0, room)]);
|
|
1105
|
+
if (chunk.length > room) {
|
|
1106
|
+
outputOverflow = true;
|
|
1107
|
+
terminate();
|
|
1108
|
+
}
|
|
1109
|
+
});
|
|
1110
|
+
child.stderr.on("data", (chunk) => {
|
|
1111
|
+
const room = 64 * 1024 - diagnostic.length;
|
|
1112
|
+
if (room > 0)
|
|
1113
|
+
diagnostic = Buffer.concat([diagnostic, chunk.subarray(0, room)]);
|
|
1114
|
+
});
|
|
1115
|
+
child.once("error", cause => { spawnError = cause; terminate(); });
|
|
1116
|
+
child.once("close", async (exitCode, signal) => {
|
|
1050
1117
|
closed = true;
|
|
1051
1118
|
clearTimeout(timeout);
|
|
1052
1119
|
clearTimeout(escalation);
|
|
1053
1120
|
clearInterval(control);
|
|
1121
|
+
let stage = "registration";
|
|
1054
1122
|
try {
|
|
1055
|
-
|
|
1123
|
+
// Cleanup ownership even when registration or the subprocess failed.
|
|
1124
|
+
let registrationError;
|
|
1125
|
+
try {
|
|
1126
|
+
await registration;
|
|
1127
|
+
}
|
|
1128
|
+
catch (cause) {
|
|
1129
|
+
registrationError = cause;
|
|
1130
|
+
}
|
|
1131
|
+
stage = "cleanup";
|
|
1056
1132
|
await repository.mutate(runId, current => ({ ...current,
|
|
1057
1133
|
...(current.proofProcess?.processId === child.pid ? { proofProcess: null } : {}) }));
|
|
1058
|
-
|
|
1134
|
+
stage = "registration";
|
|
1135
|
+
if (registrationError !== undefined)
|
|
1136
|
+
throw registrationError;
|
|
1137
|
+
stage = "process";
|
|
1138
|
+
if (spawnError !== undefined)
|
|
1139
|
+
throw spawnError;
|
|
1140
|
+
if (signal !== null || outputOverflow)
|
|
1141
|
+
throw new Error(outputOverflow ? "Result exceeded 4 MiB." : "Controlled process was signaled.");
|
|
1142
|
+
stage = "result";
|
|
1143
|
+
const result = JSON.parse(output.toString("utf8"));
|
|
1059
1144
|
if (!plain(result) || !["ok", "error"].includes(String(result.status)) || typeof result.code !== "string")
|
|
1060
|
-
throw new Error();
|
|
1145
|
+
throw new Error("Invalid result envelope.");
|
|
1146
|
+
if (result.status === "ok" && exitCode !== 0)
|
|
1147
|
+
throw new Error("Successful envelope with unsuccessful process exit.");
|
|
1148
|
+
stage = "usage";
|
|
1061
1149
|
const coordinatorRoot = saved.workers[0]?.worktree;
|
|
1062
1150
|
if (!criteria && result.status === "error" && coordinatorRoot != null)
|
|
1063
1151
|
await recordSavedReviewRunUsage(repository, runId, coordinatorRoot);
|
|
@@ -1069,8 +1157,15 @@ export async function runControlledStep(repository, cli, cwd, args, saved) {
|
|
|
1069
1157
|
}
|
|
1070
1158
|
resolve(result);
|
|
1071
1159
|
}
|
|
1072
|
-
catch {
|
|
1073
|
-
|
|
1160
|
+
catch (cause) {
|
|
1161
|
+
const excerpt = (value) => redactSecrets(value)
|
|
1162
|
+
.replace(/-----BEGIN (?:[A-Z0-9]+ )?PRIVATE KEY-----[\s\S]*$/u, "[REDACTED_PRIVATE_KEY]").slice(0, 3500);
|
|
1163
|
+
const detail = JSON.stringify({ exitCode, signal, stage, outputOverflow,
|
|
1164
|
+
cause: excerpt(cause instanceof Error ? cause.message : String(cause)),
|
|
1165
|
+
stderr: excerpt(diagnostic.toString("utf8")), stdout: excerpt(output.toString("utf8").slice(0, 4096)) }).slice(0, 9500);
|
|
1166
|
+
await repository.appendEvent(runId, { type: "diagnostic", code: "RUN_CONTROLLED_STEP_FAILED",
|
|
1167
|
+
workerId: env.AGENT_OPS_WORKER_ID ?? null, taskId: saved.workers.find(w => w.worktree === cwd)?.taskId ?? null, detail }).catch(() => undefined);
|
|
1168
|
+
reject(new AgentOpsError("RUN_FINAL_PROOF_INTERRUPTED", "Final gate did not return a complete result; inspect target and receipt before retrying. " + detail));
|
|
1074
1169
|
}
|
|
1075
1170
|
});
|
|
1076
1171
|
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { lstat, readdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
4
|
+
import { readDeliveredReceipt } from "./receipt.js";
|
|
5
|
+
import { FileFollowupStore, followupIdentity } from "./store.js";
|
|
6
|
+
import { reconcileFollowupRepairs } from "./process.js";
|
|
7
|
+
function warning(error) {
|
|
8
|
+
const code = error instanceof AgentOpsError && /^[A-Z0-9_]{1,80}$/u.test(error.code) ? error.code : "FOLLOWUP_COLLECTION_FAILED";
|
|
9
|
+
return `followup: ${code}; original completion is unchanged. Retry with followup import.`;
|
|
10
|
+
}
|
|
11
|
+
/** Metadata-only and fail-open: collection cannot invalidate a successful finish. */
|
|
12
|
+
export async function collectFollowupReceipt(options) {
|
|
13
|
+
try {
|
|
14
|
+
const { receipt, digest, reviews } = await readDeliveredReceipt(options);
|
|
15
|
+
const pending = [];
|
|
16
|
+
for (const review of reviews)
|
|
17
|
+
for (const round of ["primary", "adversarial"]) {
|
|
18
|
+
const report = round === "primary" ? review.artifact.report : review.artifact.adversarial.report;
|
|
19
|
+
const provenance = { receiptPath: options.path, receiptDigest: digest, receiptSchemaVersion: receipt.schemaVersion,
|
|
20
|
+
candidateHead: receipt.candidateHead, targetCommit: receipt.targetCommit, sourceFingerprint: receipt.sourceFingerprint,
|
|
21
|
+
taskId: review.taskId, reportDigest: review.reportDigest, round, provenanceLimit: review.provenanceLimit };
|
|
22
|
+
const add = (sourceKind, original, index) => {
|
|
23
|
+
const source = { ...provenance, sourceKind, index };
|
|
24
|
+
pending.push({ id: followupIdentity(source), kind: sourceKind === "finding" ? "candidate" : "observation",
|
|
25
|
+
original, provenance: source, disposition: "pending", history: [{ at: new Date().toISOString(), action: "collected" }] });
|
|
26
|
+
};
|
|
27
|
+
report.findings.forEach((finding, index) => { if (!finding.blocking)
|
|
28
|
+
add("finding", finding, index); });
|
|
29
|
+
report.residualRisks.forEach((risk, index) => add("residual-risk", risk, index));
|
|
30
|
+
}
|
|
31
|
+
const collected = await new FileFollowupStore(options.commonDir).mutate(items => {
|
|
32
|
+
const known = new Set(items.map(item => item.id));
|
|
33
|
+
const added = pending.filter(item => !known.has(item.id));
|
|
34
|
+
items.push(...added);
|
|
35
|
+
return { added: added.length, candidates: added.filter(item => item.kind === "candidate").length,
|
|
36
|
+
observations: added.filter(item => item.kind === "observation").length, warnings: [] };
|
|
37
|
+
});
|
|
38
|
+
const warnings = options.config === undefined ? [] : await reconcileFollowupRepairs({ ...options, config: options.config });
|
|
39
|
+
return { ...collected, warnings };
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
return { added: 0, candidates: 0, observations: 0, warnings: [warning(error)] };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Historical import never launches assessment, repair, models or provider calls. */
|
|
46
|
+
export async function reconcileFollowups(options) {
|
|
47
|
+
const result = { added: 0, candidates: 0, observations: 0, warnings: [] };
|
|
48
|
+
try {
|
|
49
|
+
const directory = join(options.commonDir, "agent-ops", "receipts");
|
|
50
|
+
// Checking through the private reader rejects symlinked ancestors before enumeration.
|
|
51
|
+
await new FileFollowupStore(options.commonDir).read();
|
|
52
|
+
const status = await lstat(directory);
|
|
53
|
+
if (!status.isDirectory() || status.isSymbolicLink())
|
|
54
|
+
throw new AgentOpsError("PRIVATE_STATE_PATH_INVALID", "Receipt directory is unsafe.");
|
|
55
|
+
const names = (await readdir(directory)).filter(name => /^[a-f0-9]{64}-[a-f0-9]{40,64}\.json$/u.test(name)).sort();
|
|
56
|
+
const { config: _config, loadConfig: _loadConfig, ...source } = options;
|
|
57
|
+
for (const name of names) {
|
|
58
|
+
const collected = await collectFollowupReceipt({ ...source, path: join(directory, name) });
|
|
59
|
+
result.added += collected.added;
|
|
60
|
+
result.candidates += collected.candidates;
|
|
61
|
+
result.observations += collected.observations;
|
|
62
|
+
// Repeated malformed sources cannot flood normal CLI diagnostics.
|
|
63
|
+
for (const item of collected.warnings)
|
|
64
|
+
if (!result.warnings.includes(item) && result.warnings.length < 8)
|
|
65
|
+
result.warnings.push(item);
|
|
66
|
+
}
|
|
67
|
+
if (options.config !== undefined)
|
|
68
|
+
result.warnings.push(...await reconcileFollowupRepairs({ ...options, config: options.config }));
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (error.code !== "ENOENT")
|
|
72
|
+
result.warnings.push(warning(error));
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|