@kylecheng3146/agent-ops 0.5.3 → 0.6.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 (93) hide show
  1. package/README.md +48 -0
  2. package/dist/packages/cli/src/args.js +165 -2
  3. package/dist/packages/cli/src/bin.js +64 -17
  4. package/dist/packages/cli/src/cli.js +33 -6
  5. package/dist/packages/cli/src/commands/advance.js +23 -3
  6. package/dist/packages/cli/src/commands/review-show.js +1 -1
  7. package/dist/packages/cli/src/commands/review.js +100 -9
  8. package/dist/packages/cli/src/commands/run.js +111 -0
  9. package/dist/packages/cli/src/commands/task.js +61 -17
  10. package/dist/packages/cli/src/commands/verify.js +1 -0
  11. package/dist/packages/cli/src/context.js +63 -1
  12. package/dist/packages/cli/src/hook-process.js +31 -1
  13. package/dist/packages/cli/src/owned-run-step.js +65 -0
  14. package/dist/packages/cli/src/parallel-deps.js +18 -5
  15. package/dist/packages/cli/src/run-deps.js +314 -0
  16. package/dist/packages/cli/src/run-entry.js +943 -0
  17. package/dist/packages/cli/src/run-setup-entry.js +32 -0
  18. package/dist/packages/cli/src/run-step-entry.js +58 -0
  19. package/dist/runtime/src/config/explain.js +6 -0
  20. package/dist/runtime/src/config/hash.js +1 -1
  21. package/dist/runtime/src/config/merge.js +9 -1
  22. package/dist/runtime/src/contracts.js +2 -2
  23. package/dist/runtime/src/hooks/completion-gate.js +8 -2
  24. package/dist/runtime/src/install/doctor.js +2 -0
  25. package/dist/runtime/src/parallel/finish.js +85 -22
  26. package/dist/runtime/src/parallel/guard.js +5 -1
  27. package/dist/runtime/src/parallel/integrate.js +288 -10
  28. package/dist/runtime/src/parallel/manage.js +4 -1
  29. package/dist/runtime/src/parallel/receipt.js +71 -5
  30. package/dist/runtime/src/parallel/service.js +41 -1
  31. package/dist/runtime/src/review/attestation.js +36 -11
  32. package/dist/runtime/src/review/execute.js +24 -1
  33. package/dist/runtime/src/review/packet.js +35 -1
  34. package/dist/runtime/src/review/runner.js +12 -1
  35. package/dist/runtime/src/review/scope.js +18 -1
  36. package/dist/runtime/src/review/slots.js +35 -10
  37. package/dist/runtime/src/run/authorization.js +60 -0
  38. package/dist/runtime/src/run/budget.js +30 -0
  39. package/dist/runtime/src/run/controls.js +139 -0
  40. package/dist/runtime/src/run/convergence.js +177 -0
  41. package/dist/runtime/src/run/hosts/claude.js +180 -0
  42. package/dist/runtime/src/run/hosts/codex.js +279 -0
  43. package/dist/runtime/src/run/hosts/index.js +4 -0
  44. package/dist/runtime/src/run/hosts/protocol.js +254 -0
  45. package/dist/runtime/src/run/hosts/types.js +1 -0
  46. package/dist/runtime/src/run/hosts/util.js +136 -0
  47. package/dist/runtime/src/run/integration.js +204 -0
  48. package/dist/runtime/src/run/macOS.js +189 -0
  49. package/dist/runtime/src/run/ownership.js +22 -0
  50. package/dist/runtime/src/run/planning.js +25 -0
  51. package/dist/runtime/src/run/policy-transition.js +1076 -0
  52. package/dist/runtime/src/run/policy.js +176 -0
  53. package/dist/runtime/src/run/ratchet.js +71 -0
  54. package/dist/runtime/src/run/scheduler.js +260 -0
  55. package/dist/runtime/src/run/service.js +425 -0
  56. package/dist/runtime/src/run/supervisor.js +635 -0
  57. package/dist/runtime/src/run/transport.js +159 -0
  58. package/dist/runtime/src/run/types.js +1 -0
  59. package/dist/runtime/src/run/usage.js +81 -0
  60. package/dist/runtime/src/run/verification.js +64 -0
  61. package/dist/runtime/src/run/worker-gate.js +48 -0
  62. package/dist/runtime/src/schema/acceptance.js +91 -0
  63. package/dist/runtime/src/schema/validate.js +67 -7
  64. package/dist/runtime/src/task/completion.js +14 -2
  65. package/dist/runtime/src/task/contract.js +17 -0
  66. package/dist/runtime/src/task/pin-finding.js +81 -0
  67. package/dist/runtime/src/task/service.js +316 -13
  68. package/dist/runtime/src/task/store.js +57 -7
  69. package/dist/runtime/src/verify/acceptance-coverage.js +85 -0
  70. package/dist/runtime/src/verify/acceptance-protocol.js +252 -0
  71. package/dist/runtime/src/verify/acceptance-replay.js +239 -0
  72. package/dist/runtime/src/verify/adapters/index.js +5 -0
  73. package/dist/runtime/src/verify/adapters/jest.js +142 -0
  74. package/dist/runtime/src/verify/adapters/node-reporter.js +20 -0
  75. package/dist/runtime/src/verify/adapters/node.js +254 -0
  76. package/dist/runtime/src/verify/adapters/pytest.js +188 -0
  77. package/dist/runtime/src/verify/adapters/rust.js +84 -0
  78. package/dist/runtime/src/verify/adapters/shared.js +82 -0
  79. package/dist/runtime/src/verify/adapters/vitest.js +218 -0
  80. package/dist/runtime/src/verify/evidence.js +5 -2
  81. package/dist/runtime/src/verify/service.js +24 -8
  82. package/dist/runtime/src/verify/source-fingerprint.js +1 -1
  83. package/dist/runtime/src/verify/spawn.js +13 -2
  84. package/docs/en/spec/acceptance-and-evidence.md +48 -0
  85. package/docs/en/spec/loop-engineering.md +49 -0
  86. package/docs/zh-TW/spec/acceptance-and-evidence.md +21 -1
  87. package/docs/zh-TW/spec/loop-engineering.md +22 -0
  88. package/package.json +1 -1
  89. package/schemas/config.schema.json +221 -19
  90. package/schemas/evidence.schema.json +129 -6
  91. package/schemas/task.schema.json +265 -6
  92. package/templates/acceptance/agent_ops_acceptance.rs +36 -0
  93. package/templates/acceptance/agent_ops_pytest.py +46 -0
