@kylecheng3146/agent-ops 0.11.0 → 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 (45) hide show
  1. package/README.md +19 -0
  2. package/dist/packages/cli/src/args.js +78 -3
  3. package/dist/packages/cli/src/bin.js +19 -1
  4. package/dist/packages/cli/src/cli.js +33 -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/commands/task.js +22 -5
  10. package/dist/packages/cli/src/context.js +13 -9
  11. package/dist/packages/cli/src/criteria-review.js +63 -0
  12. package/dist/packages/cli/src/followup-deps.js +56 -0
  13. package/dist/packages/cli/src/owned-run-step.js +1 -1
  14. package/dist/packages/cli/src/run-deps.js +16 -5
  15. package/dist/packages/cli/src/run-entry.js +283 -145
  16. package/dist/runtime/src/followup/intake.js +75 -0
  17. package/dist/runtime/src/followup/process.js +273 -0
  18. package/dist/runtime/src/followup/receipt.js +116 -0
  19. package/dist/runtime/src/followup/store.js +133 -0
  20. package/dist/runtime/src/install/harness.js +1 -1
  21. package/dist/runtime/src/install/preauth.js +11 -4
  22. package/dist/runtime/src/logging/local-log.js +12 -0
  23. package/dist/runtime/src/parallel/finish.js +10 -2
  24. package/dist/runtime/src/review/attestation.js +10 -0
  25. package/dist/runtime/src/review/criteria.js +252 -0
  26. package/dist/runtime/src/review/followup.js +233 -0
  27. package/dist/runtime/src/review/invocation.js +3 -3
  28. package/dist/runtime/src/review/runner.js +20 -0
  29. package/dist/runtime/src/run/service.js +59 -6
  30. package/dist/runtime/src/run/usage.js +5 -2
  31. package/dist/runtime/src/security/permissions.js +16 -1
  32. package/dist/runtime/src/verify/acceptance-coverage.js +2 -2
  33. package/dist/runtime/src/verify/service.js +62 -2
  34. package/dist/runtime/src/verify/spawn.js +67 -4
  35. package/docs/en/guides/configuration.md +5 -0
  36. package/docs/en/guides/task-debug.md +36 -0
  37. package/docs/en/spec/acceptance-and-evidence.md +54 -0
  38. package/docs/en/spec/loop-engineering.md +36 -0
  39. package/docs/en/spec/review.md +16 -0
  40. package/docs/zh-TW/guides/configuration.md +3 -0
  41. package/docs/zh-TW/guides/task-debug.md +31 -0
  42. package/docs/zh-TW/spec/acceptance-and-evidence.md +39 -0
  43. package/docs/zh-TW/spec/loop-engineering.md +21 -0
  44. package/docs/zh-TW/spec/review.md +17 -1
  45. package/package.json +1 -1
@@ -1,4 +1,7 @@
1
- import { stat } from "node:fs/promises";
1
+ import { criteriaReviewStatus } from "./criteria-review.js";
2
+ import { collectFollowupReceipt } from "../../../runtime/src/followup/intake.js";
3
+ import { renderCriteriaReview } from "../../../runtime/src/review/criteria.js";
4
+ import { rm, stat } from "node:fs/promises";
2
5
  import { recoverRunIntegrationAfterCleanup, validateRunIntegrationReceipt } from "../../../runtime/src/run/integration.js";
3
6
  import { pendingFindingPins } from "../../../runtime/src/run/ratchet.js";
4
7
  import { resolveReviewScope } from "../../../runtime/src/review/scope.js";
@@ -12,6 +15,7 @@ import { RunControlService, nativeResumeTime } from "../../../runtime/src/run/co
12
15
  import { beginPolicyTransition, markPolicyTransitionStage, completePolicyTransition, discoverPendingPolicyTransition } from "../../../runtime/src/run/policy-transition.js";
13
16
  import { readReviewReportArtifact } from "../../../runtime/src/review/attestation.js";
14
17
  import { execFile, spawn } from "node:child_process";
18
+ import { redactSecrets } from "../../../runtime/src/security/redact.js";
15
19
  import { promisify } from "node:util";
16
20
  import { join } from "node:path";
17
21
  import { fileURLToPath } from "node:url";
