@kylecheng3146/agent-ops 0.5.3 → 0.7.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.
- package/README.md +103 -1
- package/dist/packages/cli/src/args.js +183 -3
- package/dist/packages/cli/src/bin.js +76 -19
- package/dist/packages/cli/src/cli.js +53 -8
- package/dist/packages/cli/src/codex-loop-process.js +19 -2
- package/dist/packages/cli/src/commands/advance.js +42 -5
- package/dist/packages/cli/src/commands/init.js +2 -0
- package/dist/packages/cli/src/commands/office.js +20 -0
- package/dist/packages/cli/src/commands/review-show.js +1 -1
- package/dist/packages/cli/src/commands/review.js +100 -9
- package/dist/packages/cli/src/commands/run.js +115 -0
- package/dist/packages/cli/src/commands/task.js +61 -17
- package/dist/packages/cli/src/commands/update.js +8 -0
- package/dist/packages/cli/src/commands/verify.js +1 -0
- package/dist/packages/cli/src/context.js +64 -1
- package/dist/packages/cli/src/hook-entry.js +2 -1
- package/dist/packages/cli/src/hook-process.js +54 -3
- package/dist/packages/cli/src/loop-entry.js +2 -1
- package/dist/packages/cli/src/office-entry.js +230 -0
- package/dist/packages/cli/src/owned-run-step.js +65 -0
- package/dist/packages/cli/src/parallel-deps.js +18 -5
- package/dist/packages/cli/src/run-deps.js +321 -0
- package/dist/packages/cli/src/run-entry.js +952 -0
- package/dist/packages/cli/src/run-setup-entry.js +32 -0
- package/dist/packages/cli/src/run-step-entry.js +58 -0
- package/dist/packages/cli/src/wizard.js +14 -2
- package/dist/runtime/src/adapters/agy/config.js +4 -3
- package/dist/runtime/src/adapters/agy/events.js +9 -0
- package/dist/runtime/src/adapters/claude/config.js +3 -3
- package/dist/runtime/src/adapters/claude/events.js +8 -0
- package/dist/runtime/src/adapters/codex/config.js +3 -3
- package/dist/runtime/src/adapters/codex/events.js +8 -0
- package/dist/runtime/src/adapters/opencode/config.js +5 -4
- package/dist/runtime/src/adapters/opencode/events.js +8 -0
- package/dist/runtime/src/config/explain.js +11 -0
- package/dist/runtime/src/config/hash.js +1 -1
- package/dist/runtime/src/config/merge.js +9 -1
- package/dist/runtime/src/contracts.js +2 -2
- package/dist/runtime/src/hooks/codex-loop.js +4 -0
- package/dist/runtime/src/hooks/completion-gate.js +8 -2
- package/dist/runtime/src/install/doctor.js +2 -0
- package/dist/runtime/src/install/harness.js +44 -1
- package/dist/runtime/src/install/plan.js +16 -11
- package/dist/runtime/src/install/preauth.js +44 -18
- package/dist/runtime/src/install/profiles.js +8 -2
- package/dist/runtime/src/install/update.js +1 -0
- package/dist/runtime/src/office/collect.js +233 -0
- package/dist/runtime/src/office/page.js +873 -0
- package/dist/runtime/src/office/scene.js +168 -0
- package/dist/runtime/src/office/server.js +139 -0
- package/dist/runtime/src/office/sessions.js +115 -0
- package/dist/runtime/src/office/snapshot.js +107 -0
- package/dist/runtime/src/parallel/finish.js +85 -22
- package/dist/runtime/src/parallel/guard.js +12 -2
- package/dist/runtime/src/parallel/integrate.js +288 -10
- package/dist/runtime/src/parallel/manage.js +4 -1
- package/dist/runtime/src/parallel/receipt.js +71 -5
- package/dist/runtime/src/parallel/service.js +41 -1
- package/dist/runtime/src/review/attestation.js +36 -11
- package/dist/runtime/src/review/execute.js +24 -1
- package/dist/runtime/src/review/packet.js +35 -1
- package/dist/runtime/src/review/runner.js +12 -1
- package/dist/runtime/src/review/scope.js +18 -1
- package/dist/runtime/src/review/slots.js +35 -10
- package/dist/runtime/src/run/authorization.js +60 -0
- package/dist/runtime/src/run/budget.js +30 -0
- package/dist/runtime/src/run/controls.js +139 -0
- package/dist/runtime/src/run/convergence.js +177 -0
- package/dist/runtime/src/run/hosts/claude.js +180 -0
- package/dist/runtime/src/run/hosts/codex.js +279 -0
- package/dist/runtime/src/run/hosts/index.js +4 -0
- package/dist/runtime/src/run/hosts/protocol.js +254 -0
- package/dist/runtime/src/run/hosts/types.js +1 -0
- package/dist/runtime/src/run/hosts/util.js +136 -0
- package/dist/runtime/src/run/integration.js +204 -0
- package/dist/runtime/src/run/macOS.js +189 -0
- package/dist/runtime/src/run/ownership.js +22 -0
- package/dist/runtime/src/run/phase.js +37 -0
- package/dist/runtime/src/run/planning.js +25 -0
- package/dist/runtime/src/run/policy-transition.js +1076 -0
- package/dist/runtime/src/run/policy.js +176 -0
- package/dist/runtime/src/run/ratchet.js +71 -0
- package/dist/runtime/src/run/scheduler.js +260 -0
- package/dist/runtime/src/run/service.js +433 -0
- package/dist/runtime/src/run/supervisor.js +635 -0
- package/dist/runtime/src/run/transport.js +159 -0
- package/dist/runtime/src/run/types.js +1 -0
- package/dist/runtime/src/run/usage.js +81 -0
- package/dist/runtime/src/run/verification.js +64 -0
- package/dist/runtime/src/run/worker-gate.js +48 -0
- package/dist/runtime/src/schema/acceptance.js +91 -0
- package/dist/runtime/src/schema/validate.js +80 -9
- package/dist/runtime/src/task/completion.js +14 -2
- package/dist/runtime/src/task/contract.js +17 -0
- package/dist/runtime/src/task/pin-finding.js +81 -0
- package/dist/runtime/src/task/service.js +316 -13
- package/dist/runtime/src/task/store.js +57 -7
- package/dist/runtime/src/verify/acceptance-coverage.js +85 -0
- package/dist/runtime/src/verify/acceptance-protocol.js +252 -0
- package/dist/runtime/src/verify/acceptance-replay.js +239 -0
- package/dist/runtime/src/verify/adapters/index.js +5 -0
- package/dist/runtime/src/verify/adapters/jest.js +142 -0
- package/dist/runtime/src/verify/adapters/node-reporter.js +20 -0
- package/dist/runtime/src/verify/adapters/node.js +254 -0
- package/dist/runtime/src/verify/adapters/pytest.js +188 -0
- package/dist/runtime/src/verify/adapters/rust.js +84 -0
- package/dist/runtime/src/verify/adapters/shared.js +82 -0
- package/dist/runtime/src/verify/adapters/vitest.js +218 -0
- package/dist/runtime/src/verify/evidence.js +5 -2
- package/dist/runtime/src/verify/service.js +24 -8
- package/dist/runtime/src/verify/source-fingerprint.js +1 -1
- package/dist/runtime/src/verify/spawn.js +13 -2
- package/docs/en/spec/acceptance-and-evidence.md +48 -0
- package/docs/en/spec/harness-adapters.md +113 -1
- package/docs/en/spec/loop-engineering.md +49 -0
- package/docs/zh-TW/spec/acceptance-and-evidence.md +21 -1
- package/docs/zh-TW/spec/harness-adapters.md +93 -1
- package/docs/zh-TW/spec/loop-engineering.md +22 -0
- package/package.json +1 -1
- package/schemas/config.schema.json +230 -19
- package/schemas/evidence.schema.json +129 -6
- package/schemas/task.schema.json +265 -6
- package/templates/acceptance/agent_ops_acceptance.rs +36 -0
- package/templates/acceptance/agent_ops_pytest.py +46 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { sha256 } from "../fs/hash.js";
|
|
4
5
|
import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
|
|
@@ -6,7 +7,10 @@ import { currentGateFingerprint } from "../hooks/completion-gate.js";
|
|
|
6
7
|
import { collectChangeSurface } from "../verify/change-surface.js";
|
|
7
8
|
import { aggregateVerificationStatus, executeConfiguredCommand } from "../verify/command-executor.js";
|
|
8
9
|
import { prepareFinishReceipt } from "./receipt.js";
|
|
10
|
+
import { readRunIntegrationProof, readRunIntegrationReceiptBinding, markRunIntegration } from "../run/integration.js";
|
|
11
|
+
import { canonicalJson } from "../config/hash.js";
|
|
9
12
|
import { listWorktrees } from "./manage.js";
|
|
13
|
+
import { withPrivateFileLock } from "../security/permissions.js";
|
|
10
14
|
import { assertWorktreeName, NOTES_REF, noteSessionLine, parseWorktreeListPorcelain, readWorktreeRecord, removeCheckout, resolveCheckouts, worktreePath } from "./service.js";
|
|
11
15
|
const LOCK_NAME = "agent-ops-finish.lock";
|
|
12
16
|
const LOCK_POLL_MS = 2_000;
|
|
@@ -97,30 +101,50 @@ export async function withFinishLock(deps, commonDir, action) {
|
|
|
97
101
|
const path = join(commonDir, LOCK_NAME);
|
|
98
102
|
const deadline = Date.now() + (deps.lockWaitMs ?? LOCK_WAIT_MS);
|
|
99
103
|
const sleep = deps.sleep ?? (async (ms) => await new Promise((resolve) => setTimeout(resolve, ms)));
|
|
104
|
+
const token = randomUUID();
|
|
100
105
|
for (;;) {
|
|
106
|
+
let claimed = false;
|
|
101
107
|
try {
|
|
102
|
-
await
|
|
103
|
-
|
|
104
|
-
|
|
108
|
+
claimed = await withPrivateFileLock(path + ".state", commonDir, async () => {
|
|
109
|
+
for (;;) {
|
|
110
|
+
try {
|
|
111
|
+
await mkdir(path);
|
|
112
|
+
await writeFile(join(path, "owner.json"), JSON.stringify({ pid: process.pid, at: Date.now(), token }));
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
if (error.code !== "EEXIST")
|
|
117
|
+
throw error;
|
|
118
|
+
if (!await staleLock(path))
|
|
119
|
+
return false;
|
|
120
|
+
await rm(path, { recursive: true, force: true });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
});
|
|
105
124
|
}
|
|
106
125
|
catch (error) {
|
|
107
|
-
if (error.code !== "
|
|
126
|
+
if (!(error instanceof AgentOpsError) || error.code !== "PRIVATE_STATE_LOCK_TIMEOUT")
|
|
108
127
|
throw error;
|
|
109
|
-
if (await staleLock(path)) {
|
|
110
|
-
await rm(path, { recursive: true, force: true });
|
|
111
|
-
continue;
|
|
112
|
-
}
|
|
113
|
-
if (Date.now() >= deadline) {
|
|
114
|
-
throw finishError("WORKTREE_FINISH_BUSY", "Another worktree finish is still running; try again later.");
|
|
115
|
-
}
|
|
116
|
-
await sleep(LOCK_POLL_MS);
|
|
117
128
|
}
|
|
129
|
+
if (claimed)
|
|
130
|
+
break;
|
|
131
|
+
if (Date.now() >= deadline)
|
|
132
|
+
throw finishError("WORKTREE_FINISH_BUSY", "Another worktree finish is still running; try again later.");
|
|
133
|
+
await sleep(LOCK_POLL_MS);
|
|
118
134
|
}
|
|
119
135
|
try {
|
|
120
136
|
return await action();
|
|
121
137
|
}
|
|
122
138
|
finally {
|
|
123
|
-
await
|
|
139
|
+
await withPrivateFileLock(path + ".state", commonDir, async () => {
|
|
140
|
+
const owner = await readFile(join(path, "owner.json"), "utf8").catch(error => {
|
|
141
|
+
if (error.code === "ENOENT")
|
|
142
|
+
return null;
|
|
143
|
+
throw error;
|
|
144
|
+
});
|
|
145
|
+
if (owner !== null && JSON.parse(owner)?.token === token)
|
|
146
|
+
await rm(path, { recursive: true, force: true });
|
|
147
|
+
});
|
|
124
148
|
}
|
|
125
149
|
}
|
|
126
150
|
/**
|
|
@@ -211,11 +235,12 @@ export async function finishWorktree(deps, options) {
|
|
|
211
235
|
return await withFinishLock(deps, commonDir, async () => {
|
|
212
236
|
const sessionChildren = (await listWorktrees(deps, mainRoot))
|
|
213
237
|
.map(({ record: child }) => child)
|
|
214
|
-
.filter((child) => child.
|
|
238
|
+
.filter((child) => child.path !== record.path && child.sessionId === record.sessionId &&
|
|
239
|
+
(child.agentId !== undefined || (record.runId !== undefined && child.runId === record.runId)));
|
|
215
240
|
if (options.finalProof === undefined && sessionChildren.length > 0) {
|
|
216
241
|
throw finishError("WORKTREE_CHILDREN_REQUIRE_ADVANCE", "This session has child worktrees; run task advance for integrated final evidence before finish.");
|
|
217
242
|
}
|
|
218
|
-
if (options.finalProof !== undefined &&
|
|
243
|
+
if (options.finalProof !== undefined && options.finalProof.recovery !== true &&
|
|
219
244
|
(sessionChildren.length !== options.finalProof.children.length ||
|
|
220
245
|
sessionChildren.some((child) => !options.finalProof.children.some(({ name }) => name === child.name)))) {
|
|
221
246
|
throw finishError("WORKTREE_CHILDREN_CHANGED", "The child worktree set changed after final review.");
|
|
@@ -257,12 +282,21 @@ export async function finishWorktree(deps, options) {
|
|
|
257
282
|
if ((await collectChangeSurface(worktreeRunner)).paths.length > 0) {
|
|
258
283
|
throw finishError("WORKTREE_DIRTY", `Commit or discard the uncommitted changes in ${record.path} first.`);
|
|
259
284
|
}
|
|
260
|
-
const
|
|
261
|
-
|
|
285
|
+
const actualTarget = await git(deps, mainRoot, ["rev-parse", "--verify", `${record.targetBranch}^{commit}`], "WORKTREE_TARGET_MISSING", `${record.targetBranch} does not resolve to a commit.`);
|
|
286
|
+
const recovering = options.finalProof?.recovery === true;
|
|
287
|
+
if (recovering) {
|
|
288
|
+
const sealed = await readRunIntegrationProof(commonDir, record);
|
|
289
|
+
const { recovery: _recovery, ...requested } = options.finalProof;
|
|
290
|
+
if (sealed === null || canonicalJson(sealed) !== canonicalJson(requested) || actualTarget !== sealed.head)
|
|
291
|
+
throw finishError("RUN_INTEGRATION_PROOF_CHANGED", "Recovery requires the exact sealed candidate already at the target.");
|
|
292
|
+
}
|
|
293
|
+
const target = recovering ? options.finalProof.target : actualTarget;
|
|
294
|
+
if (options.finalProof !== undefined && actualTarget !== options.finalProof.target && !recovering) {
|
|
262
295
|
throw finishError("WORKTREE_TARGET_MOVED", "The target branch moved after final review; rerun the final evidence gate.");
|
|
263
296
|
}
|
|
264
297
|
const ahead = Number(await git(deps, record.path, ["rev-list", "--count", `${target}..HEAD`], "WORKTREE_LOG_FAILED", "Git could not compare the branch with its target."));
|
|
265
|
-
|
|
298
|
+
const noChangeProof = options.finalProof?.noChange;
|
|
299
|
+
if (ahead === 0 && noChangeProof === undefined && !recovering) {
|
|
266
300
|
throw finishError("WORKTREE_NOTHING_TO_MERGE", `${record.branch} has no commits beyond ${record.targetBranch}; remove the worktree instead.`);
|
|
267
301
|
}
|
|
268
302
|
const worktreeConfig = await deps.loadConfig(record.path);
|
|
@@ -274,6 +308,9 @@ export async function finishWorktree(deps, options) {
|
|
|
274
308
|
if ((await deps.git(record.path, ["merge-base", "--is-ancestor", target, "HEAD"])).exitCode !== 0) {
|
|
275
309
|
throw finishError("WORKTREE_TARGET_MOVED", "The final candidate is not based on the target; rerun the final evidence gate.");
|
|
276
310
|
}
|
|
311
|
+
if (noChangeProof !== undefined && noChangeProof.sourceCommit !== headBeforeCompletion) {
|
|
312
|
+
throw finishError("WORKTREE_NO_CHANGE_SOURCE_MISMATCH", "The verified-no-change proof does not match the candidate commit.");
|
|
313
|
+
}
|
|
277
314
|
}
|
|
278
315
|
// Direct finish completes before merge. A final-proof finish checks all
|
|
279
316
|
// evidence first, then completes only after the target's fast-forward:
|
|
@@ -283,12 +320,13 @@ export async function finishWorktree(deps, options) {
|
|
|
283
320
|
throw finishError("WORKTREE_TASK_INCOMPLETE", "The worktree has no task; create one, verify and review it before finishing.");
|
|
284
321
|
}
|
|
285
322
|
const completeTasks = async () => {
|
|
323
|
+
const noChangePaths = noChangeProof?.reviewScope.changedFiles;
|
|
286
324
|
for (const { task } of [...tree].sort((left, right) => right.depth - left.depth)) {
|
|
287
325
|
if (task.status === "complete")
|
|
288
326
|
continue;
|
|
289
327
|
const base = options.finalProof === undefined ? task.reviewBase ?? record.base : target;
|
|
290
328
|
try {
|
|
291
|
-
await deps.tasks(record.path, base).complete(task.task.id, {});
|
|
329
|
+
await deps.tasks(record.path, base, noChangePaths).complete(task.task.id, {});
|
|
292
330
|
}
|
|
293
331
|
catch (error) {
|
|
294
332
|
throw finishError("WORKTREE_TASK_INCOMPLETE", `Task ${task.task.id} (${task.task.title}) could not be completed against ${base}: ${error instanceof Error ? error.message : String(error)} ` +
|
|
@@ -337,6 +375,15 @@ export async function finishWorktree(deps, options) {
|
|
|
337
375
|
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.`);
|
|
338
376
|
}
|
|
339
377
|
}
|
|
378
|
+
const sealedIntegration = options.finalProof === undefined || record.runId === undefined
|
|
379
|
+
? null
|
|
380
|
+
: await readRunIntegrationReceiptBinding(commonDir, record, { target, candidate: head });
|
|
381
|
+
const requestedIntegration = options.finalProof?.integrationJournal;
|
|
382
|
+
if (requestedIntegration !== undefined && sealedIntegration !== null &&
|
|
383
|
+
canonicalJson(requestedIntegration) !== canonicalJson(sealedIntegration)) {
|
|
384
|
+
throw finishError("RUN_INTEGRATION_PROOF_CHANGED", "The requested receipt binding differs from the sealed integration transaction.");
|
|
385
|
+
}
|
|
386
|
+
const integrationJournal = sealedIntegration ?? requestedIntegration;
|
|
340
387
|
const receiptOptions = options.finalProof === undefined ? undefined : {
|
|
341
388
|
commonDir,
|
|
342
389
|
record,
|
|
@@ -345,7 +392,9 @@ export async function finishWorktree(deps, options) {
|
|
|
345
392
|
expectedFingerprint: options.finalProof.sourceFingerprint,
|
|
346
393
|
children: options.finalProof.children,
|
|
347
394
|
config: worktreeConfig,
|
|
348
|
-
gitRunner: worktreeRunner
|
|
395
|
+
gitRunner: worktreeRunner,
|
|
396
|
+
...(noChangeProof === undefined ? {} : { noChange: noChangeProof }),
|
|
397
|
+
...(integrationJournal === undefined ? {} : { integrationJournal })
|
|
349
398
|
};
|
|
350
399
|
let receipt = receiptOptions === undefined ? undefined : await prepareFinishReceipt({
|
|
351
400
|
...receiptOptions,
|
|
@@ -355,7 +404,13 @@ export async function finishWorktree(deps, options) {
|
|
|
355
404
|
const gateEnabled = mainConfig.features.completionGate.enabled;
|
|
356
405
|
const before = gateEnabled ? await currentGateFingerprint(mainRoot, mainRunner) : "";
|
|
357
406
|
let mergedHead;
|
|
358
|
-
if (
|
|
407
|
+
if (noChangeProof !== undefined || recovering) {
|
|
408
|
+
// A verified-no-change finish records proof and task state while leaving
|
|
409
|
+
// the target ref untouched. The ordinary finish path still rejects an
|
|
410
|
+
// empty branch, preserving its public behavior.
|
|
411
|
+
mergedHead = actualTarget;
|
|
412
|
+
}
|
|
413
|
+
else if (onTarget) {
|
|
359
414
|
if (options.finalProof !== undefined &&
|
|
360
415
|
await git(deps, mainRoot, ["rev-parse", "HEAD"], "WORKTREE_LOG_FAILED", "Git could not read HEAD.") !== target) {
|
|
361
416
|
throw finishError("WORKTREE_TARGET_MOVED", "The target changed after the final proof was prepared.");
|
|
@@ -384,13 +439,16 @@ export async function finishWorktree(deps, options) {
|
|
|
384
439
|
}
|
|
385
440
|
}
|
|
386
441
|
if (receiptOptions !== undefined) {
|
|
442
|
+
await markRunIntegration(commonDir, record, "target-moved", "target");
|
|
387
443
|
try {
|
|
388
444
|
await completeTasks();
|
|
389
445
|
await validateGate();
|
|
446
|
+
await markRunIntegration(commonDir, record, "tasks-completed", "tasks");
|
|
390
447
|
receipt = await prepareFinishReceipt({
|
|
391
448
|
...receiptOptions,
|
|
392
449
|
tasks: await deps.tasks(record.path).list().then((tasks) => tasks.filter(({ status }) => status !== "archived"))
|
|
393
450
|
});
|
|
451
|
+
await markRunIntegration(commonDir, record, "receipt-written", "receipt", receipt);
|
|
394
452
|
}
|
|
395
453
|
catch (error) {
|
|
396
454
|
throw finishError("WORKTREE_FINISH_PARTIAL", `Code merged at ${mergedHead}; final task state or receipt failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -410,8 +468,10 @@ export async function finishWorktree(deps, options) {
|
|
|
410
468
|
warnings.push(message);
|
|
411
469
|
}
|
|
412
470
|
};
|
|
413
|
-
const finishNote = `${noteText(record, tree)}${receipt === undefined ? "" : `\nreceipt: ${receipt.path}\nreceipt-sha256: ${receipt.digest}`}`;
|
|
471
|
+
const finishNote = `${noteText(record, tree)}${noChangeProof === undefined ? "" : "\nverified-no-change: true"}${receipt === undefined ? "" : `\nreceipt: ${receipt.path}\nreceipt-sha256: ${receipt.digest}`}`;
|
|
414
472
|
await attempt("note", async () => await git(deps, mainRoot, ["notes", `--ref=${NOTES_REF}`, "add", "-f", "-m", finishNote, mergedHead], "WORKTREE_NOTE_FAILED", "Git could not write the agent-ops note."));
|
|
473
|
+
if (receiptOptions !== undefined)
|
|
474
|
+
await markRunIntegration(commonDir, record, "receipt-written", "note");
|
|
415
475
|
if (gateEnabled) {
|
|
416
476
|
await attempt("gate", async () => {
|
|
417
477
|
const mainGate = await deps.gate(mainRoot, mainConfig);
|
|
@@ -435,10 +495,13 @@ export async function finishWorktree(deps, options) {
|
|
|
435
495
|
const pinnedHead = await assertPinnedChild(child);
|
|
436
496
|
await deps.trust.revoke(child.path, await deps.loadConfig(child.path));
|
|
437
497
|
await removeCheckout(deps, child, false, pinnedHead);
|
|
498
|
+
await markRunIntegration(commonDir, record, "receipt-written", "child:" + child.name);
|
|
438
499
|
});
|
|
439
500
|
}
|
|
440
501
|
}
|
|
441
502
|
await attempt("remove", async () => await removeCheckout(deps, record, options.finalProof === undefined, options.finalProof?.head));
|
|
503
|
+
if (receiptOptions !== undefined)
|
|
504
|
+
await markRunIntegration(commonDir, record, "cleaned", "coordinator");
|
|
442
505
|
return { record, mergedHead, rebased: !fastForward, taskIds: tree.map(({ task }) => task.task.id), warnings,
|
|
443
506
|
...(receipt === undefined ? {} : { receipt: receipt.path }) };
|
|
444
507
|
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { registeredRunWriter } from "../run/ownership.js";
|
|
1
2
|
import { realpath } from "node:fs/promises";
|
|
2
3
|
import { basename, dirname, join, relative, sep } from "node:path";
|
|
3
4
|
import { insideWorktreeDirectory, mayUseWorktree, readWorktreeRecord, WORKTREE_DIRECTORY } from "./service.js";
|
|
@@ -35,6 +36,8 @@ export async function canonicalPath(path) {
|
|
|
35
36
|
}
|
|
36
37
|
}
|
|
37
38
|
}
|
|
39
|
+
/** The gitignored goal file the `run` profile writes in the main checkout. */
|
|
40
|
+
export const RUN_GOAL_FILE = join(".agent-ops", "state", "run-goal.md");
|
|
38
41
|
/**
|
|
39
42
|
* In worktree auto mode the main checkout is shared by every session, so no
|
|
40
43
|
* session writes it directly: each edit belongs in that session's own
|
|
@@ -45,16 +48,23 @@ export async function evaluateWorktreeWrite(mainRoot, paths, sessionId,
|
|
|
45
48
|
/** Creates or reuses this writer's worktree and returns its path. */
|
|
46
49
|
ensureWorktree,
|
|
47
50
|
/** The subagent making the write, when there is one: it has a worktree of its own. */
|
|
48
|
-
agentId
|
|
51
|
+
agentId,
|
|
52
|
+
/** The `auto-run` capability: `agent-ops run` starts from the main checkout. */
|
|
53
|
+
autoRun = false) {
|
|
49
54
|
for (const path of paths) {
|
|
50
55
|
const target = await canonicalPath(path);
|
|
56
|
+
if (autoRun && target === join(mainRoot, RUN_GOAL_FILE))
|
|
57
|
+
continue;
|
|
51
58
|
const inMain = target === mainRoot || target.startsWith(`${mainRoot}${sep}`);
|
|
52
59
|
if (inMain && insideWorktreeDirectory(mainRoot, target) && sessionId !== undefined) {
|
|
53
60
|
// A worktree belongs to the session that made it: writing into another
|
|
54
61
|
// session's mixes two tasks' changes into one review and one merge.
|
|
55
62
|
const name = relative(join(mainRoot, WORKTREE_DIRECTORY), target).split(sep)[0] ?? "";
|
|
56
63
|
const record = await readWorktreeRecord(join(mainRoot, WORKTREE_DIRECTORY, name));
|
|
57
|
-
|
|
64
|
+
const permitted = record === null || (record.runId === undefined
|
|
65
|
+
? mayUseWorktree(record, sessionId, agentId)
|
|
66
|
+
: await registeredRunWriter(record, sessionId, agentId));
|
|
67
|
+
if (record !== null && !permitted) {
|
|
58
68
|
const owner = record.sessionId;
|
|
59
69
|
const sameSession = owner === sessionId;
|
|
60
70
|
let own = "";
|
|
@@ -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.
|
|
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
|
-
|
|
78
|
-
|
|
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(
|
|
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
|
|
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({
|
|
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
|
-
|
|
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
|
|
128
|
-
|
|
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
|
+
}
|