@kylecheng3146/agent-ops 0.10.0 → 0.11.0

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 (76) hide show
  1. package/README.md +3 -2
  2. package/dist/packages/cli/src/args.js +18 -53
  3. package/dist/packages/cli/src/bin.js +28 -29
  4. package/dist/packages/cli/src/cli.js +25 -17
  5. package/dist/packages/cli/src/codex-loop-process.js +0 -6
  6. package/dist/packages/cli/src/commands/advance.js +2 -1
  7. package/dist/packages/cli/src/commands/parallel.js +1 -1
  8. package/dist/packages/cli/src/commands/review.js +8 -11
  9. package/dist/packages/cli/src/commands/run.js +12 -3
  10. package/dist/packages/cli/src/commands/task.js +6 -50
  11. package/dist/packages/cli/src/commands/verify.js +7 -3
  12. package/dist/packages/cli/src/context.js +22 -4
  13. package/dist/packages/cli/src/default-base.js +26 -0
  14. package/dist/packages/cli/src/hook-process.js +19 -83
  15. package/dist/packages/cli/src/office-entry.js +8 -4
  16. package/dist/packages/cli/src/owned-run-step.js +4 -4
  17. package/dist/packages/cli/src/parallel-deps.js +0 -2
  18. package/dist/packages/cli/src/run-deps.js +12 -9
  19. package/dist/packages/cli/src/run-entry.js +123 -19
  20. package/dist/packages/cli/src/run-setup-entry.js +2 -2
  21. package/dist/runtime/src/adapters/agy/config.js +2 -3
  22. package/dist/runtime/src/adapters/claude/config.js +2 -2
  23. package/dist/runtime/src/adapters/codex/config.js +2 -5
  24. package/dist/runtime/src/hooks/completion-gate.js +0 -15
  25. package/dist/runtime/src/install/harness.js +7 -4
  26. package/dist/runtime/src/install/plan.js +3 -3
  27. package/dist/runtime/src/install/profiles.js +0 -2
  28. package/dist/runtime/src/office/scene.js +1 -1
  29. package/dist/runtime/src/office/server.js +43 -16
  30. package/dist/runtime/src/office/snapshot.js +5 -2
  31. package/dist/runtime/src/parallel/finish.js +139 -65
  32. package/dist/runtime/src/parallel/manage.js +0 -3
  33. package/dist/runtime/src/parallel/service.js +5 -1
  34. package/dist/runtime/src/review/execute.js +77 -11
  35. package/dist/runtime/src/review/packet.js +17 -6
  36. package/dist/runtime/src/review/runner.js +2 -0
  37. package/dist/runtime/src/run/controls.js +99 -1
  38. package/dist/runtime/src/run/hosts/codex.js +28 -1
  39. package/dist/runtime/src/run/hosts/protocol.js +5 -2
  40. package/dist/runtime/src/run/integration.js +35 -13
  41. package/dist/runtime/src/run/policy.js +65 -10
  42. package/dist/runtime/src/run/scheduler.js +10 -5
  43. package/dist/runtime/src/run/service.js +68 -21
  44. package/dist/runtime/src/run/supervisor.js +11 -2
  45. package/dist/runtime/src/run/transport.js +1 -0
  46. package/dist/runtime/src/run/verification.js +2 -2
  47. package/dist/runtime/src/schema/validate.js +10 -5
  48. package/dist/runtime/src/security/permissions.js +85 -0
  49. package/dist/runtime/src/task/completion.js +4 -42
  50. package/dist/runtime/src/task/contract.js +11 -0
  51. package/dist/runtime/src/task/service.js +34 -57
  52. package/dist/runtime/src/task/store.js +3 -7
  53. package/dist/runtime/src/verify/acceptance-coverage.js +2 -3
  54. package/dist/runtime/src/verify/acceptance-protocol.js +4 -3
  55. package/dist/runtime/src/verify/acceptance-replay.js +4 -7
  56. package/dist/runtime/src/verify/service.js +6 -23
  57. package/dist/runtime/src/verify/source-fingerprint.js +17 -4
  58. package/docs/en/guides/configuration.md +8 -4
  59. package/docs/en/guides/task-debug.md +39 -121
  60. package/docs/en/spec/acceptance-and-evidence.md +31 -19
  61. package/docs/en/spec/harness-adapters.md +35 -12
  62. package/docs/en/spec/loop-engineering.md +40 -6
  63. package/docs/en/spec/review.md +6 -0
  64. package/docs/zh-TW/guides/configuration.md +6 -3
  65. package/docs/zh-TW/guides/task-debug.md +27 -66
  66. package/docs/zh-TW/spec/acceptance-and-evidence.md +19 -18
  67. package/docs/zh-TW/spec/harness-adapters.md +28 -9
  68. package/docs/zh-TW/spec/loop-engineering.md +14 -3
  69. package/docs/zh-TW/spec/review.md +5 -0
  70. package/package.json +1 -1
  71. package/schemas/config.schema.json +1 -21
  72. package/dist/packages/cli/src/debug-intake.js +0 -245
  73. package/dist/runtime/src/task/debug-intake.js +0 -15
  74. package/dist/runtime/src/task/debug-state.js +0 -129
  75. package/dist/runtime/src/task/debug-user-event.js +0 -95
  76. package/dist/runtime/src/task/debug.js +0 -612
@@ -1,18 +1,18 @@
1
1
  import { withActivity } from "../office/activity.js";