@@ -106,6 +110,84 @@ export function coordinatorWakeNote(state, coordinator) {
106
110
  return (coordinator.nativeSessionId === null ? "Fresh native session after wait; the earlier conversation is unavailable. " : "") +
107
111
  `Children: ${children.join("; ")}. Review deliveries, then plan follow-up workers and wait again, or request finalization.`;
108
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
+ }
109
191
  /** Retain the structured child failure instead of hiding it behind recovery text. */
110
192
  export function runStepError(result, fallback) {
111
193
  const details = Array.isArray(result.errors) ? result.errors.filter(plain)
@@ -231,24 +313,15 @@ export async function superviseNativeRun(root, runId) {
231
313
  throw new AgentOpsError("RUN_ANSWER_CHANGED", "The saved answer no longer matches its recorded digest.");
232
314
  answers.push({ questionId: question.questionId, prompt: question.prompt, answer: answer.answer });
233
315
  }
234
- const content = { runId, workerId: worker.workerId, generation: worker.generation, contractHash: current.currentContractHash,
316
+ const localTask = worker.taskId === null ? undefined : await new TaskService(new FileTaskStore(join(worker.worktree, ".agent-ops/tasks/state.json"), worker.worktree)).status({ taskId: worker.taskId }).catch(() => undefined);
317
+ const criteriaAdvice = localTask === undefined ? null : await criteriaReviewStatus(worker.worktree, localTask.task);
318
+ const content = { criteriaAdvisory: criteriaAdvice === null ? null : renderCriteriaReview(criteriaAdvice), runId, workerId: worker.workerId, generation: worker.generation, contractHash: current.currentContractHash,
235
319
  originalGoal: current.goal, answers, rootTaskId: current.rootTaskId, taskId: worker.taskId, cli, ownerSessionId: worker.ownerSessionId,
236
320
  policyConfigHash: current.policyBinding?.configHash,
237
- instructions: ["Keep the original goal fixed. Read this repository's AGENTS.md and use its existing verifier authority.",
238
- "Plan 2–5 substantive acceptance criteria. Submit a plan request before changing product files; the supervisor creates the task with the fixed goal.",
239
- "For every mechanically testable requirement use a configured acceptance runner; explicit review-only fallback requires a recorded reason and fresh goal review.",
240
- "Implement, commit and verify. Repair failed checks and pin mechanically testable review findings into regression criteria. Never disable a mandatory policy verifier.",
241
- "Only registered run workers may write. Use isolated worktrees and supervisor-issued leases; do not launch an unregistered writing subagent.",
242
- "Commit product changes before revising the contract. A contract revision stops existing generations; dirty recovery preserves the checkout and blocks takeover.",
243
- "Write one request atomically to .agent-ops/tasks/run-request.json; wait for matching run-response.json before another request.",
244
- "Requests require requestId (unique alphanumeric or hyphen, max 64), workerId, generation and current contractHash.",
245
- "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).",
246
- "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.",
247
- "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.",
248
- "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 };
249
322
  const path = join(worker.worktree, ".agent-ops", "tasks", "run-instructions.json");
250
323
  await writePrivateFile(path, JSON.stringify(content, null, 2), worker.worktree);
251
- 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();
252
325
  }
253
326
  async function start(worker, diagnostic, resumeAt) {
254
327
  if (await discoverPendingPolicyTransition(repository, runId) !== null)
@@ -341,7 +414,8 @@ export async function superviseNativeRun(root, runId) {
341
414
  });
342
415
  await service.create({ title: plan.title, intent: plan.intent, criteria: plan.criteria, goal: current.goal,
343
416
  sessionId: ownerSessionId, policyConfigHash: calculateConfigHash(config), ...(plan.covers === undefined ? {} : { surface: { covers: plan.covers } }) });
344
- await start((await repository.read(runId)).workers.find(w => w.workerId === registration.workerId));
417
+ const advisory = await criteriaStep(workerRoot, node.taskId);
418
+ await start((await repository.read(runId)).workers.find(w => w.workerId === registration.workerId), JSON.stringify(advisory));
345
419
  }
346
420
  catch (cause) {
347
421
  const latest = (await repository.read(runId));
@@ -385,97 +459,10 @@ export async function superviseNativeRun(root, runId) {
385
459
  return result.data.receipt;
386
460
  }
387
461
  async function step(cwd, args, saved) {
388
- const remaining = remainingRunTimeMs(saved);
389
- if (remaining <= 0)
390
- throw new AgentOpsError("RUN_BUDGET_EXHAUSTED", "No active time remains for final proof.");
391
- return await new Promise((resolve, reject) => {
392
- const env = { ...process.env, AGENT_OPS_HOST: saved.host,
393
- AGENT_OPS_SESSION_ID: saved.workers.find(worker => worker.worktree === cwd)?.ownerSessionId ?? saved.ownerSessionId,
394
- AGENT_OPS_RUN_ID: saved.runId,
395
- AGENT_OPS_WORKER_ID: saved.workers.find(worker => worker.worktree === cwd)?.workerId ?? saved.coordinatorId,
396
- AGENT_OPS_WORKER_GENERATION: String(saved.workers.find(worker => worker.worktree === cwd)?.generation ?? saved.workers.find(worker => worker.workerId === saved.coordinatorId)?.generation) };
397
- delete env.CODEX_THREAD_ID;
398
- delete env.AGENT_OPS_AGENT_ID;
399
- delete env.CODEX_SANDBOX_NETWORK_DISABLED;
400
- 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"] });
401
- let output = "";
402
- let closed = false;
403
- let escalation;
404
- const terminate = () => {
405
- if (child.pid === undefined || escalation !== undefined)
406
- return;
407
- try {
408
- process.kill(process.platform === "win32" ? child.pid : -child.pid, "SIGTERM");
409
- }
410
- catch { }
411
- escalation = setTimeout(() => {
412
- if (!closed)
413
- try {
414
- process.kill(process.platform === "win32" ? child.pid : -child.pid, "SIGKILL");
415
- }
416
- catch { }
417
- }, 3000);
418
- };
419
- const timeout = Number.isFinite(remaining) ? setTimeout(terminate, remaining) : undefined;
420
- const registration = (async () => {
421
- const identity = child.pid === undefined ? null : await nativeProcessIdentity(child.pid);
422
- if (identity === null || closed) {
423
- terminate();
424
- return;
425
- }
426
- await repository.mutate(runId, current => {
427
- if (current.status !== "active" || current.disableRestart) {
428
- terminate();
429
- throw new AgentOpsError("RUN_PROOF_STOPPED", "Stop raced final proof registration.");
430
- }
431
- return { ...current, proofProcess: { processId: child.pid, processIdentity: identity } };
432
- });
433
- child.stdin.end("start\n");
434
- })();
435
- child.stdin.on("error", terminate);
436
- void registration.catch(() => terminate());
437
- let checking = false;
438
- const control = setInterval(() => {
439
- if (checking)
440
- return;
441
- checking = true;
442
- void repository.read(runId).then(current => {
443
- if (current === null || current.disableRestart || current.status !== "active")
444
- terminate();
445
- }).catch(terminate).finally(() => { checking = false; });
446
- }, 250);
447
- child.stdout.on("data", chunk => { output += chunk.toString(); if (Buffer.byteLength(output) > 4 * 1024 * 1024)
448
- terminate(); });
449
- child.stderr.resume();
450
- child.once("error", cause => { closed = true; clearTimeout(timeout); clearInterval(control); reject(cause); });
451
- child.once("close", async () => {
452
- closed = true;
453
- clearTimeout(timeout);
454
- clearTimeout(escalation);
455
- clearInterval(control);
456
- try {
457
- await registration;
458
- await repository.mutate(runId, current => ({ ...current,
459
- ...(current.proofProcess?.processId === child.pid ? { proofProcess: null } : {}) }));
460
- const result = JSON.parse(output);
461
- if (!plain(result) || !["ok", "error"].includes(String(result.status)) || typeof result.code !== "string")
462
- throw new Error();
463
- const coordinatorRoot = saved.workers[0]?.worktree;
464
- if (result.status === "error" && coordinatorRoot != null)
465
- await recordSavedReviewRunUsage(repository, runId, coordinatorRoot);
466
- if (plain(result.data) && plain(result.data.result) && typeof result.data.result.sourceFingerprint === "string" &&
467
- typeof result.data.result.taskId === "string") {
468
- const artifact = await readReviewReportArtifact(cwd, result.data.result.sourceFingerprint, result.data.result.taskId);
469
- if (artifact !== null)
470
- await recordReviewRunUsage(repository, runId, artifact);
471
- }
472
- resolve(result);
473
- }
474
- catch {
475
- reject(new AgentOpsError("RUN_FINAL_PROOF_INTERRUPTED", "Final gate did not return a complete result; inspect target and receipt before retrying."));
476
- }
477
- });
478
- });
462
+ return await runControlledStep(repository, cli, cwd, args, saved);
463
+ }
464
+ async function criteriaStep(cwd, taskId) {
465
+ return await runCriteriaStep(repository, runId, cwd, taskId, step);
479
466
  }
480
467
  async function stopAll(reason, closeBudget = true) {
481
468
  const current = (await repository.read(runId));
@@ -640,8 +627,11 @@ export async function superviseNativeRun(root, runId) {
640
627
  return;
641
628
  }
642
629
  const latest = result.state.workers.find(w => w.workerId === worker.workerId);
643
- if (["fenced", "stopped"].includes(latest.status))
644
- 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
+ }
645
635
  else
646
636
  await supervisor.sendRepair(runId, worker.workerId, worker.generation, message);
647
637
  }
@@ -675,6 +665,17 @@ export async function superviseNativeRun(root, runId) {
675
665
  contractRevision: current.contractRevision + (current.currentContractHash === finalContract ? 0 : 1) }));