@@ -1,10 +1,15 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readdir } from "node:fs/promises";
1
3
  import { join } from "node:path";
2
4
  import { calculateConfigHash } from "../config/hash.js";
3
5
  import { AgentOpsError } from "../fs/paths.js";
6
+ import { sha256 } from "../fs/hash.js";
4
7
  import { resolveReviewScope } from "../review/scope.js";
5
8
  import { readPrivateFile, writePrivateFile } from "../security/permissions.js";
6
9
  import { checkTaskVerificationEvidence } from "../task/completion.js";
10
+ import { validateEvidence } from "../schema/validate.js";
7
11
  import { FileEvidenceStore } from "../verify/evidence.js";
12
+ import { REVIEW_ATTESTATION_DIRECTORY } from "../review/attestation.js";
8
13
  import { calculateSourceFingerprint } from "../verify/source-fingerprint.js";
9
14
  import { collectChangeSurface } from "../verify/change-surface.js";
10
15
  import { listWorktrees } from "./manage.js";
@@ -26,6 +31,111 @@ function runner(deps, cwd) {
26
31
  function markerPath(record) {
27
32
  return join(record.path, ".agent-ops", "tasks", "integrated-children.json");
28
33
  }
34
+ function journalPath(record) {
35
+ return join(record.path, ".agent-ops", "tasks", "integration-journal.json");
36
+ }
37
+ function deliveryPath(record) {
38
+ return join(record.path, ".agent-ops", "tasks", "delivery.json");
39
+ }
40
+ function deliveryArchivePath(record, child) {
41
+ return join(record.path, ".agent-ops", "tasks", "deliveries", `${child.name}.json`);
42
+ }
43
+ async function readJournal(record) {
44
+ const source = await readPrivateFile(journalPath(record), record.path);
45
+ if (source === null)
46
+ return null;
47
+ try {
48
+ const value = JSON.parse(source);
49
+ if (value.schemaVersion !== 1 || typeof value.transactionId !== "string" || value.transactionId.length === 0 || value.transactionId.length > 256 ||
50
+ !/^[a-f0-9]{40,64}$/u.test(value.expectedTarget ?? "") ||
51
+ !/^[a-f0-9]{40,64}$/u.test(value.coordinatorBefore ?? "") ||
52
+ !["prepared", "merging", "importing", "recorded", "rolled-back"].includes(String(value.status)) ||
53
+ !Array.isArray(value.steps) || value.steps.some((step) => typeof step !== "object" || step === null ||
54
+ typeof step.name !== "string" ||
55
+ !/^[a-f0-9]{40,64}$/u.test(step.commit ?? "") ||
56
+ !["changed", "no-change"].includes(String(step.kind)) ||
57
+ !["prepared", "merged", "imported", "recorded"].includes(String(step.status)) ||
58
+ !Array.isArray(step.taskIds) ||
59
+ !Array.isArray(step.sourceArtifacts))) {
60
+ throw new Error("invalid integration journal");
61
+ }
62
+ return value;
63
+ }
64
+ catch (cause) {
65
+ throw error("WORKTREE_INTEGRATION_JOURNAL_INVALID", "Integration journal is invalid; preserve the worktree for recovery.");
66
+ }
67
+ }
68
+ /** Read the coordinator's durable integration transaction for finish/recovery. */
69
+ export async function readIntegrationJournal(record) {
70
+ return await readJournal(record);
71
+ }
72
+ async function writeJournal(record, journal) {
73
+ await mkdir(join(record.path, ".agent-ops", "tasks"), { recursive: true });
74
+ await writePrivateFile(journalPath(record), `${JSON.stringify(journal, null, 2)}\n`, record.path);
75
+ }
76
+ /** Stable digest used by finish receipts to bind journal bookkeeping. */
77
+ export function integrationJournalDigest(journal) {
78
+ return sha256(JSON.stringify(journal));
79
+ }
80
+ /** Persist a coordinator journal update so recovery can resume the same transaction. */
81
+ export async function writeIntegrationJournal(record, journal) {
82
+ await writeJournal(record, journal);
83
+ }
84
+ async function readNoChangeDelivery(record) {
85
+ const source = await readPrivateFile(deliveryPath(record), record.path);
86
+ if (source === null)
87
+ return null;
88
+ try {
89
+ const value = JSON.parse(source);
90
+ if (value.schemaVersion !== 1 || value.deliveryKind !== "no-change" ||
91
+ typeof value.sourceCommit !== "string" || !/^[a-f0-9]{40,64}$/u.test(value.sourceCommit) ||
92
+ typeof value.deliveryDigest !== "string" || !/^[a-f0-9]{40,64}$/u.test(value.deliveryDigest) ||
93
+ typeof value.contractDigest !== "string" || !/^[a-f0-9]{40,64}$/u.test(value.contractDigest) ||
94
+ typeof value.reviewScope !== "string" || value.reviewScope.length === 0 ||
95
+ typeof value.runId !== "string" || typeof value.workerId !== "string" ||
96
+ !Number.isSafeInteger(value.generation) || value.generation < 1 ||
97
+ !Array.isArray(value.artifactRefs) || value.artifactRefs.some((ref) => typeof ref !== "string" || ref.length === 0 || ref.length > 4096 || ref.includes("\0"))) {
98
+ throw new Error("invalid no-change delivery");
99
+ }
100
+ return value;
101
+ }
102
+ catch {
103
+ throw error("WORKTREE_NO_CHANGE_DELIVERY_INVALID", `Child ${record.name} has an invalid no-change delivery manifest.`);
104
+ }
105
+ }
106
+ function parseNoChangeReviewScope(value) {
107
+ let parsed;
108
+ try {
109
+ parsed = JSON.parse(value);
110
+ }
111
+ catch {
112
+ throw error("WORKTREE_NO_CHANGE_SCOPE_INVALID", "No-change review scope is not valid JSON.");
113
+ }
114
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
115
+ throw error("WORKTREE_NO_CHANGE_SCOPE_INVALID", "No-change review scope is invalid.");
116
+ const scope = parsed;
117
+ const safePaths = (paths) => Array.isArray(paths) && paths.length > 0 && paths.every((path) => {
118
+ if (typeof path !== "string" || path.length === 0 || path.includes("\\") || path.startsWith("/") || path.includes("\0"))
119
+ return false;
120
+ return path.split("/").every((segment) => segment.length > 0 && segment !== "." && segment !== "..");
121
+ });
122
+ const noChange = scope.noChange === true;
123
+ if (scope.mode === "worktree" && safePaths(scope.changedFiles)) {
124
+ return { mode: "worktree", changedFiles: scope.changedFiles, ...(noChange ? { noChange: true } : {}) };
125
+ }
126
+ if (scope.mode === "base" && typeof scope.baseRef === "string" && typeof scope.resolvedBase === "string" &&
127
+ /^[a-f0-9]{40,64}$/u.test(scope.resolvedBase) && safePaths(scope.changedFiles)) {
128
+ return { mode: "base", baseRef: scope.baseRef, resolvedBase: scope.resolvedBase, changedFiles: scope.changedFiles, ...(noChange ? { noChange: true } : {}) };
129
+ }
130
+ throw error("WORKTREE_NO_CHANGE_SCOPE_INVALID", "No-change review scope must list at least one safe supporting path.");
131
+ }
132
+ /** Persist a coordinator-issued no-change handoff before the worker is fenced. */
133
+ export async function writeNoChangeDelivery(record, delivery) {
134
+ if (delivery.runId !== record.runId || delivery.workerId !== record.workerId || delivery.generation !== record.workerGeneration) {
135
+ throw error("WORKTREE_NO_CHANGE_OWNERSHIP", `No-change delivery does not match worktree ${record.name}.`);
136
+ }
137
+ await writePrivateFile(deliveryPath(record), `${JSON.stringify(delivery, null, 2)}\n`, record.path);
138
+ }
29
139
  async function previous(record) {
30
140
  const source = await readPrivateFile(markerPath(record), record.path);
31
141
  if (source === null)
@@ -46,10 +156,12 @@ async function previous(record) {
46
156
  return value;
47
157
  }
48
158
  /** Integrate only this session's agent-owned branches; local proof is never promoted as final proof. */
49
- export async function integrateSessionChildren(deps, coordinator, parentTaskId) {
159
+ export async function integrateSessionChildren(deps, coordinator, parentTaskId, eligibleRunWorkerIds) {
50
160
  const statuses = await listWorktrees(deps, coordinator.mainRoot);
51
161
  const children = statuses.map(({ record }) => record)
52
- .filter((record) => record.sessionId === coordinator.sessionId && record.agentId !== undefined)
162
+ .filter((record) => record.name !== coordinator.name && record.sessionId === coordinator.sessionId &&
163
+ (record.agentId !== undefined || (coordinator.runId !== undefined && record.runId === coordinator.runId)))
164
+ .filter(record => eligibleRunWorkerIds === undefined || (record.workerId !== undefined && eligibleRunWorkerIds.includes(record.workerId)))
53
165
  .sort((a, b) => a.name.localeCompare(b.name));
54
166
  const integrated = await previous(coordinator);
55
167
  if (children.length === 0)
@@ -74,9 +186,18 @@ export async function integrateSessionChildren(deps, coordinator, parentTaskId)
74
186
  throw error("WORKTREE_CHILD_MOVED", `Child ${record.name} changed after delivery; keep its worktree and replan integration.`);
75
187
  continue;
76
188
  }
77
- if ((await deps.git(record.path, ["merge-base", "--is-ancestor", record.base, head])).exitCode !== 0 ||
78
- Number(await git(deps, record.path, ["rev-list", "--count", `${record.base}..${head}`])) === 0) {
189
+ const ahead = Number(await git(deps, record.path, ["rev-list", "--count", `${record.base}..${head}`]));
190
+ const noChange = ahead === 0;
191
+ const manifest = noChange ? await readNoChangeDelivery(record) : null;
192
+ if (noChange && manifest === null)
79
193
  throw error("WORKTREE_CHILD_EMPTY", `Child ${record.name} has no committed delivery.`);
194
+ if (coordinator.runId !== undefined &&
195
+ (record.runId !== coordinator.runId || record.workerId === undefined || record.ownerSessionId === undefined || record.workerGeneration === undefined ||
196
+ (coordinator.coordinatorId !== undefined && record.coordinatorId !== coordinator.coordinatorId))) {
197
+ throw error("WORKTREE_CHILD_RUN_OWNERSHIP", `Child ${record.name} is not explicitly registered to run ${coordinator.runId}.`);
198
+ }
199
+ if (manifest !== null && (manifest.runId !== coordinator.runId || manifest.workerId !== record.workerId || manifest.generation !== record.workerGeneration || manifest.sourceCommit !== head)) {
200
+ throw error("WORKTREE_NO_CHANGE_OWNERSHIP", `Child ${record.name} no-change manifest does not match its registered worker and source.`);
80
201
  }
81
202
  const childConfig = await deps.loadConfig(record.path);
82
203
  if (calculateConfigHash(childConfig) !== configHash) {
@@ -84,12 +205,18 @@ export async function integrateSessionChildren(deps, coordinator, parentTaskId)
84
205
  }
85
206
  const tasks = await deps.tasks(record.path).list();
86
207
  const roots = tasks.filter(({ task }) => task.parentTaskId === undefined);
87
- if (roots.length !== 1 || tasks.some(({ status }) => status === "archived")) {
208
+ if (roots.length !== 1 || tasks.some(record => record.status === "archived" && record.supersededBy === undefined)) {
88
209
  throw error("WORKTREE_CHILD_TASK_INVALID", `Child ${record.name} must deliver one active task tree.`);
89
210
  }
90
- const scope = await resolveReviewScope({ root: record.path, runner: runner(deps, record.path), base: record.base });
211
+ const suppliedScope = manifest === null ? undefined : parseNoChangeReviewScope(manifest.reviewScope);
212
+ const scope = manifest === null
213
+ ? await resolveReviewScope({ root: record.path, runner: runner(deps, record.path), base: record.base })
214
+ : await resolveReviewScope({ root: record.path, runner: runner(deps, record.path), base: head,
215
+ noChangePaths: suppliedScope.changedFiles });
91
216
  const fingerprint = await calculateSourceFingerprint(record.path, scope, runner(deps, record.path));
92
217
  for (const task of tasks) {
218
+ if (task.supersededBy !== undefined)
219
+ continue;
93
220
  if (task.task.intent === undefined || task.task.intent.trim() === "") {
94
221
  throw error("WORKTREE_CHILD_INTENT_MISSING", `Child task ${task.task.id} needs its pre-work modification intent.`);
95
222
  }
@@ -101,17 +228,65 @@ export async function integrateSessionChildren(deps, coordinator, parentTaskId)
101
228
  throw error("WORKTREE_CHILD_UNVERIFIED", `Child task ${task.task.id}: ${problem.remedy}`);
102
229
  }
103
230
  }
104
- deliveries.push({ record, head, tasks });
231
+ deliveries.push({
232
+ record,
233
+ head,
234
+ tasks,
235
+ kind: noChange ? "no-change" : "changed",
236
+ sourceArtifacts: manifest?.artifactRefs ?? [],
237
+ reviewScope: manifest?.reviewScope ?? null,
238
+ deliveryDigest: manifest?.deliveryDigest ?? null,
239
+ contractDigest: manifest?.contractDigest ?? null,
240
+ runId: manifest?.runId ?? record.runId ?? null,
241
+ workerId: manifest?.workerId ?? record.workerId ?? null,
242
+ workerGeneration: manifest?.generation ?? record.workerGeneration ?? null
243
+ });
105
244
  }
106
245
  if (deliveries.length === 0)
107
246
  return integrated;
108
247
  const before = await git(deps, coordinator.path, ["rev-parse", "HEAD"]);
248
+ const expectedTarget = await git(deps, coordinator.mainRoot, ["rev-parse", "--verify", `${coordinator.targetBranch}^{commit}`]);
249
+ const priorJournal = await readJournal(coordinator);
250
+ if (priorJournal !== null && priorJournal.status !== "rolled-back" &&
251
+ priorJournal.expectedTarget !== expectedTarget && priorJournal.status !== "recorded") {
252
+ throw error("WORKTREE_INTEGRATION_TARGET_MOVED", "The integration target moved while a journal is pending; re-run final proof.");
253
+ }
254
+ let journal = priorJournal !== null && priorJournal.status !== "rolled-back"
255
+ ? priorJournal
256
+ : {
257
+ schemaVersion: 1,
258
+ transactionId: `integration-${randomUUID()}`,
259
+ expectedTarget,
260
+ coordinatorBefore: before,
261
+ status: "prepared",
262
+ steps: [],
263
+ updatedAt: new Date().toISOString()
264
+ };
265
+ await writeJournal(coordinator, journal);
109
266
  if ((await collectChangeSurface(runner(deps, coordinator.path))).paths.length > 0) {
110
267
  throw error("WORKTREE_DIRTY", "Commit the candidate worktree before integrating children.");
111
268
  }
112
269
  try {
113
270
  for (const delivery of deliveries) {
114
- if ((await deps.git(coordinator.path, ["merge-base", "--is-ancestor", delivery.head, "HEAD"])).exitCode !== 0) {
271
+ const priorStep = journal.steps.find((step) => step.name === delivery.record.name);
272
+ if (priorStep !== undefined && priorStep.commit !== delivery.head)
273
+ throw error("WORKTREE_CHILD_MOVED", `Child ${delivery.record.name} changed after its journaled delivery.`);
274
+ if (priorStep?.status === "recorded")
275
+ continue;
276
+ const step = {
277
+ name: delivery.record.name,
278
+ commit: delivery.head,
279
+ kind: delivery.kind,
280
+ status: "prepared",
281
+ taskIds: delivery.tasks.map(({ task }) => task.id),
282
+ sourceArtifacts: delivery.sourceArtifacts,
283
+ reviewScope: delivery.reviewScope,
284
+ deliveryDigest: delivery.deliveryDigest,
285
+ contractDigest: delivery.contractDigest
286
+ };
287
+ journal = { ...journal, status: "merging", steps: [...journal.steps.filter((candidate) => candidate.name !== delivery.record.name), step], updatedAt: new Date().toISOString() };
288
+ await writeJournal(coordinator, journal);
289
+ if (delivery.kind === "changed" && (await deps.git(coordinator.path, ["merge-base", "--is-ancestor", delivery.head, "HEAD"])).exitCode !== 0) {
115
290
  const merge = await deps.git(coordinator.path, ["merge", "--no-ff", "--no-edit", delivery.head]);
116
291
  if (merge.exitCode !== 0) {
117
292
  const files = (await deps.git(coordinator.path, ["diff", "--name-only", "--diff-filter=U"])).stdout.trim();
@@ -122,10 +297,48 @@ export async function integrateSessionChildren(deps, coordinator, parentTaskId)
122
297
  if (await git(deps, delivery.record.path, ["rev-parse", "HEAD"]) !== delivery.head) {
123
298
  throw error("WORKTREE_CHILD_MOVED", `Child ${delivery.record.name} moved during integration.`);
124
299
  }
300
+ journal = {
301
+ ...journal,
302
+ status: "merging",
303
+ steps: journal.steps.map((candidate) => candidate.name === delivery.record.name ? { ...candidate, status: "merged" } : candidate),
304
+ updatedAt: new Date().toISOString()
305
+ };
306
+ await writeJournal(coordinator, journal);
125
307
  }
308
+ journal = { ...journal, status: "importing", updatedAt: new Date().toISOString() };
309
+ await writeJournal(coordinator, journal);
310
+ for (const delivery of deliveries)
311
+ await copyDeliveryHistory(delivery.record, coordinator, delivery.tasks);
126
312
  await deps.tasks(coordinator.path).importDelivery(parentTaskId, deliveries.flatMap(({ tasks }) => tasks));
127
- const next = [...integrated, ...deliveries.map(({ record, head, tasks }) => ({
128
- name: record.name, commit: head, taskIds: tasks.map(({ task }) => task.id)
313
+ for (const delivery of deliveries) {
314
+ if (delivery.sourceArtifacts.length > 0) {
315
+ const manifest = await readPrivateFile(deliveryPath(delivery.record), delivery.record.path);
316
+ if (manifest !== null) {
317
+ await mkdir(join(coordinator.path, ".agent-ops", "tasks", "deliveries"), { recursive: true });
318
+ await writePrivateFile(deliveryArchivePath(coordinator, delivery.record), manifest, coordinator.path);
319
+ }
320
+ }
321
+ }
322
+ journal = { ...journal, status: "recorded", steps: journal.steps.map((step) => ({ ...step, status: "recorded" })), updatedAt: new Date().toISOString() };
323
+ await writeJournal(coordinator, journal);
324
+ const next = [...integrated, ...deliveries.map(({ record, head, tasks, kind, sourceArtifacts, reviewScope, deliveryDigest, contractDigest, runId, workerId, workerGeneration }) => ({
325
+ name: record.name,
326
+ commit: head,
327
+ taskIds: tasks.map(({ task }) => task.id),
328
+ ...(kind === "no-change" ? {
329
+ deliveryKind: kind,
330
+ ...(sourceArtifacts.length === 0 ? {} : { sourceArtifacts }),
331
+ ...(reviewScope === null ? {} : { reviewScope }),
332
+ ...(deliveryDigest === null ? {} : { deliveryDigest }),
333
+ ...(contractDigest === null ? {} : { contractDigest }),
334
+ ...(runId === null ? {} : { runId }),
335
+ ...(workerId === null ? {} : { workerId }),
336
+ ...(workerGeneration === null ? {} : { workerGeneration })
337
+ } : {
338
+ ...(runId === null ? {} : { runId }),
339
+ ...(workerId === null ? {} : { workerId }),
340
+ ...(workerGeneration === null ? {} : { workerGeneration })
341
+ })
129
342
  }))];
130
343
  await writePrivateFile(markerPath(coordinator), `${JSON.stringify(next, null, 2)}\n`, coordinator.path);
131
344
  return next;
@@ -133,6 +346,71 @@ export async function integrateSessionChildren(deps, coordinator, parentTaskId)
133
346
  catch (failure) {
134
347
  await deps.git(coordinator.path, ["merge", "--abort"]);
135
348
  await deps.git(coordinator.path, ["reset", "--hard", before]);
349
+ await writeJournal(coordinator, { ...journal, status: "rolled-back", updatedAt: new Date().toISOString() });
136
350
  throw failure;
137
351
  }
138
352
  }
353
+ /** Preserve immutable local proof for historical review; import never promotes it to final proof. */
354
+ async function copyDeliveryHistory(child, coordinator, tasks) {
355
+ const source = new FileEvidenceStore(child.path, child.path);
356
+ const target = new FileEvidenceStore(coordinator.path, coordinator.path);
357
+ const references = new Set(tasks.flatMap(record => [record.evidence,
358
+ ...(record.revisions ?? []).map(revision => revision.previousEvidence)].flatMap(evidence => Object.values(evidence).flat())));
359
+ for (const reference of references) {
360
+ if (reference.startsWith("review:"))
361
+ continue;
362
+ const checked = validateEvidence(await source.load(reference));
363
+ if (!checked.ok || !tasks.some(record => record.task.id === checked.value.taskId))
364
+ throw error("WORKTREE_CHILD_HISTORY_INVALID", "Historical evidence must resolve to the delivered task tree.");
365
+ const saved = await target.save(checked.value);
366
+ if (saved !== reference)
367
+ throw error("WORKTREE_CHILD_HISTORY_INVALID", "Historical evidence has a noncanonical identity.");
368
+ const artifact = checked.value.acceptance?.executionArtifact;
369
+ if (artifact === undefined)
370
+ continue;
371
+ const content = await readPrivateFile(join(child.path, artifact), child.path);
372
+ if (content === null || Buffer.byteLength(content) > 4 * 1024 * 1024 ||
373
+ artifact !== ".agent-ops/tasks/acceptance/" + sha256(content) + ".json")
374
+ throw error("WORKTREE_CHILD_HISTORY_INVALID", "Acceptance execution history is missing or changed.");
375
+ const destination = join(coordinator.path, artifact);
376
+ const existing = await readPrivateFile(destination, coordinator.path);
377
+ if (existing !== null && existing !== content)
378
+ throw error("WORKTREE_CHILD_HISTORY_INVALID", "Acceptance artifact collision.");
379
+ if (existing === null)
380
+ await writePrivateFile(destination, content, coordinator.path);
381
+ }
382
+ const directory = join(child.path, REVIEW_ATTESTATION_DIRECTORY);
383
+ let names;
384
+ try {
385
+ names = await readdir(directory);
386
+ }
387
+ catch (cause) {
388
+ if (cause.code !== "ENOENT")
389
+ throw cause;
390
+ names = [];
391
+ }
392
+ for (const name of names.filter(name => /^[a-f0-9]{64}\.[a-z][a-z0-9-]{0,127}\.reports\.json$/u.test(name))) {
393
+ const content = await readPrivateFile(join(directory, name), child.path);
394
+ if (content === null || Buffer.byteLength(content) > 512 * 1024)
395
+ continue;
396
+ let value;
397
+ try {
398
+ value = JSON.parse(content);
399
+ }
400
+ catch {
401
+ continue;
402
+ }
403
+ if (typeof value !== "object" || value === null || Array.isArray(value))
404
+ continue;
405
+ const artifact = value;
406
+ if (artifact.status !== "FAIL" || !tasks.some(task => task.task.id === artifact.taskId) ||
407
+ name !== artifact.sourceFingerprint + "." + artifact.taskId + ".reports.json")
408
+ continue;
409
+ const destination = join(coordinator.path, REVIEW_ATTESTATION_DIRECTORY, name);
410
+ const existing = await readPrivateFile(destination, coordinator.path);
411
+ if (existing !== null && existing !== content)
412
+ throw error("WORKTREE_CHILD_HISTORY_INVALID", "Failed review history collision.");
413
+ if (existing === null)
414
+ await writePrivateFile(destination, content, coordinator.path);
415
+ }
416
+ }
@@ -3,7 +3,7 @@ import { join, resolve } from "node:path";
3
3
  import { sha256 } from "../fs/hash.js";
4
4
  import { AgentOpsError } from "../fs/paths.js";
5
5
  import { collectChangeSurface } from "../verify/change-surface.js";
6
- import { assertSessionId, assertWorktreeName, bindSession, insideWorktreeDirectory, parseWorktreeListPorcelain, readWorktreeRecord, removeCheckout, resolveCheckouts, worktreePath, writeWorktreeRecord } from "./service.js";
6
+ import { assertSessionId, assertWorktreeName, bindSession, insideWorktreeDirectory, parseWorktreeListPorcelain, readWorktreeRecord, assertRunWorktreeOwnership, removeCheckout, resolveCheckouts, worktreePath, writeWorktreeRecord } from "./service.js";
7
7
  /**
8
8
  * A baseline no source state hashes to. A resumed session inherits whatever
9
9
  * the last one left in the worktree, so none of it counts as "unchanged".
@@ -180,6 +180,9 @@ export async function commitWorktree(deps, options) {
180
180
  if (record.sessionId !== options.sessionId) {
181
181
  throw manageError("WORKTREE_COMMIT_FOREIGN", `${record.path} belongs to session ${record.sessionId}, not ${options.sessionId}.`);
182
182
  }
183
+ if (options.runOwnership !== undefined) {
184
+ assertRunWorktreeOwnership(record, options.runOwnership);
185
+ }
183
186
  const step = async (args, code, message) => {
184
187
  const result = await deps.git(record.path, args);
185
188
  if (result.exitCode !== 0) {
@@ -1,12 +1,15 @@
1
+ import { readdir } from "node:fs/promises";
1
2
  import { join } from "node:path";
2
3
  import { sha256 } from "../fs/hash.js";
3
4
  import { AgentOpsError } from "../fs/paths.js";
4
- import { findReviewAttestation, readReviewReportArtifact } from "../review/attestation.js";
5
+ import { findReviewAttestation, readReviewReportArtifact, REVIEW_ATTESTATION_DIRECTORY } from "../review/attestation.js";
5
6
  import { resolveReviewScope } from "../review/scope.js";
6
7
  import { readPrivateFile, writePrivateFile } from "../security/permissions.js";
7
8
  import { checkTaskCompletionEvidence } from "../task/completion.js";
8
9
  import { FileEvidenceStore } from "../verify/evidence.js";
10
+ import { validateEvidence } from "../schema/validate.js";
9
11
  import { calculateSourceFingerprint } from "../verify/source-fingerprint.js";
12
+ import { validateRunIntegrationReceipt } from "../run/integration.js";
10
13
  function seal(value) {
11
14
  return { digest: sha256(JSON.stringify(value)), value };
12
15
  }
@@ -19,13 +22,22 @@ function receiptPath(commonDir, record, head) {
19
22
  /** Complete local proof is copied into the Git common directory before the branch moves. */
20
23
  export async function prepareFinishReceipt(options) {
21
24
  const { record, candidateHead, targetCommit, tasks, config } = options;
22
- const scope = await resolveReviewScope({ root: record.path, runner: options.gitRunner, base: targetCommit });
25
+ if (options.integrationJournal !== undefined) {
26
+ validateRunIntegrationReceipt(options.integrationJournal, {
27
+ candidateHead, targetCommit, integrationJournal: options.integrationJournal
28
+ });
29
+ }
30
+ const scope = options.noChange?.reviewScope ?? await resolveReviewScope({ root: record.path, runner: options.gitRunner, base: targetCommit });
31
+ if (options.noChange !== undefined && options.noChange.sourceCommit !== candidateHead) {
32
+ throw new AgentOpsError("WORKTREE_NO_CHANGE_SOURCE_MISMATCH", "Verified-no-change receipt must pin the current candidate commit.");
33
+ }
23
34
  const sourceFingerprint = await calculateSourceFingerprint(record.path, scope, options.gitRunner);
24
35
  if (sourceFingerprint !== options.expectedFingerprint) {
25
36
  throw new AgentOpsError("WORKTREE_FINAL_SOURCE_CHANGED", "The final candidate no longer matches the reviewed fingerprint.");
26
37
  }
27
38
  const evidenceStore = new FileEvidenceStore(record.path, record.path);
28
39
  const verification = {};
40
+ const executionArtifacts = {};
29
41
  const reviews = {};
30
42
  const residualRisks = new Set();
31
43
  const modes = new Set();
@@ -36,7 +48,8 @@ export async function prepareFinishReceipt(options) {
36
48
  if (problem !== null) {
37
49
  throw new AgentOpsError("WORKTREE_FINAL_EVIDENCE_MISSING", `Task ${task.task.id}: ${problem.remedy}`);
38
50
  }
39
- for (const references of Object.values(task.evidence)) {
51
+ for (const references of [task.evidence, ...(task.revisions ?? []).map(revision => revision.previousEvidence)]
52
+ .flatMap(evidence => Object.values(evidence))) {
40
53
  for (const reference of references) {
41
54
  if (reference.startsWith("review:"))
42
55
  continue;
@@ -44,11 +57,22 @@ export async function prepareFinishReceipt(options) {
44
57
  if (value === null)
45
58
  throw new AgentOpsError("WORKTREE_FINAL_EVIDENCE_MISSING", `Missing ${reference}.`);
46
59
  verification[reference] = seal(value);
60
+ const checked = validateEvidence(value);
61
+ if (!checked.ok)
62
+ throw new AgentOpsError("WORKTREE_FINAL_EVIDENCE_MISSING", "Historical verification evidence is invalid.");
63
+ const artifact = checked.value.acceptance?.executionArtifact;
64
+ if (artifact !== undefined) {
65
+ const content = await readPrivateFile(join(record.path, artifact), record.path);
66
+ if (content === null || Buffer.byteLength(content) > 4 * 1024 * 1024 ||
67
+ artifact !== ".agent-ops/tasks/acceptance/" + sha256(content) + ".json")
68
+ throw new AgentOpsError("WORKTREE_FINAL_EVIDENCE_MISSING", "Paired execution artifact is missing or changed.");
69
+ executionArtifacts[artifact] = seal(content);
70
+ }
47
71
  }
48
72
  }
49
73
  const attestation = await findReviewAttestation(record.path, sourceFingerprint, task.task.id);
50
74
  const report = await readReviewReportArtifact(record.path, sourceFingerprint, task.task.id);
51
- if (attestation === null || report === null || report.taskId !== task.task.id) {
75
+ if (attestation === null || report === null || report.status !== "PASS" || report.taskId !== task.task.id) {
52
76
  throw new AgentOpsError("WORKTREE_FINAL_EVIDENCE_MISSING", `Missing review for ${task.task.id}.`);
53
77
  }
54
78
  modes.add(attestation.tree === undefined ? "per-task-fallback" : "tree");
@@ -61,6 +85,35 @@ export async function prepareFinishReceipt(options) {
61
85
  .map(({ attestation }) => JSON.stringify(attestation.value.tree))).size !== 1)) {
62
86
  throw new AgentOpsError("WORKTREE_FINAL_REVIEW_MISMATCH", "Final tasks do not share one complete review mode and tree scope.");
63
87
  }
88
+ const historicalReviews = {};
89
+ let names;
90
+ try {
91
+ names = await readdir(join(record.path, REVIEW_ATTESTATION_DIRECTORY));
92
+ }
93
+ catch (cause) {
94
+ if (cause.code === "ENOENT")
95
+ names = [];
96
+ else
97
+ throw cause;
98
+ }
99
+ for (const name of names.filter(name => /^[a-f0-9]{64}\.[a-z][a-z0-9-]{0,127}\.reports\.json$/u.test(name))) {
100
+ const path = REVIEW_ATTESTATION_DIRECTORY + "/" + name;
101
+ const content = await readPrivateFile(join(record.path, path), record.path);
102
+ if (content === null || Buffer.byteLength(content) > 512 * 1024)
103
+ continue;
104
+ let artifact;
105
+ try {
106
+ artifact = JSON.parse(content);
107
+ }
108
+ catch {
109
+ continue;
110
+ }
111
+ if (artifact === null || typeof artifact !== "object" || artifact.status !== "FAIL" ||
112
+ !tasks.some(task => task.task.id === artifact.taskId) ||
113
+ name !== artifact.sourceFingerprint + "." + artifact.taskId + ".reports.json")
114
+ continue;
115
+ historicalReviews[path] = seal(content);
116
+ }
64
117
  const receipt = {
65
118
  schemaVersion: 1,
66
119
  sessionId: record.sessionId,
@@ -71,15 +124,28 @@ export async function prepareFinishReceipt(options) {
71
124
  tasks,
72
125
  children: options.children,
73
126
  verification,
127
+ executionArtifacts,
128
+ historicalReviews,
74
129
  reviews,
75
130
  residualRisks: [...residualRisks],
76
- createdAt: new Date().toISOString()
131
+ createdAt: new Date().toISOString(),
132
+ ...(options.noChange === undefined ? {} : {
133
+ noChange: {
134
+ sourceCommit: options.noChange.sourceCommit,
135
+ contractDigest: options.noChange.contractDigest,
136
+ reviewScope: JSON.stringify(options.noChange.reviewScope),
137
+ artifactRefs: options.noChange.artifactRefs
138
+ }
139
+ }),
140
+ ...(options.integrationJournal === undefined ? {} : { integrationJournal: options.integrationJournal })
77
141
  };
78
142
  const content = `${JSON.stringify(receipt, null, 2)}\n`;
79
143
  const path = receiptPath(options.commonDir, record, candidateHead);
80
144
  await writePrivateFile(path, content, options.commonDir);
81
145
  const reread = await readPrivateFile(path, options.commonDir);
82
146
  if (reread !== content || Object.values(verification).some((item) => !validSeal(item)) ||
147
+ Object.values(executionArtifacts).some(item => !validSeal(item)) ||
148
+ Object.values(historicalReviews).some(item => !validSeal(item)) ||
83
149
  Object.values(reviews).some(({ attestation, report }) => !validSeal(attestation) || !validSeal(report))) {
84
150
  throw new AgentOpsError("WORKTREE_RECEIPT_INVALID", "The local finish receipt could not be read back intact.");
85
151
  }
@@ -28,6 +28,25 @@ const DEFAULT_SETUP_TIMEOUT_MS = 10 * 60 * 1000;
28
28
  export function mayUseWorktree(record, sessionId, agentId) {
29
29
  return record.sessionId === sessionId && (agentId === undefined || record.agentId === agentId);
30
30
  }
31
+ /**
32
+ * Run workers use an explicit coordinator mapping in addition to the legacy
33
+ * session/agent guard. A native session id alone cannot claim a worktree.
34
+ */
35
+ export function mayUseRunWorktree(record, ownership, sessionId, agentId) {
36
+ return mayUseWorktree(record, sessionId, agentId) &&
37
+ record.runId === ownership.runId &&
38
+ record.workerId === ownership.workerId &&
39
+ record.ownerSessionId === ownership.ownerSessionId &&
40
+ record.workerGeneration === ownership.generation;
41
+ }
42
+ export function assertRunWorktreeOwnership(record, ownership) {
43
+ if (record.runId !== ownership.runId ||
44
+ record.workerId !== ownership.workerId ||
45
+ record.ownerSessionId !== ownership.ownerSessionId ||
46
+ record.workerGeneration !== ownership.generation) {
47
+ throw worktreeError("WORKTREE_RUN_OWNERSHIP_MISMATCH", `Worktree ${record.name} is not owned by run ${ownership.runId}, worker ${ownership.workerId}, generation ${ownership.generation}.`);
48
+ }
49
+ }
31
50
  function worktreeError(code, message) {
32
51
  return new AgentOpsError(code, message);
33
52
  }
@@ -199,6 +218,11 @@ export async function readWorktreeRecord(root) {
199
218
  const value = JSON.parse(source);
200
219
  return value.schemaVersion === 1 && typeof value.name === "string" &&
201
220
  (value.agentId === undefined || typeof value.agentId === "string") &&
221
+ (value.runId === undefined || typeof value.runId === "string") &&
222
+ (value.workerId === undefined || typeof value.workerId === "string") &&
223
+ (value.coordinatorId === undefined || typeof value.coordinatorId === "string") &&
224
+ (value.ownerSessionId === undefined || typeof value.ownerSessionId === "string") &&
225
+ (value.workerGeneration === undefined || (typeof value.workerGeneration === "number" && Number.isSafeInteger(value.workerGeneration) && value.workerGeneration > 0)) &&
202
226
  typeof value.branch === "string" && typeof value.path === "string" &&
203
227
  typeof value.mainRoot === "string" && typeof value.targetBranch === "string" &&
204
228
  typeof value.base === "string" && typeof value.sessionId === "string" &&
@@ -319,10 +343,19 @@ export async function addWorktree(deps, options) {
319
343
  base,
320
344
  sessionId,
321
345
  ...(options.agentId === undefined ? {} : { agentId: options.agentId }),
346
+ ...(options.runOwnership === undefined ? {} : {
347
+ runId: options.runOwnership.runId,
348
+ ...(options.runOwnership.coordinatorId === undefined ? {} : { coordinatorId: options.runOwnership.coordinatorId }),
349
+ workerId: options.runOwnership.workerId,
350
+ ownerSessionId: options.runOwnership.ownerSessionId,
351
+ workerGeneration: options.runOwnership.generation
352
+ }),
322
353
  createdAt: deps.now?.() ?? new Date().toISOString()
323
354
  };
324
355
  let trusted = false;
325
356
  try {
357
+ if (options.runOwnership !== undefined)
358
+ await writeWorktreeRecord(record);
326
359
  const copied = await copyMissing(mainRoot, record.path, await carriedFiles(deps, mainRoot));
327
360
  const worktreeConfig = await deps.loadConfig(record.path);
328
361
  // The same commands the user already trusted, and nothing else: any
@@ -332,6 +365,8 @@ export async function addWorktree(deps, options) {
332
365
  trusted = true;
333
366
  }
334
367
  const setup = [];
368
+ if (options.runOwnership !== undefined)
369
+ await writePrivateFile(join(record.path, ".agent-ops/tasks/run-setup-attempt.json"), JSON.stringify({ startedAt: deps.now?.() ?? new Date().toISOString() }), record.path);
335
370
  for (const step of setupSteps) {
336
371
  const result = await deps.runSetup(record.path, {
337
372
  ...step,
@@ -339,15 +374,19 @@ export async function addWorktree(deps, options) {
339
374
  });
340
375
  const label = [step.command, ...step.args].join(" ");
341
376
  if (result.exitCode !== 0) {
342
- throw worktreeError("WORKTREE_SETUP_FAILED", `Setup step failed (${label}, exit ${String(result.exitCode)}); the worktree was removed. ${result.output.trim().split("\n").slice(-5).join("\n")}`.trim());
377
+ throw worktreeError(options.runOwnership === undefined ? "WORKTREE_SETUP_FAILED" : "RUN_SETUP_RECOVERY_REQUIRED", `Setup step failed (${label}, exit ${String(result.exitCode)}); ${options.runOwnership === undefined ? "the worktree was removed" : "the run checkout was preserved"}. ${result.output.trim().split("\n").slice(-5).join("\n")}`.trim());
343
378
  }
344
379
  setup.push(label);
345
380
  }
381
+ if (options.runOwnership !== undefined)
382
+ await writePrivateFile(join(record.path, ".agent-ops/tasks/run-setup-complete.json"), JSON.stringify({ setupHash: sha256(JSON.stringify(setupSteps)) }), record.path);
346
383
  await writeWorktreeRecord(record);
347
384
  await bindSession(deps, record, mainConfig);
348
385
  return { record, copied, trusted, setup };
349
386
  }
350
387
  catch (error) {
388
+ if (options.runOwnership !== undefined)
389
+ throw error;
351
390
  if (mainConfig.features.completionGate.enabled) {
352
391
  const mainGate = await deps.gate(mainRoot, mainConfig);
353
392
  await (options.agentId === undefined ? mainGate.redirect(sessionId, null) : mainGate.removeRoot(sessionId, path))
@@ -387,6 +426,7 @@ export async function ensureSessionWorktree(deps, options) {
387
426
  cwd: mainRoot,
388
427
  name,
389
428
  sessionId: options.sessionId,
429
+ ...(options.runOwnership === undefined ? {} : { runOwnership: options.runOwnership }),
390
430
  ...(agentId === undefined ? {} : { agentId })
391
431
  })).record;
392
432
  }