2
- import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
2
+ import { lstat, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { join } from "node:path";
5
- import { sha256 } from "../fs/hash.js";
6
- import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
5
+ import { AgentOpsError } from "../fs/paths.js";
7
6
  import { currentGateFingerprint } from "../hooks/completion-gate.js";
8
7
  import { collectChangeSurface } from "../verify/change-surface.js";
9
- import { aggregateVerificationStatus, executeConfiguredCommand } from "../verify/command-executor.js";
10
8
  import { prepareFinishReceipt } from "./receipt.js";
11
9
  import { resolveReviewScope } from "../review/scope.js";
12
10
  import { calculateSourceFingerprint } from "../verify/source-fingerprint.js";
13
- import { readRunIntegrationProof, readRunIntegrationReceiptBinding, markRunIntegration } from "../run/integration.js";
11
+ import { readRunIntegrationProof, readRunIntegrationReceiptBinding, markRunIntegration, validateRunIntegrationCleanup, recoverRunIntegrationAfterCleanup } from "../run/integration.js";
12
+ import { FileRunRepository } from "../run/service.js";
13
+ import { sha256 } from "../fs/hash.js";
14
+ import { redactSecrets } from "../security/redact.js";
14
15
  import { canonicalJson } from "../config/hash.js";
15
- import { listWorktrees } from "./manage.js";
16
16
  import { withPrivateFileLock } from "../security/permissions.js";
17
17
  import { markOfficeSessionCompleted } from "../office/sessions.js";
18
18
  import { assertWorktreeName, NOTES_REF, noteSessionLine, parseWorktreeListPorcelain, readWorktreeRecord, removeCheckout, resolveCheckouts, worktreePath } from "./service.js";
@@ -23,6 +23,104 @@ const LOCK_WAIT_MS = 30 * 60 * 1000;
23
23
  // reused by something we can see; the lock is reclaimed.
24
24
  const LOCK_STALE_MS = 2 * 60 * 60 * 1000;
25
25
  const MAX_INTENTS = 20;
26
+ /** Resume only the uncompleted cleanup steps of an already receipt-bound transaction. */
27
+ async function finishRunCleanup(deps, commonDir, record, proof, children) {
28
+ const sealed = await readRunIntegrationProof(commonDir, record);
29
+ const { recovery: _recovery, ...requested } = proof;
30
+ if (sealed === null || canonicalJson(sealed) !== canonicalJson(requested))
31
+ throw finishError("RUN_INTEGRATION_PROOF_CHANGED", "Cleanup requires the exact sealed final candidate.");
32
+ const { state, integration, receipt, noteBound } = await validateRunIntegrationCleanup(commonDir, record.runId, deps.git, true);
33
+ if (state.status !== "active" || state.disableRestart || state.awaitingResume)
34
+ throw finishError("RUN_RESUME_DISABLED", "Manual resume is required before executing pending cleanup steps.");
35
+ if (state.events.some(event => event.code === "RUN_COMMAND_DENIED"))
36
+ throw finishError("RUN_COMMAND_DENIED", "Cleanup cannot erase an explicit native or organization denial.");
37
+ const expectedChildren = new Map(proof.children.map(child => [child.name, child]));
38
+ if (children.some(child => !expectedChildren.has(child.name)) || proof.children.some(child => !children.some(actual => actual.name === child.name) && !integration.completedSteps.includes("child:" + child.name)))
39
+ throw finishError("WORKTREE_CHILDREN_CHANGED", "Cleanup requires the sealed child set or recorded completed child cleanup.");
40
+ for (const child of [record, ...children]) {
41
+ const expected = child === record ? proof.head : expectedChildren.get(child.name).commit;
42
+ if (await git(deps, child.path, ["rev-parse", "HEAD"], "WORKTREE_SOURCE_MOVED", "Cannot read cleanup checkout.") !== expected ||
43
+ (child !== record && (child.runId !== record.runId || child.ownerSessionId !== record.ownerSessionId ||
44
+ child.workerId !== expectedChildren.get(child.name).workerId ||
45
+ child.workerGeneration !== expectedChildren.get(child.name).workerGeneration)))
46
+ throw finishError("WORKTREE_CHILDREN_CHANGED", "The cleanup checkout owner or candidate changed; preserve it.");
47
+ }
48
+ // This key suppresses identical removal retries, never authorizes cleanup or reuses proof.
49
+ const inputs = [];
50
+ for (const child of [record, ...children]) {
51
+ const status = await git(deps, child.path, ["status", "--porcelain=v1", "-z", "--untracked-files=all"], "WORKTREE_LOG_FAILED", "Cannot inspect cleanup inputs.");
52
+ const untracked = await deps.git(child.path, ["ls-files", "--others", "--exclude-standard", "-z"]);
53
+ if (untracked.exitCode !== 0)
54
+ throw finishError("WORKTREE_LOG_FAILED", "Cannot inspect cleanup files.");
55
+ const files = [];
56
+ for (const path of untracked.stdout.split("\0").filter(Boolean)) {
57
+ const info = await lstat(join(child.path, path));
58
+ const pid = /(?:^|\/)\.agent-ops-process-(\d+)-/u.exec(path)?.[1];
59
+ files.push([path, info.size, info.mtimeMs, info.ctimeMs, info.mode, pid === undefined ? null : alive(Number(pid))]);
60
+ }
61
+ inputs.push({ path: child.path, status, files });
62
+ }
63
+ const inputDigest = sha256(canonicalJson({ transaction: integration, inputs }));
64
+ const previous = [...state.events].reverse().find(event => event.code === "RUN_CLEANUP_BLOCKED");
65
+ if (previous?.detail !== null && previous?.detail !== undefined) {
66
+ let saved = {};
67
+ try {
68
+ saved = JSON.parse(previous.detail);
69
+ }
70
+ catch { /* Old diagnostics cannot suppress a new attempt. */ }
71
+ if (saved.inputDigest === inputDigest)
72
+ throw finishError("RUN_CLEANUP_UNCHANGED", "Cleanup inputs match the saved failure; resolve its original blocker before retrying. See run status/logs.");
73
+ }
74
+ const repository = new FileRunRepository(join(commonDir, "agent-ops/runs"), commonDir);
75
+ const step = async (name, action) => {
76
+ if (integration.completedSteps.includes(name))
77
+ return;
78
+ await readRunIntegrationProof(commonDir, record);
79
+ const { state: current } = await validateRunIntegrationCleanup(commonDir, record.runId, deps.git, name === "note");
80
+ if (current.status !== "active" || current.disableRestart || current.awaitingResume)
81
+ throw finishError("RUN_RESUME_DISABLED", "The run was paused or stopped before the pending cleanup step.");
82
+ await action();
83
+ await markRunIntegration(commonDir, record, "receipt-written", name);
84
+ };
85
+ try {
86
+ await step("note", async () => {
87
+ if (!noteBound)
88
+ await git(deps, record.mainRoot, ["notes", `--ref=${NOTES_REF}`, "add", "-m",
89
+ `${noteText(record, taskTree(receipt.tasks ?? []))}\nreceipt: ${integration.receiptPath}\nreceipt-sha256: ${integration.receiptDigest}`,
90
+ proof.head], "WORKTREE_NOTE_FAILED", "Cannot bind the sealed receipt to its candidate.");
91
+ });
92
+ const config = await deps.loadConfig(record.mainRoot);
93
+ await step("gate", async () => {
94
+ if (!config.features.completionGate.enabled)
95
+ return;
96
+ const gate = await deps.gate(record.mainRoot, config);
97
+ const fingerprint = await currentGateFingerprint(record.mainRoot, runner(deps, record.mainRoot));
98
+ for (const child of [record, ...children])
99
+ await gate.afterFinish(record.sessionId, child.path, fingerprint);
100
+ });
101
+ await step("trust", async () => await deps.trust.revoke(record.path, await deps.loadConfig(record.path)));
102
+ for (const child of children)
103
+ await step("child:" + child.name, async () => {
104
+ if ((await collectChangeSurface(runner(deps, child.path))).paths.length > 0)
105
+ throw finishError("WORKTREE_CHILDREN_CHANGED", "The child changed after integration; preserve it.");
106
+ await deps.trust.revoke(child.path, await deps.loadConfig(child.path));
107
+ await removeCheckout(deps, child, false, expectedChildren.get(child.name).commit);
108
+ });
109
+ await step("coordinator", async () => await removeCheckout(deps, record, false, proof.head));
110
+ }
111
+ catch (cause) {
112
+ // Completed steps change the retry key; preserve the latest boundary with the original cause.
113
+ const latest = (await repository.read(record.runId));
114
+ const digest = sha256(canonicalJson({ transaction: latest.integration, inputs }));
115
+ await repository.appendEvent(record.runId, { type: "diagnostic", code: "RUN_CLEANUP_BLOCKED", workerId: record.workerId, taskId: state.rootTaskId,
116
+ detail: JSON.stringify({ inputDigest: digest, code: cause instanceof AgentOpsError ? cause.code : "RUN_CLEANUP_FAILED",
117
+ message: redactSecrets(cause instanceof Error ? cause.message : "Cleanup failed; preserve the owned checkout.").slice(0, 4096) }) });
118
+ throw cause;
119
+ }
120
+ await recoverRunIntegrationAfterCleanup(commonDir, record.runId, deps.git);
121
+ return { record, mergedHead: proof.head, taskIds: (receipt.tasks ?? []).map(item => item.task.id), warnings: [],
122
+ receipt: integration.receiptPath, residualRisks: receipt.residualRisks ?? [] };
123
+ }
26
124
  /**
27
125
  * The work and the moved target touch the same lines. The rebase was undone;
28
126
  * this carries what the agent needs to redo it deliberately: the files, and
@@ -151,30 +249,6 @@ export async function withFinishLock(deps, commonDir, action) {
151
249
  });
152
250
  }
153
251
  }
154
- /**
155
- * The branch's own change, independent of where it is based: hunk positions
156
- * and blob ids move with a rebase, the change itself does not.
157
- */
158
- async function patchHash(deps, cwd, target) {
159
- const diff = await git(deps, cwd, ["diff", "--no-color", "--no-ext-diff", "--no-textconv", `${target}...HEAD`], "WORKTREE_DIFF_FAILED", "Git could not diff the branch.");
160
- return sha256(diff.split("\n")
161
- .filter((line) => !line.startsWith("index "))
162
- .map((line) => line.startsWith("@@") ? "@@" : line)
163
- .join("\n"));
164
- }
165
- async function reverify(deps, root, config) {
166
- const results = [];
167
- for (const command of config.verification.commands) {
168
- results.push(await executeConfiguredCommand(command, {
169
- cwd: command.cwd === "." ? root : await resolveContainedPath(root, command.cwd),
170
- trusted: true,
171
- ...(deps.processRunner === undefined ? {} : { runner: deps.processRunner })
172
- }));
173
- }
174
- return aggregateVerificationStatus(results) === "PASS"
175
- ? []
176
- : results.filter((result) => result.required && result.status !== "PASS").map(({ commandId }) => commandId);
177
- }
178
252
  async function targetIntents(deps, cwd, target) {
179
253
  const commits = (await git(deps, cwd, ["log", "--format=%H", `HEAD..${target}`], "WORKTREE_LOG_FAILED", "Git could not list the target-side commits.")).split("\n").filter(Boolean);
180
254
  const intents = [];
@@ -238,10 +312,16 @@ export async function finishWorktree(deps, options) {
238
312
  }
239
313
  // Display only: the integrator is at work while the finish runs.
240
314
  return await withActivity(commonDir, { kind: "finish", root: record.path, sessionId: record.sessionId, worktree: name }, async () => await withFinishLock(deps, commonDir, async () => {
241
- const sessionChildren = (await listWorktrees(deps, mainRoot))
242
- .map(({ record: child }) => child)
243
- .filter((child) => child.path !== record.path && child.sessionId === record.sessionId &&
315
+ const listing = await git(deps, mainRoot, ["worktree", "list", "--porcelain"], "WORKTREE_LIST_FAILED", "Cannot list cleanup worktrees.");
316
+ const records = await Promise.all(parseWorktreeListPorcelain(listing).map(child => readWorktreeRecord(child.path)));
317
+ const sessionChildren = records
318
+ .filter((child) => child !== null && child.path !== record.path && child.sessionId === record.sessionId &&
244
319
  (child.agentId !== undefined || (record.runId !== undefined && child.runId === record.runId)));
320
+ if (options.finalProof?.recovery === true && record.runId !== undefined) {
321
+ const saved = await new FileRunRepository(join(commonDir, "agent-ops/runs"), commonDir).read(record.runId);
322
+ if (["receipt-written", "cleaned"].includes(saved?.integration?.status ?? ""))
323
+ return await finishRunCleanup(deps, commonDir, record, options.finalProof, sessionChildren);
324
+ }
245
325
  if (options.finalProof === undefined && sessionChildren.length > 0) {
246
326
  throw finishError("WORKTREE_CHILDREN_REQUIRE_ADVANCE", "This session has child worktrees; run task advance for integrated final evidence before finish.");
247
327
  }
@@ -346,6 +426,25 @@ export async function finishWorktree(deps, options) {
346
426
  throw finishError("WORKTREE_TASK_INCOMPLETE", `The worktree's task is not ready to merge (${problem.code}). ${problem.remedy ?? ""}`.trim());
347
427
  }
348
428
  };
429
+ // A final proof already refused a moved target above. A plain finish
430
+ // rebases for the caller but never delivers the rebased combination: its
431
+ // evidence and review covered the old base, not what would merge.
432
+ if (options.finalProof === undefined &&
433
+ (await deps.git(record.path, ["merge-base", "--is-ancestor", target, "HEAD"])).exitCode !== 0) {
434
+ const rebase = await deps.git(record.path, ["rebase", "--no-autostash", target]);
435
+ if (rebase.exitCode !== 0) {
436
+ const files = (await deps.git(record.path, ["diff", "--name-only", "--diff-filter=U"])).stdout
437
+ .split("\n").map((line) => line.trim()).filter(Boolean);
438
+ await deps.git(record.path, ["rebase", "--abort"]);
439
+ await deps.git(record.path, ["reset", "--hard", headBeforeCompletion]);
440
+ throw new WorktreeConflictError(record, target, headBeforeCompletion, files, await targetIntents(deps, record.path, target));
441
+ }
442
+ const completed = tree.filter(({ task }) => task.status === "complete").map(({ task }) => task.task.id);
443
+ throw finishError("WORKTREE_REBASED", `${record.targetBranch} moved, so ${record.branch} was rebased onto ${target}; its earlier verification and review do not cover the result. ` +
444
+ (completed.length === 0
445
+ ? `Verify and review again with --base ${target}, then finish again.`
446
+ : `Completed task(s) ${completed.join(", ")} cannot take new evidence: create a follow-up task in this worktree covering the rebased change, verify and review it with --base ${target}, then finish again.`));
447
+ }
349
448
  let plainReceipt;
350
449
  let plainReceiptWarning;
351
450
  if (options.finalProof === undefined) {
@@ -365,36 +464,7 @@ export async function finishWorktree(deps, options) {
365
464
  plainReceiptWarning = `receipt: ${error instanceof Error ? error.message : String(error)}`;
366
465
  }
367
466
  }
368
- const head = await git(deps, record.path, ["rev-parse", "HEAD"], "WORKTREE_LOG_FAILED", "Git could not read HEAD.");
369
- const fastForward = (await deps.git(record.path, ["merge-base", "--is-ancestor", target, "HEAD"])).exitCode === 0;
370
- if (options.finalProof !== undefined && !fastForward) {
371
- throw finishError("WORKTREE_TARGET_MOVED", "The target branch moved after final review.");
372
- }
373
- if (!fastForward) {
374
- const before = await patchHash(deps, record.path, target);
375
- const rebase = await deps.git(record.path, ["rebase", "--no-autostash", target]);
376
- if (rebase.exitCode !== 0) {
377
- const files = (await deps.git(record.path, ["diff", "--name-only", "--diff-filter=U"])).stdout
378
- .split("\n").map((line) => line.trim()).filter(Boolean);
379
- await deps.git(record.path, ["rebase", "--abort"]);
380
- await deps.git(record.path, ["reset", "--hard", head]);
381
- throw new WorktreeConflictError(record, target, head, files, await targetIntents(deps, record.path, target));
382
- }
383
- const reset = async () => { await deps.git(record.path, ["reset", "--hard", head]); };
384
- if (await patchHash(deps, record.path, target) !== before) {
385
- await reset();
386
- throw finishError("WORKTREE_REBASE_CHANGED", `Rebasing onto ${target} changed the branch's own diff, so the review no longer covers it. The branch was reset; rebase it yourself and review the result as a new task.`);
387
- }
388
- if (await deps.trust.status(record.path, worktreeConfig) !== "TRUSTED") {
389
- await reset();
390
- throw finishError("WORKTREE_UNTRUSTED", "Re-verifying the rebased branch runs repository commands; run agent-ops trust grant in the worktree.");
391
- }
392
- const failed = await reverify(deps, record.path, worktreeConfig);
393
- if (failed.length > 0) {
394
- await reset();
395
- throw finishError("WORKTREE_REVERIFY_FAILED", `After rebasing onto ${target}, verification failed: ${failed.join(", ")}. The branch was reset; fix it as a new task and finish again.`);
396
- }
397
- }
467
+ const head = headBeforeCompletion;
398
468
  const sealedIntegration = options.finalProof === undefined || record.runId === undefined
399
469
  ? null
400
470
  : await readRunIntegrationReceiptBinding(commonDir, record, { target, candidate: head });
@@ -481,7 +551,7 @@ export async function finishWorktree(deps, options) {
481
551
  await step();
482
552
  }
483
553
  catch (error) {
484
- const message = `${label}: ${error instanceof Error ? error.message : String(error)}`;
554
+ const message = `${label}: ${error instanceof AgentOpsError ? error.code + ": " : ""}${error instanceof Error ? error.message : String(error)}`;
485
555
  if (options.finalProof !== undefined) {
486
556
  throw finishError("WORKTREE_FINISH_PARTIAL", `Code merged at ${mergedHead} with receipt ${receipt?.path}; ${message}`);
487
557
  }
@@ -508,9 +578,13 @@ export async function finishWorktree(deps, options) {
508
578
  }
509
579
  });
510
580
  }
581
+ if (receiptOptions !== undefined)
582
+ await markRunIntegration(commonDir, record, "receipt-written", "gate");
511
583
  // Office is display-only: its room closing never fails or partially fails a finish.
512
584
  await markOfficeSessionCompleted(commonDir, record.sessionId).catch(() => undefined);
513
585
  await attempt("trust", async () => await deps.trust.revoke(record.path, worktreeConfig));
586
+ if (receiptOptions !== undefined)
587
+ await markRunIntegration(commonDir, record, "receipt-written", "trust");
514
588
  if (options.finalProof !== undefined) {
515
589
  for (const child of sessionChildren) {
516
590
  await attempt(`child ${child.name}`, async () => {
@@ -524,7 +598,7 @@ export async function finishWorktree(deps, options) {
524
598
  await attempt("remove", async () => await removeCheckout(deps, record, options.finalProof === undefined, options.finalProof?.head));
525
599
  if (receiptOptions !== undefined)
526
600
  await markRunIntegration(commonDir, record, "cleaned", "coordinator");
527
- return { record, mergedHead, rebased: !fastForward, taskIds: tree.map(({ task }) => task.task.id), warnings,
601
+ return { record, mergedHead, taskIds: tree.map(({ task }) => task.task.id), warnings,
528
602
  ...(receipt === undefined ? {} : { receipt: receipt.path, residualRisks: receipt.receipt.residualRisks }) };
529
603
  }));
530
604
  }
@@ -192,11 +192,8 @@ export async function resumeWorktree(deps, options) {
192
192
  const sessionId = assertSessionId(options.sessionId);
193
193
  const mainRoot = await mainCheckout(deps, options.cwd);
194
194
  const previous = await requireRecord(mainRoot, name);
195
- // A base gives the service repository context, which debug resume needs.
196
195
  const tasks = deps.tasks(previous.path, previous.base);
197
196
  const attached = await tasks.status({ sessionId: previous.sessionId }).catch(() => null);
198
- if (attached?.task.debug !== undefined)
199
- await tasks.debugService().resume(attached.task.id, sessionId);
200
197
  if (attached !== null)
201
198
  await tasks.attach(sessionId, attached.task.id);
202
199
  const record = { ...previous, sessionId };
@@ -5,7 +5,7 @@ import { sha256 } from "../fs/hash.js";
5
5
  import { parseInstallManifest } from "../fs/manifest.js";
6
6
  import { AgentOpsError } from "../fs/paths.js";
7
7
  import { writeSessionMarker } from "../hooks/codex-loop.js";
8
- import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
8
+ import { cleanupWorktreeHeartbeats, readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
9
9
  /**
10
10
  * One Git worktree per writing session, so parallel sessions stop sharing the
11
11
  * change surface every fingerprint is taken over. Worktrees live inside the
@@ -256,6 +256,10 @@ export async function bindSession(deps, record, mainConfig, baselineFingerprint)
256
256
  await mainGate.addRoot(record.sessionId, record.path);
257
257
  }
258
258
  export async function removeCheckout(deps, record, force, expectedHead) {
259
+ if (!force) {
260
+ const untracked = await git(deps, record.path, ["ls-files", "--others", "--exclude-standard", "-z", "--", ".claude/", ".codex/"], "WORKTREE_REMOVE_FAILED", "Git could not inspect runtime heartbeat paths.");
261
+ await cleanupWorktreeHeartbeats(record.path, untracked.split("\0").filter(Boolean));
262
+ }
259
263
  await git(deps, record.mainRoot, ["worktree", "remove", ...(force ? ["--force"] : []), record.path], "WORKTREE_REMOVE_FAILED", `Git could not remove ${record.path}.`);
260
264
  await git(deps, record.mainRoot, expectedHead === undefined
261
265
  ? ["branch", force ? "-D" : "-d", record.branch]
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { chmod, copyFile, lstat, mkdir, mkdtemp, realpath, rm, stat, writeFile } from "node:fs/promises";
2
+ import { chmod, copyFile, lstat, mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  import { runVerificationCommand } from "../verify/spawn.js";
@@ -18,7 +18,7 @@ import { extractUsage, sumUsage } from "./usage.js";
18
18
  * claude both exceeded it on this repository, and a timeout costs the whole
19
19
  * review while looking like an unavailable target. Fifteen minutes was not
20
20
  * enough either: agy rounds took 450-900 s and timed out at 900 s; the stall
21
- * guards still stop a reviewer that goes quiet.
21
+ * guards still stop reviewers whose transports expose progress.
22
22
  */
23
23
  export const DEFAULT_REVIEW_TIMEOUT_MS = 1_800_000;
24
24
  /**
@@ -33,9 +33,18 @@ export const DEFAULT_STALL_IDLE_MS = 90_000;
33
33
  /**
34
34
  * agy writes nothing to stdout, stderr or its log while one generation
35
35
  * streams, so a long think is indistinguishable from a wedge. Its own logs
36
- * show normal generations silent for up to ~177s; 90s cut real reviews off.
36
+ * cannot distinguish a long generation from a wedge. Only the configured
37
+ * round timeout or caller cancellation may stop a silent agy review.
37
38
  */
38
- export const AGY_STALL_IDLE_MS = 240_000;
39
+ export const AGY_STALL_IDLE_MS = Number.POSITIVE_INFINITY;
40
+ /**
41
+ * agy logs this once its turn is over and the answer is on stdout. Some runs
42
+ * then never exit (the language server shutdown hangs), which would hold a
43
+ * finished round until the full round timeout and report it as NOT_RUN.
44
+ */
45
+ const AGY_TURN_FINISHED = /Stream goroutine exited for \S+, sending completion signal/u;
46
+ /** How long a finished agy turn may take to exit before it is stopped. */
47
+ export const AGY_EXIT_GRACE_MS = 30_000;
39
48
  /**
40
49
  * A call the service dropped before answering. It produced no verdict, so
41
50
  * one fresh retry is not review shopping.
@@ -221,10 +230,10 @@ function watchForStall(logFile, parent, idleMs) {
221
230
  controller.abort("stalled");
222
231
  }
223
232
  };
224
- const timer = setInterval(() => {
233
+ const timer = Number.isFinite(idleMs) ? setInterval(() => {
225
234
  void check();
226
- }, pollMs);
227
- timer.unref();
235
+ }, pollMs) : undefined;
236
+ timer?.unref();
228
237
  return {
229
238
  signal: parent === undefined
230
239
  ? controller.signal
@@ -232,10 +241,60 @@ function watchForStall(logFile, parent, idleMs) {
232
241
  stalled: () => stalled,
233
242
  beat,
234
243
  stop: () => {
235
- clearInterval(timer);
244
+ if (timer !== undefined)
245
+ clearInterval(timer);
236
246
  }
237
247
  };
238
248
  }
249
+ /**
250
+ * Stops an agy round that finished its turn but did not exit. Only the log's
251
+ * completion line starts the grace window, so a long silent generation is
252
+ * never cut off; the round timeout still caps a reviewer that never finishes.
253
+ */
254
+ function watchForFinishedTurn(logFile, graceMs) {
255
+ const controller = new AbortController();
256
+ let fired = false;
257
+ let grace;
258
+ const check = async () => {
259
+ if (logFile === undefined || grace !== undefined)
260
+ return;
261
+ try {
262
+ if (!AGY_TURN_FINISHED.test(await readFile(logFile, "utf8")))
263
+ return;
264
+ }
265
+ catch {
266
+ return;
267
+ }
268
+ if (grace !== undefined)
269
+ return;
270
+ clearInterval(poll);
271
+ grace = setTimeout(() => {
272
+ fired = true;
273
+ controller.abort("finished-without-exit");
274
+ }, graceMs);
275
+ grace.unref();
276
+ };
277
+ const poll = setInterval(() => {
278
+ void check();
279
+ }, Math.max(20, Math.min(5_000, Math.floor(graceMs / 3))));
280
+ poll.unref();
281
+ return {
282
+ signal: controller.signal,
283
+ fired: () => fired,
284
+ stop: () => {
285
+ clearInterval(poll);
286
+ if (grace !== undefined)
287
+ clearTimeout(grace);
288
+ }
289
+ };
290
+ }
291
+ /** A finished agy turn that was stopped for not exiting counts as completed. */
292
+ function finishedTurnCompletion(target, spawned, watch, graceMs, options) {
293
+ if (!watch.fired() || spawned.failureClass !== "aborted" || options.signal?.aborted === true)
294
+ return spawned;
295
+ options.onProgress?.(`${target}: finished its turn but did not exit within ${Math.max(1, Math.round(graceMs / 1_000))}s → stopped it and read its answer`);
296
+ return { ...spawned, failureClass: "none" };
297
+ }
239
298
  function throwIfInterrupted(target, options, failureClass) {
240
299
  if (failureClass !== "aborted" && options.signal?.aborted !== true) {
241
300
  return;
@@ -505,6 +564,8 @@ async function attemptTarget(request, options) {
505
564
  const stallIdleMs = options.stallIdleMs ??
506
565
  (target === "agy" ? AGY_STALL_IDLE_MS : DEFAULT_STALL_IDLE_MS);
507
566
  const stallWatch = watchForStall(heartbeatLog, options.signal, stallIdleMs);
567
+ const exitGraceMs = options.agyExitGraceMs ?? AGY_EXIT_GRACE_MS;
568
+ const finishedWatch = watchForFinishedTurn(agyLog, exitGraceMs);
508
569
  let spawned;
509
570
  try {
510
571
  spawned = await runVerificationCommand({
@@ -524,13 +585,15 @@ async function attemptTarget(request, options) {
524
585
  stdin: invocation.stdin,
525
586
  env: environment,
526
587
  replaceEnv: true,
527
- signal: stallWatch.signal,
588
+ signal: AbortSignal.any([stallWatch.signal, finishedWatch.signal]),
528
589
  onActivity: stallWatch.beat
529
590
  });
530
591
  }
531
592
  finally {
532
593
  stallWatch.stop();
594
+ finishedWatch.stop();
533
595
  }
596
+ spawned = finishedTurnCompletion(target, spawned, finishedWatch, exitGraceMs, options);
534
597
  // Ahead of `throwIfInterrupted`, which reads the same `aborted` failure
535
598
  // class as a user interrupt. A stall is this executor's own abort, and
536
599
  // ends one target rather than the whole review.
@@ -605,6 +668,7 @@ async function attemptTarget(request, options) {
605
668
  if (nudge !== undefined) {
606
669
  options.onProgress?.(`${target}: turn ended without answering${denied.length > 0 ? ` (denied ${denied.join(", ")})` : ""} → resuming the conversation once`);
607
670
  const nudgeWatch = watchForStall(nudgeLog, options.signal, stallIdleMs);
671
+ const nudgeFinished = watchForFinishedTurn(nudgeLog, exitGraceMs);
608
672
  let second;
609
673
  try {
610
674
  second = await runVerificationCommand({
@@ -624,13 +688,15 @@ async function attemptTarget(request, options) {
624
688
  stdin: nudge.stdin,
625
689
  env: environment,
626
690
  replaceEnv: true,
627
- signal: nudgeWatch.signal,
691
+ signal: AbortSignal.any([nudgeWatch.signal, nudgeFinished.signal]),
628
692
  onActivity: nudgeWatch.beat
629
693
  });
630
694
  }
631
695
  finally {
632
696
  nudgeWatch.stop();
697
+ nudgeFinished.stop();
633
698
  }
699
+ second = finishedTurnCompletion(target, second, nudgeFinished, exitGraceMs, options);
634
700
  usage = sumUsage(usage, extractUsage(target, second.stdout, second.stderr));
635
701
  throwIfInterrupted(target, options, second.failureClass);
636
702
  nudged = { log: nudgeLog, stdout: second.stdout };
@@ -710,7 +776,7 @@ export function createReviewExecutor(options) {
710
776
  const clock = options.now ?? (() => Date.now());
711
777
  return async (request) => {
712
778
  const deadlineAt = clock() +
713
- Math.min(options.chainTimeoutMs ?? DEFAULT_REVIEW_CHAIN_TIMEOUT_MS, await options.remainingBudgetMs?.() ?? Infinity);
779
+ (options.chainTimeoutMs ?? DEFAULT_REVIEW_CHAIN_TIMEOUT_MS);
714
780
  const remainingMs = () => deadlineAt - clock();
715
781
  /**
716
782
  * The options one stage runs under: its own timeout, or what is left of
@@ -3,15 +3,26 @@ const MAX_PACKET_BYTES = 64 * 1024;
3
3
  export function reviewPacketData(packet) {
4
4
  const artifacts = packet.contractArtifacts?.map(({ path, digest }) => ({ path, digest }));
5
5
  let indexed = false;
6
- if (packet.contractManifest !== undefined && artifacts !== undefined) {
6
+ let sealedSurface = false;
7
+ const manifest = packet.contractManifest;
8
+ const reference = manifest === undefined ? undefined : { path: manifest.path, digest: manifest.digest };
9
+ if (manifest !== undefined && Buffer.byteLength(manifest.content, "utf8") <= 512 * 1024 &&
10
+ sha256(manifest.content) === manifest.digest &&
11
+ manifest.path === ".agent-ops/tasks/review-contracts/" + manifest.digest + ".json") {
7
12
  try {
8
- const manifest = JSON.parse(packet.contractManifest.content);
9
- indexed = JSON.stringify(manifest?.artifacts) === JSON.stringify(artifacts);
13
+ const content = JSON.parse(manifest.content);
14
+ indexed = artifacts !== undefined && JSON.stringify(content?.artifacts) === JSON.stringify(artifacts);
15
+ sealedSurface = packet.surface !== undefined &&
16
+ JSON.stringify(safeDeep(content?.surface)) === JSON.stringify(packet.surface);
10
17
  }
11
- catch { /* Legacy manifests keep inline artifact metadata. */ }
18
+ catch { /* Legacy manifests keep inline metadata. */ }
12
19
  }
13
- return { ...packet, contractArtifacts: indexed ? undefined : artifacts,
14
- contractManifest: packet.contractManifest === undefined ? undefined : { path: packet.contractManifest.path, digest: packet.contractManifest.digest } };
20
+ const data = { ...packet, contractArtifacts: indexed ? undefined : artifacts, contractManifest: reference };
21
+ // Preserve small packets; large audits remain complete in the validated snapshot manifest.
22
+ if (sealedSurface && reference !== undefined && Buffer.byteLength(JSON.stringify(data), "utf8") > MAX_PACKET_BYTES) {
23
+ return { ...data, surface: { manifest: reference, field: "surface", digest: sha256(JSON.stringify(packet.surface)) } };
24
+ }
25
+ return data;
15
26
  }
16
27
  function safe(value) {
17
28
  return safeTaskText(redactSecrets(value));
@@ -87,6 +87,8 @@ function taskDataBlock(invocation) {
87
87
  "BEGIN_TASK_DATA",
88
88
  JSON.stringify(data),
89
89
  ...(invocation.packet.contractManifest === undefined ? [] : ["Read the complete contract manifest at " + invocation.packet.contractManifest.path + ". Assess coverage and every revision against its immutable original goal. Review whether verifier materials actually test the requirement and load product implementation rather than replacement helpers. Review-only and UNKNOWN are not mechanical proof."]),
90
+ ...(invocation.packet.surface !== undefined && invocation.packet.contractManifest !== undefined ?
91
+ ["Read the complete surface audit and revision history, including discovery and post-block exclusions, from the manifest surface field when task data references it."] : []),
90
92
  ...(data.contractArtifacts === undefined && invocation.packet.contractArtifacts !== undefined ?
91
93
  ["The complete evidence artifact path/digest index is in that manifest; inspect supporting evidence via those paths."] : []),
92
94
  "END_TASK_DATA"
@@ -1,10 +1,108 @@
1
1
  import { sha256 } from "../fs/hash.js";
2
2
  import { AgentOpsError } from "../fs/paths.js";
3
- import { canonicalJson } from "../config/hash.js";
3
+ import { canonicalJson, calculateConfigHash } from "../config/hash.js";
4
+ import { readRunPolicy } from "./policy.js";
4
5
  import { join } from "node:path";
5
6
  import { readPrivateFile, writePrivateFile } from "../security/permissions.js";
6
7
  import { redactSecrets } from "../security/redact.js";
7
8
  const MAX_REASON_LENGTH = 4_096;
9
+ const RETRY_APPROVAL = "retry-approval:";
10
+ const AUTH_ID = /^auth-[a-f0-9]{64}$/u;
11
+ const DENIAL_DIAGNOSTIC = "Native authorization could not be accepted; no alternate permission path is allowed.";
12
+ /** A manual resume covers initial recovery once, never a subsequent supervisor restart. */
13
+ export function nativeResumeTime(state, now = Date.now()) {
14
+ const control = state.controls.at(-1);
15
+ if (control?.action !== "resume" || control.actor !== "user" || control.appliedAt !== null)
16
+ return undefined;
17
+ const time = Date.parse(control.requestedAt);
18
+ if (!Number.isFinite(time) || !Number.isFinite(now) || time > now ||
19
+ state.events.some(e => e.code === "RUN_SUPERVISOR_START" && (!Number.isFinite(Date.parse(e.at)) || Date.parse(e.at) >= time)))
20
+ return undefined;
21
+ return control.requestedAt;
22
+ }
23
+ /** An explicit retry acknowledges a failed reviewer, never a command permission. */
24
+ export async function approvalRetryEvents(state, requested) {
25
+ const fail = () => { throw error("RUN_NOT_RESUMABLE", "Approval retry requires the original, intact Codex usage-limit review failure."); };
26
+ if (!Array.isArray(requested) || requested.some(id => typeof id !== "string" || !AUTH_ID.test(id)) || new Set(requested).size !== requested.length)
27
+ fail();
28
+ const prior = new Set();
29
+ for (const control of state.controls) {
30
+ if (control.action !== "resume" || control.actor !== "user" || !control.reason?.startsWith(RETRY_APPROVAL))
31
+ continue;
32
+ let ids;
33
+ try {
34
+ ids = JSON.parse(control.reason.slice(RETRY_APPROVAL.length));
35
+ }
36
+ catch {
37
+ return fail();
38
+ }
39
+ if (!Array.isArray(ids) || ids.some(id => typeof id !== "string" || !AUTH_ID.test(id)))
40
+ fail();
41
+ for (const id of ids)
42
+ prior.add(id);
43
+ }
44
+ const ignored = new Set();
45
+ for (const id of new Set([...prior, ...requested])) {
46
+ const index = state.events.findIndex(e => e.id === id);
47
+ const event = state.events[index];
48
+ if (event?.type !== "control" || event.code !== "RUN_COMMAND_DENIED")
49
+ fail();
50
+ const path = join(state.commonDir, "agent-ops/runs", state.runId, "authorizations", id + ".json");
51
+ let ref;
52
+ let authorization;
53
+ try {
54
+ ref = JSON.parse(event.detail);
55
+ const source = await readPrivateFile(path, state.commonDir);
56
+ if (ref.artifact !== path || ref.result !== "denied" || ref.source !== "codex-auto-review" || source === null ||
57
+ Buffer.byteLength(source) > 128 * 1024 || sha256(source) !== ref.digest)
58
+ fail();
59
+ authorization = JSON.parse(source);
60
+ }
61
+ catch {
62
+ return fail();
63
+ }
64
+ const a = authorization;
65
+ if (a === null || typeof a !== "object")
66
+ fail();
67
+ const worker = state.workers.find(w => w.workerId === event.workerId);
68
+ if (a.runId !== state.runId || a.workerId !== event.workerId || worker === undefined || event.taskId !== worker.taskId ||
69
+ !Number.isSafeInteger(a.generation) || a.generation < 1 || a.generation > worker.generation ||
70
+ (!prior.has(id) && (a.generation !== worker.generation || a.nativeSessionId !== worker.nativeSessionId)) ||
71
+ a.source !== "codex-auto-review" || a.result !== "denied" || a.operation !== "command" ||
72
+ typeof a.reason !== "string" || !/^Automatic approval review failed: You[’']ve hit your usage limit(?:[.!]|$)/u.test(a.reason) ||
73
+ typeof a.command !== "string" || typeof a.resource !== "string" ||
74
+ !/^[a-f0-9]{64}$/u.test(a.commandDigest) || !/^[a-f0-9]{64}$/u.test(a.resourceDigest) ||
75
+ typeof a.authorizationId !== "string" || a.authorizationId.length === 0 || a.authorizationId.length > 256 ||
76
+ !(a.nativeSessionId === null || typeof a.nativeSessionId === "string") ||
77
+ id !== "auth-" + sha256(canonicalJson({ runId: state.runId, workerId: a.workerId, generation: a.generation,
78
+ nativeSessionId: a.nativeSessionId, source: a.source, id: a.authorizationId })))
79
+ fail();
80
+ // Command/resource strings were redacted when saved; their original digests cannot be recomputed.
81
+ let binding = state.policyBinding;
82
+ let matched = false;
83
+ for (let depth = 0; binding !== undefined && depth < 128; depth++) {
84
+ const policy = await readRunPolicy({ ...state, policyBinding: binding }, binding.runtimeHash, Date.now(), true);
85
+ if (binding.artifactDigest === a.policyArtifactDigest) {
86
+ matched = binding.configHash === a.policyConfigHash && binding.runtimeHash === a.runtimeHash && binding.expiresAt === a.expiresAt;
87
+ break;
88
+ }
89
+ if (policy?.previousDigest == null)
90
+ break;
91
+ const source = await readPrivateFile(join(state.commonDir, "agent-ops/runs", state.runId, "policies", policy.previousDigest + ".json"), state.commonDir);
92
+ if (source === null || sha256(source) !== policy.previousDigest)
93
+ fail();
94
+ const previous = JSON.parse(source);
95
+ binding = { artifactDigest: policy.previousDigest, configHash: calculateConfigHash(previous.config), runtimeHash: previous.runtimeHash, expiresAt: previous.expiresAt };
96
+ }
97
+ if (!matched)
98
+ fail();
99
+ ignored.add(id);
100
+ const next = state.events.slice(index + 1).find(e => e.workerId === event.workerId && e.code === "RUN_COMMAND_DENIED");
101
+ if (next?.type === "diagnostic" && next.taskId === null && next.detail === DENIAL_DIAGNOSTIC)
102
+ ignored.add(next.id);
103
+ }
104
+ return ignored;
105
+ }
8
106
  function error(code, message) {
9
107
  return new AgentOpsError(code, message);
10
108
  }