676
666
  await supervisor.finalize(runId, { targetCommit: receipt.candidateHead, candidateCommit: receipt.candidateHead,
677
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
+ }
678
679
  try {
679
680
  await stopAll("stop");
680
681
  await launchd.disableRestart(runDescriptor(current), "verified final integration completed");
@@ -809,47 +810,18 @@ export async function superviseNativeRun(root, runId) {
809
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.");
810
811
  continue;
811
812
  }
812
- async function rejectRequest(code) {
813
- const digest = sha256(source);
814
- const diagnostic = join(directory, runId, "invalid-requests", worker.workerId + "-" + worker.generation + "-" + digest + ".json");
815
- if (await readPrivateFile(diagnostic, commonDir) === null) {
816
- await writePrivateFile(diagnostic, canonicalJson({ code, workerId: worker.workerId, generation: worker.generation, requestDigest: digest }), commonDir);
817
- await repository.appendEvent(runId, { type: "diagnostic", code, workerId: worker.workerId, taskId: worker.taskId,
818
- detail: "Supervisor request is malformed or stale; a diagnostic response is available in the current checkout." });
819
- }
820
- await writePrivateFile(join(worker.worktree, ".agent-ops/tasks/run-response.json"), JSON.stringify({ code,
821
- 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") {
822
815
  if (observation.nativeGoalState === "complete")
823
- await repair(worker, code, "A malformed or stale request cannot establish completion. Read current run-instructions.json and replace run-request.json atomically.");
824
- }
825
- let request;
826
- try {
827
- request = JSON.parse(source);
828
- }
829
- catch {
830
- 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.");
831
817
  continue;
832
818
  }
833
- if (!plain(request) || typeof request.requestId !== "string" || !/^[A-Za-z0-9-]{1,64}$/u.test(request.requestId)) {
834
- await rejectRequest("RUN_REQUEST_INVALID");
835
- continue;
836
- }
837
- if (request.workerId !== worker.workerId || request.generation !== worker.generation) {
838
- await rejectRequest("RUN_REQUEST_STALE");
839
- continue;
840
- }
841
- const donePath = join(directory, runId, "requests", sha256(request.requestId) + ".json");
842
- const requestDigest = sha256(canonicalJson(request));
843
- const completed = await readPrivateFile(donePath, commonDir);
844
- if (completed !== null) {
845
- const cached = JSON.parse(completed);
846
- const response = plain(cached) && cached.requestDigest === requestDigest ? cached.response :
847
- { requestId: request.requestId, code: "RUN_REQUEST_ID_CONFLICT", error: "A request ID cannot be reused for different content." };
848
- await writePrivateFile(join(worker.worktree, ".agent-ops", "tasks", "run-response.json"), JSON.stringify(response), worker.worktree);
849
- if (observation.nativeGoalState === "complete")
819
+ if (reconciled.kind === "completed") {
820
+ if (reconciled.sameGeneration && observation.nativeGoalState === "complete")
850
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.");
851
822
  continue;
852
823
  }
824
+ const { request, donePath, requestDigest } = reconciled;
853
825
  const current = (await repository.read(runId));
854
826
  let response;
855
827
  try {
@@ -894,7 +866,8 @@ export async function superviseNativeRun(root, runId) {
894
866
  await repository.mutate(runId, saved => ({ ...saved, rootTaskId: task.task.id, currentContractHash: treeContractHash([task.task]),
895
867
  tasks: saved.tasks.map(node => node.taskId === worker.taskId ? { ...node, taskId: task.task.id } : node),
896
868
  workers: saved.workers.map(w => w.workerId === worker.workerId ? { ...w, taskId: task.task.id } : w) }));
897
- response = { requestId: request.requestId, code: "RUN_PLAN_CREATED", task: task.task, contractHash: treeContractHash([task.task]) };
869
+ const advisory = await criteriaStep(worker.worktree, task.task.id);
870
+ response = { advisory, requestId: request.requestId, code: "RUN_PLAN_CREATED", task: task.task, contractHash: treeContractHash([task.task]) };
898
871
  await recordRunPhase(repository, runId, worker.workerId, "implementing", { taskId: task.task.id, update: { passed: 0, total: task.task.criteria.length } });
899
872
  await instructions((await repository.read(runId)).workers.find(w => w.workerId === worker.workerId));
900
873
  }
@@ -1026,6 +999,7 @@ export async function superviseNativeRun(root, runId) {
1026
999
  }
1027
1000
  }
1028
1001
  await writePrivateFile(donePath, JSON.stringify({ requestDigest, response }), commonDir);
1002
+ await rm(requestPath, { force: true }).catch(() => { });
1029
1003
  const after = (await repository.read(runId));
1030
1004
  if (after.status === "complete") {
1031
1005
  await writePrivateFile(join(directory, runId, "final-response.json"), JSON.stringify(response), commonDir);
@@ -1056,3 +1030,167 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
1056
1030
  throw new AgentOpsError("RUN_SUPERVISOR_ARGUMENTS", "Supervisor requires canonical repository root and run ID.");
1057
1031
  await superviseNativeRun(process.argv[2], process.argv[3]);
1058
1032
  }
1033
+ export async function runControlledStep(repository, cli, cwd, args, saved) {
1034
+ const runId = saved.runId;
1035
+ const criteria = args[0] === "review" && args.includes("--criteria");
1036
+ const remaining = criteria ? Infinity : remainingRunTimeMs(saved);
1037
+ if (remaining <= 0)
1038
+ throw new AgentOpsError("RUN_BUDGET_EXHAUSTED", "No active time remains for final proof.");
1039
+ return await new Promise((resolve, reject) => {
1040
+ const env = { ...process.env, AGENT_OPS_HOST: saved.host,
1041
+ AGENT_OPS_SESSION_ID: saved.workers.find(worker => worker.worktree === cwd)?.ownerSessionId ?? saved.ownerSessionId,
1042
+ AGENT_OPS_RUN_ID: saved.runId,
1043
+ AGENT_OPS_WORKER_ID: saved.workers.find(worker => worker.worktree === cwd)?.workerId ?? saved.coordinatorId,
1044
+ AGENT_OPS_WORKER_GENERATION: String(saved.workers.find(worker => worker.worktree === cwd)?.generation ?? saved.workers.find(worker => worker.workerId === saved.coordinatorId)?.generation) };
1045
+ delete env.CODEX_THREAD_ID;
1046
+ delete env.AGENT_OPS_AGENT_ID;
1047
+ delete env.CODEX_SANDBOX_NETWORK_DISABLED;
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"] });
1049
+ let output = Buffer.alloc(0), diagnostic = Buffer.alloc(0);
1050
+ let outputOverflow = false;
1051
+ let spawnError;
1052
+ let closed = false;
1053
+ let escalation;
1054
+ const terminate = () => {
1055
+ if (child.pid === undefined || escalation !== undefined)
1056
+ return;
1057
+ try {
1058
+ process.kill(process.platform === "win32" ? child.pid : -child.pid, "SIGTERM");
1059
+ }
1060
+ catch { }
1061
+ escalation = setTimeout(() => {
1062
+ if (!closed)
1063
+ try {
1064
+ process.kill(process.platform === "win32" ? child.pid : -child.pid, "SIGKILL");
1065
+ }
1066
+ catch { }
1067
+ }, 3000);
1068
+ };
1069
+ const timeout = Number.isFinite(remaining) ? setTimeout(terminate, remaining) : undefined;
1070
+ const registration = (async () => {
1071
+ const identity = child.pid === undefined ? null : await nativeProcessIdentity(child.pid);
1072
+ if (identity === null || closed) {
1073
+ terminate();
1074
+ throw new Error("Controlled process identity unavailable.");
1075
+ }
1076
+ await repository.mutate(runId, current => {
1077
+ if (current.status !== "active" || current.disableRestart) {
1078
+ terminate();
1079
+ throw new AgentOpsError("RUN_PROOF_STOPPED", "Stop raced final proof registration.");
1080
+ }
1081
+ if (criteria && current.proofProcess != null) {
1082
+ terminate();
1083
+ throw new AgentOpsError("CRITERIA_REVIEW_PROCESS_BUSY", "Another controlled process is already registered.");
1084
+ }
1085
+ return { ...current, proofProcess: { processId: child.pid, processIdentity: identity } };
1086
+ });
1087
+ child.stdin.end("start\n");
1088
+ })();
1089
+ child.stdin.on("error", terminate);
1090
+ void registration.catch(() => terminate());
1091
+ let checking = false;
1092
+ const control = setInterval(() => {
1093
+ if (checking)
1094
+ return;
1095
+ checking = true;
1096
+ void repository.read(runId).then(current => {
1097
+ if (current === null || current.disableRestart || current.status !== "active")
1098
+ terminate();
1099
+ }).catch(terminate).finally(() => { checking = false; });
1100
+ }, 250);
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) => {
1117
+ closed = true;
1118
+ clearTimeout(timeout);
1119
+ clearTimeout(escalation);
1120
+ clearInterval(control);
1121
+ let stage = "registration";
1122
+ try {
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";
1132
+ await repository.mutate(runId, current => ({ ...current,
1133
+ ...(current.proofProcess?.processId === child.pid ? { proofProcess: null } : {}) }));
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"));
1144
+ if (!plain(result) || !["ok", "error"].includes(String(result.status)) || typeof result.code !== "string")
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";
1149
+ const coordinatorRoot = saved.workers[0]?.worktree;
1150
+ if (!criteria && result.status === "error" && coordinatorRoot != null)
1151
+ await recordSavedReviewRunUsage(repository, runId, coordinatorRoot);
1152
+ if (plain(result.data) && plain(result.data.result) && typeof result.data.result.sourceFingerprint === "string" &&
1153
+ typeof result.data.result.taskId === "string") {
1154
+ const artifact = await readReviewReportArtifact(cwd, result.data.result.sourceFingerprint, result.data.result.taskId);
1155
+ if (artifact !== null)
1156
+ await recordReviewRunUsage(repository, runId, artifact);
1157
+ }
1158
+ resolve(result);
1159
+ }
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));
1169
+ }
1170
+ });
1171
+ });
1172
+ }
1173
+ export async function runCriteriaStep(repository, runId, cwd, taskId, step) {
1174
+ const current = (await repository.read(runId));
1175
+ if (current.status !== "active" || current.disableRestart)
1176
+ throw new AgentOpsError("RUN_CRITERIA_STOPPED", "Run stopped before criteria review.");
1177
+ try {
1178
+ if (current.proofProcess != null)
1179
+ throw new Error("Controlled process busy");
1180
+ const result = await step(cwd, ["review", "--criteria", "--task", taskId, "--yes"], current);
1181
+ const latest = (await repository.read(runId));
1182
+ if (latest.status !== "active" || latest.disableRestart || result.code === "CRITERIA_REVIEW_CANCELLED")
1183
+ throw new AgentOpsError("RUN_CRITERIA_STOPPED", "Criteria review cancelled; do not start implementation.");
1184
+ if (result.status === "ok")
1185
+ return result.data;
1186
+ }
1187
+ catch (cause) {
1188
+ const latest = await repository.read(runId);
1189
+ if (latest === null || latest.status !== "active" || latest.disableRestart ||
1190
+ cause instanceof AgentOpsError && cause.code === "RUN_CRITERIA_STOPPED")
1191
+ throw cause;
1192
+ }
1193
+ await repository.appendEvent(runId, { type: "diagnostic", code: "CRITERIA_REVIEW_NOT_RUN", workerId: null, taskId,
1194
+ detail: "Pre-edit advisory unavailable; final evidence gates still apply." }).catch(() => undefined);
1195
+ return { code: "CRITERIA_REVIEW_NOT_RUN", text: "Criteria advisory NOT_RUN (process unavailable); final evidence gates still apply." };
1196
+ }
@@ -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
+ }