@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.
Files changed (36) hide show
  1. package/README.md +6 -0
  2. package/dist/packages/cli/src/args.js +68 -2
  3. package/dist/packages/cli/src/bin.js +9 -0
  4. package/dist/packages/cli/src/cli.js +29 -0
  5. package/dist/packages/cli/src/commands/advance.js +3 -2
  6. package/dist/packages/cli/src/commands/followup.js +74 -0
  7. package/dist/packages/cli/src/commands/parallel.js +4 -0
  8. package/dist/packages/cli/src/commands/run.js +1 -1
  9. package/dist/packages/cli/src/context.js +13 -9
  10. package/dist/packages/cli/src/followup-deps.js +56 -0
  11. package/dist/packages/cli/src/run-deps.js +16 -5
  12. package/dist/packages/cli/src/run-entry.js +159 -64
  13. package/dist/runtime/src/followup/intake.js +75 -0
  14. package/dist/runtime/src/followup/process.js +273 -0
  15. package/dist/runtime/src/followup/receipt.js +116 -0
  16. package/dist/runtime/src/followup/store.js +133 -0
  17. package/dist/runtime/src/install/preauth.js +11 -4
  18. package/dist/runtime/src/parallel/finish.js +10 -2
  19. package/dist/runtime/src/review/attestation.js +10 -0
  20. package/dist/runtime/src/review/followup.js +233 -0
  21. package/dist/runtime/src/review/runner.js +20 -0
  22. package/dist/runtime/src/run/service.js +59 -6
  23. package/dist/runtime/src/run/usage.js +5 -2
  24. package/dist/runtime/src/security/permissions.js +16 -1
  25. package/dist/runtime/src/verify/acceptance-coverage.js +2 -2
  26. package/dist/runtime/src/verify/service.js +62 -2
  27. package/dist/runtime/src/verify/spawn.js +64 -2
  28. package/docs/en/guides/configuration.md +5 -0
  29. package/docs/en/guides/task-debug.md +36 -0
  30. package/docs/en/spec/acceptance-and-evidence.md +54 -0
  31. package/docs/en/spec/loop-engineering.md +36 -0
  32. package/docs/zh-TW/guides/configuration.md +3 -0
  33. package/docs/zh-TW/guides/task-debug.md +31 -0
  34. package/docs/zh-TW/spec/acceptance-and-evidence.md +39 -0
  35. package/docs/zh-TW/spec/loop-engineering.md +21 -0
  36. 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: ["Keep the original goal fixed. Read this repository's AGENTS.md and use its existing verifier authority.",
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 "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 requests handoff after local green; 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.";
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 start(latest, message);
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
- async function rejectRequest(code) {
732
- const digest = sha256(source);
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 (!plain(request) || typeof request.requestId !== "string" || !/^[A-Za-z0-9-]{1,64}$/u.test(request.requestId)) {
753
- await rejectRequest("RUN_REQUEST_INVALID");
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
- return;
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 => { output += chunk.toString(); if (Buffer.byteLength(output) > 4 * 1024 * 1024)
1046
- terminate(); });
1047
- child.stderr.resume();
1048
- child.once("error", cause => { closed = true; clearTimeout(timeout); clearInterval(control); reject(cause); });
1049
- child.once("close", async () => {
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
- await registration;
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
- const result = JSON.parse(output);
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
- reject(new AgentOpsError("RUN_FINAL_PROOF_INTERRUPTED", "Final gate did not return a complete result; inspect target and receipt before retrying."));
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
+ }