@kylecheng3146/agent-ops 0.5.2 → 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.
- package/README.md +64 -0
- package/dist/packages/cli/src/args.js +214 -10
- package/dist/packages/cli/src/bin.js +77 -17
- package/dist/packages/cli/src/cli.js +42 -10
- package/dist/packages/cli/src/commands/advance.js +173 -0
- package/dist/packages/cli/src/commands/review-show.js +189 -0
- package/dist/packages/cli/src/commands/review.js +201 -34
- package/dist/packages/cli/src/commands/run.js +111 -0
- package/dist/packages/cli/src/commands/task.js +62 -17
- package/dist/packages/cli/src/commands/verify.js +1 -0
- package/dist/packages/cli/src/context.js +63 -1
- package/dist/packages/cli/src/hook-process.js +31 -1
- 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 +314 -0
- package/dist/packages/cli/src/run-entry.js +943 -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/runtime/src/config/explain.js +6 -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/completion-gate.js +8 -2
- package/dist/runtime/src/install/doctor.js +2 -0
- package/dist/runtime/src/install/harness.js +1 -1
- package/dist/runtime/src/install/preauth.js +10 -1
- package/dist/runtime/src/parallel/finish.js +217 -39
- package/dist/runtime/src/parallel/guard.js +5 -1
- package/dist/runtime/src/parallel/integrate.js +416 -0
- package/dist/runtime/src/parallel/manage.js +4 -1
- package/dist/runtime/src/parallel/receipt.js +153 -0
- package/dist/runtime/src/parallel/service.js +45 -3
- package/dist/runtime/src/review/attestation.js +60 -11
- package/dist/runtime/src/review/execute.js +24 -1
- package/dist/runtime/src/review/packet.js +35 -1
- package/dist/runtime/src/review/render.js +71 -0
- 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/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 +425 -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 +72 -7
- package/dist/runtime/src/task/completion.js +26 -6
- package/dist/runtime/src/task/contract.js +17 -0
- package/dist/runtime/src/task/pin-finding.js +81 -0
- package/dist/runtime/src/task/render.js +3 -0
- package/dist/runtime/src/task/service.js +361 -10
- 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/guides/configuration.md +10 -0
- package/docs/en/spec/acceptance-and-evidence.md +58 -0
- package/docs/en/spec/delegation.md +4 -4
- package/docs/en/spec/loop-engineering.md +49 -0
- package/docs/en/spec/review.md +24 -2
- package/docs/zh-TW/guides/configuration.md +9 -0
- package/docs/zh-TW/spec/acceptance-and-evidence.md +31 -1
- package/docs/zh-TW/spec/delegation.md +4 -4
- package/docs/zh-TW/spec/loop-engineering.md +22 -0
- package/docs/zh-TW/spec/review.md +24 -2
- package/package.json +1 -1
- package/schemas/config.schema.json +221 -19
- package/schemas/evidence.schema.json +129 -6
- package/schemas/task.schema.json +268 -6
- package/templates/acceptance/agent_ops_acceptance.rs +36 -0
- package/templates/acceptance/agent_ops_pytest.py +46 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { FileRunRepository } from "../../../runtime/src/run/service.js";
|
|
2
|
+
import { readRunPolicy, runRuntimeHash } from "../../../runtime/src/run/policy.js";
|
|
3
|
+
import { activeWallTimeMs } from "../../../runtime/src/run/scheduler.js";
|
|
4
|
+
import { runVerificationCommand, NodeVerificationProcessRunner } from "../../../runtime/src/verify/spawn.js";
|
|
5
|
+
import { redactSecrets } from "../../../runtime/src/security/redact.js";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { realpath } from "node:fs/promises";
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
// This entry is released by run-step-entry only after its process group is registered.
|
|
10
|
+
const [commonDir, runId, root, index, expectedPolicy] = process.argv.slice(2);
|
|
11
|
+
if ([commonDir, runId, root, index, expectedPolicy].some(v => v === undefined))
|
|
12
|
+
throw new Error("Incomplete run setup identity.");
|
|
13
|
+
const repository = new FileRunRepository(join(commonDir, "agent-ops/runs"), commonDir);
|
|
14
|
+
const state = await repository.read(runId);
|
|
15
|
+
const group = process.env.AGENT_OPS_RUN_PROOF_PID;
|
|
16
|
+
const actualCommon = execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd: root, encoding: "utf8" }).trim();
|
|
17
|
+
if (state === null || state.status !== "active" || state.disableRestart || state.proofProcess?.processId !== Number(group) ||
|
|
18
|
+
state.policyBinding?.artifactDigest !== expectedPolicy || await realpath(actualCommon) !== await realpath(state.commonDir))
|
|
19
|
+
throw new Error("Stale run setup authorization.");
|
|
20
|
+
const policy = await readRunPolicy(state, await runRuntimeHash());
|
|
21
|
+
const step = /^(?:0|[1-9][0-9]*)$/u.test(index) ? policy?.config.worktree?.setup?.[Number(index)] : undefined;
|
|
22
|
+
if (step === undefined)
|
|
23
|
+
throw new Error("Setup must select an authorized fixed policy command.");
|
|
24
|
+
const remaining = state.budget.limitMs - activeWallTimeMs(state.budget.activeIntervals, Date.now());
|
|
25
|
+
if (remaining <= 0)
|
|
26
|
+
throw new Error("Run budget is exhausted.");
|
|
27
|
+
const result = await runVerificationCommand({ id: "run-setup", ...step, cwd: ".", required: true,
|
|
28
|
+
timeoutMs: Math.min(step.timeoutMs ?? 600000, remaining), evidence: { kind: "exit-code" } }, {
|
|
29
|
+
cwd: root, runner: new NodeVerificationProcessRunner({ processGroupId: Number(group) })
|
|
30
|
+
});
|
|
31
|
+
process.stdout.write(JSON.stringify({ exitCode: result.status === "PASS" ? 0 : result.exitCode ?? 1,
|
|
32
|
+
output: redactSecrets(result.stdout + result.stderr) }) + "\n");
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
/** Dormant process group: the supervisor records this PID before releasing the CLI. */
|
|
4
|
+
const cli = fileURLToPath(new URL("./bin.js", import.meta.url));
|
|
5
|
+
const setup = fileURLToPath(new URL("./run-setup-entry.js", import.meta.url));
|
|
6
|
+
const target = process.argv[2];
|
|
7
|
+
if (target !== cli && target !== setup)
|
|
8
|
+
throw new Error("Run proof entry must use its own installed execution entry.");
|
|
9
|
+
let input = "";
|
|
10
|
+
let started = false;
|
|
11
|
+
process.stdin.setEncoding("utf8");
|
|
12
|
+
process.stdin.on("data", chunk => {
|
|
13
|
+
if (started)
|
|
14
|
+
return;
|
|
15
|
+
input += chunk;
|
|
16
|
+
if (input.length > 16 || (input.includes("\n") && input !== "start\n"))
|
|
17
|
+
process.exit(1);
|
|
18
|
+
if (input !== "start\n")
|
|
19
|
+
return;
|
|
20
|
+
started = true;
|
|
21
|
+
// Keep this identity alive until the CLI finishes its signal cleanup.
|
|
22
|
+
process.on("SIGTERM", () => { });
|
|
23
|
+
process.on("SIGINT", () => { });
|
|
24
|
+
process.stdin.pause();
|
|
25
|
+
process.env.AGENT_OPS_RUN_PROOF_PID = String(process.pid);
|
|
26
|
+
const child = spawn(process.execPath, [target, ...process.argv.slice(3)], { env: process.env, stdio: ["ignore", "inherit", "inherit"] });
|
|
27
|
+
child.once("error", () => process.exit(1));
|
|
28
|
+
child.once("close", (code, signal) => {
|
|
29
|
+
const deadline = Date.now() + 3000;
|
|
30
|
+
const reconcile = () => {
|
|
31
|
+
const listing = spawn("ps", ["-axo", "pid=,pgid="], { detached: true, stdio: ["ignore", "pipe", "ignore"] });
|
|
32
|
+
let output = "";
|
|
33
|
+
listing.stdout.on("data", chunk => { output += chunk.toString(); });
|
|
34
|
+
listing.once("error", () => process.kill(-process.pid, "SIGKILL"));
|
|
35
|
+
listing.once("close", status => {
|
|
36
|
+
if (status !== 0)
|
|
37
|
+
process.kill(-process.pid, "SIGKILL");
|
|
38
|
+
const remaining = output.split("\n").some(line => {
|
|
39
|
+
const [pid, group] = line.trim().split(/\s+/u).map(Number);
|
|
40
|
+
return group === process.pid && pid !== process.pid;
|
|
41
|
+
});
|
|
42
|
+
if (remaining && Date.now() < deadline) {
|
|
43
|
+
setTimeout(reconcile, 50);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (remaining)
|
|
47
|
+
process.kill(-process.pid, "SIGKILL");
|
|
48
|
+
else if (signal !== null)
|
|
49
|
+
process.exit(1);
|
|
50
|
+
else
|
|
51
|
+
process.exit(code ?? 1);
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
reconcile();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
process.stdin.on("end", () => { if (!started)
|
|
58
|
+
process.exit(1); });
|
|
@@ -29,6 +29,12 @@ export function explainConfig(merged) {
|
|
|
29
29
|
source: entry.source,
|
|
30
30
|
sourcePath: entry.sourcePath
|
|
31
31
|
})),
|
|
32
|
+
...(merged.provenance.acceptanceRunners === undefined ? {} : {
|
|
33
|
+
acceptanceRunners: merged.provenance.acceptanceRunners.map(entry => ({
|
|
34
|
+
id: entry.value.id, adapter: entry.value.adapter,
|
|
35
|
+
source: entry.source, sourcePath: entry.sourcePath
|
|
36
|
+
}))
|
|
37
|
+
}),
|
|
32
38
|
pathMappings: merged.provenance.pathMappings.map((entry) => ({
|
|
33
39
|
path: entry.value.path,
|
|
34
40
|
verifierIds: [...entry.value.verifierIds],
|
|
@@ -105,6 +105,7 @@ export function mergeConfigLayers(inputLayers) {
|
|
|
105
105
|
const layers = [...inputLayers].sort((left, right) => SOURCE_RANK[left.source] - SOURCE_RANK[right.source]);
|
|
106
106
|
const profiles = new Map();
|
|
107
107
|
const commands = new Map();
|
|
108
|
+
const acceptanceRunners = new Map();
|
|
108
109
|
const mappings = new Map();
|
|
109
110
|
const exceptions = new Map();
|
|
110
111
|
const reviewRoles = new Map();
|
|
@@ -128,6 +129,9 @@ export function mergeConfigLayers(inputLayers) {
|
|
|
128
129
|
}
|
|
129
130
|
commands.set(command.id, effective(command, layer));
|
|
130
131
|
}
|
|
132
|
+
for (const runner of layer.config.verification.acceptanceRunners ?? []) {
|
|
133
|
+
acceptanceRunners.set(runner.id, effective(runner, layer));
|
|
134
|
+
}
|
|
131
135
|
for (const mapping of layer.config.pathMappings) {
|
|
132
136
|
const key = mappingKey(mapping);
|
|
133
137
|
if (layer.source === "project") {
|
|
@@ -162,6 +166,7 @@ export function mergeConfigLayers(inputLayers) {
|
|
|
162
166
|
features,
|
|
163
167
|
profiles: [...profiles.values()],
|
|
164
168
|
verificationCommands: [...commands.values()],
|
|
169
|
+
...(acceptanceRunners.size === 0 ? {} : { acceptanceRunners: [...acceptanceRunners.values()] }),
|
|
165
170
|
pathMappings: [...mappings.values()],
|
|
166
171
|
securityExceptions: [...exceptions.values()],
|
|
167
172
|
reviewRoles: [...reviewRoles.values()],
|
|
@@ -171,7 +176,10 @@ export function mergeConfigLayers(inputLayers) {
|
|
|
171
176
|
schemaVersion: schemaVersion.value,
|
|
172
177
|
profiles: provenance.profiles.map(({ value }) => value),
|
|
173
178
|
verification: {
|
|
174
|
-
commands: provenance.verificationCommands.map(({ value }) => value)
|
|
179
|
+
commands: provenance.verificationCommands.map(({ value }) => value),
|
|
180
|
+
...(provenance.acceptanceRunners === undefined ? {} : {
|
|
181
|
+
acceptanceRunners: provenance.acceptanceRunners.map(({ value }) => value)
|
|
182
|
+
})
|
|
175
183
|
},
|
|
176
184
|
features: provenance.features.value,
|
|
177
185
|
pathMappings: provenance.pathMappings.map(({ value }) => value),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export const CONFIG_SCHEMA_VERSION = 3;
|
|
2
|
-
export const TASK_SCHEMA_VERSION =
|
|
3
|
-
export const EVIDENCE_SCHEMA_VERSION =
|
|
2
|
+
export const TASK_SCHEMA_VERSION = 2;
|
|
3
|
+
export const EVIDENCE_SCHEMA_VERSION = 4;
|
|
4
4
|
/** @deprecated Use the document-specific schema version constants. */
|
|
5
5
|
export const SCHEMA_VERSION = CONFIG_SCHEMA_VERSION;
|
|
6
6
|
/**
|
|
@@ -6,6 +6,7 @@ import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../secur
|
|
|
6
6
|
import { checkTaskCompletionEvidence, findIncompleteSubtask } from "../task/completion.js";
|
|
7
7
|
import { collectBaseChangePaths, collectChangeSurface } from "../verify/change-surface.js";
|
|
8
8
|
import { calculateSourceFingerprint } from "../verify/source-fingerprint.js";
|
|
9
|
+
import { resolveReviewScope } from "../review/scope.js";
|
|
9
10
|
import { NOTES_REF, noteSessionLine, readWorktreeRecord } from "../parallel/service.js";
|
|
10
11
|
const FINGERPRINT = /^[a-f0-9]{64}$/u;
|
|
11
12
|
const SESSION = /^[^\0\r\n]{1,256}$/u;
|
|
@@ -192,7 +193,7 @@ export class CompletionGateService {
|
|
|
192
193
|
* range is recomputed here rather than trusted, so evidence for a range that
|
|
193
194
|
* no longer ends at HEAD still fails.
|
|
194
195
|
*/
|
|
195
|
-
async #evidenceFingerprint(completionBase) {
|
|
196
|
+
async #evidenceFingerprint(completionBase, noChangePaths) {
|
|
196
197
|
const worktree = await this.#fingerprint();
|
|
197
198
|
if (completionBase === null) {
|
|
198
199
|
return worktree;
|
|
@@ -202,6 +203,11 @@ export class CompletionGateService {
|
|
|
202
203
|
return worktree;
|
|
203
204
|
}
|
|
204
205
|
try {
|
|
206
|
+
if (noChangePaths !== undefined) {
|
|
207
|
+
const scope = await resolveReviewScope({ root: this.#options.root, runner: this.#options.gitRunner,
|
|
208
|
+
base: completionBase, noChangePaths });
|
|
209
|
+
return await calculateSourceFingerprint(this.#options.root, scope, this.#options.gitRunner);
|
|
210
|
+
}
|
|
205
211
|
const changedFiles = await collectBaseChangePaths(this.#options.gitRunner, completionBase);
|
|
206
212
|
if (changedFiles.length === 0) {
|
|
207
213
|
return worktree;
|
|
@@ -367,7 +373,7 @@ export class CompletionGateService {
|
|
|
367
373
|
if (unfinished !== undefined) {
|
|
368
374
|
return gateResult("block", "FAIL", "COMPLETION_GATE_SUBTASK_INCOMPLETE", `Complete subtask ${unfinished.task.id} before its parent.`);
|
|
369
375
|
}
|
|
370
|
-
const sourceFingerprint = await this.#evidenceFingerprint(stored.completionBase);
|
|
376
|
+
const sourceFingerprint = await this.#evidenceFingerprint(stored.completionBase, stored.noChangePaths);
|
|
371
377
|
const problem = await checkTaskCompletionEvidence(stored, {
|
|
372
378
|
...this.#options,
|
|
373
379
|
sourceFingerprint
|
|
@@ -548,6 +548,8 @@ export async function doctorInstallation(options) {
|
|
|
548
548
|
? []
|
|
549
549
|
: [await checkProbe("root-ghost-files", options.probes.rootGhostFiles)])
|
|
550
550
|
];
|
|
551
|
+
if (options.probes?.acceptanceCoverage !== undefined)
|
|
552
|
+
checks.push(await checkProbe("acceptance-coverage", options.probes.acceptanceCoverage));
|
|
551
553
|
return {
|
|
552
554
|
checks,
|
|
553
555
|
surfaces: surfaceInventory.surfaces,
|
|
@@ -322,7 +322,7 @@ export function managedRules(descriptor, context) {
|
|
|
322
322
|
lines.push("For every change:", "", "1. Define two to five mechanically verifiable acceptance criteria.", "2. Inspect the smallest relevant scope and preserve unrelated changes.", "3. Apply the smallest safe change.", "4. Run evidence-producing verification for every criterion.", "5. Obtain complete independent review before claiming completion, via", " `agent-ops review --task <task-id> --yes` (or the CLI's equivalent invocation).", " Never call a", " review-target CLI (agy, codex, claude) directly — direct calls skip", " the enforced read-only sandbox flags and can hang or fail on command", " permission prompts.", " Start this command through the trusted outer host runner as its first", " and only invocation. On Codex, the outer tool request must use", " `sandbox_permissions: \"require_escalated\"` so network and loopback", " are available; in that same request, unset", " `CODEX_SANDBOX_NETWORK_DISABLED` and set `AGENT_OPS_HOST` when three", " targets are configured. The Codex shell form is", " `env -u CODEX_SANDBOX_NETWORK_DISABLED AGENT_OPS_HOST=<current-host> agent-ops review --task <task-id> --yes`.", " Keep it a plain command line: to keep the output add `--output <file>`", " (the JSON envelope is written there and one line is printed) instead of", " a shell redirect, which the host can only allow by matching the whole", " command string, task id included.", " Do not run it once in the restricted sandbox and retry; changing the", " variable inside that sandbox is not elevation.", " Review runs exactly two fresh sessions: with three configured targets,", " set `AGENT_OPS_HOST` to the current CLI id so it is excluded; with two", " targets both run in configured order; with one target it runs twice.", " The first session is necessary; a PASS then requires an adversarial", " re-check carrying the first report. A first FAIL or NOT_RUN stops.", "", "Treat `.agent-ops/config.json` as verifier authority. Discovery output is", "only a proposal until a user confirms it. Repository commands require an", "exact matching trust record. Confirmed project init/update grants it", "automatically when verification commands are configured.", "");
|
|
323
323
|
}
|
|
324
324
|
if (context.capabilities.includes("task")) {
|
|
325
|
-
lines.push("Split a change that exceeds five acceptance criteria into subtasks:", "`agent-ops task create --parent <task-id>` records one, and", "`agent-ops task status --parent <task-id>` lists them. Each subtask", "carries its own criteria
|
|
325
|
+
lines.push("Split a change that exceeds five acceptance criteria into subtasks:", "`agent-ops task create --parent <task-id>` records one, and", "`agent-ops task status --parent <task-id>` lists them. Each subtask", "carries its own criteria and verification; final review covers all", "tasks together when their worktrees are integrated;", "completing one never completes its parent.", "", "To review every active subtask of a parent together, run", "`agent-ops batch --parent <task-id> --yes` instead of one review per", "subtask. It needs what review needs: start it through the trusted outer", "host runner as its first and only invocation, with the same Codex", "elevation, and keep `--output <file>` instead of a shell redirect. It", "verifies, one task at a time, each task that lacks fresh evidence, and", "reviews a few tasks at once. Do not commit or edit the worktree while it", "runs: a moved HEAD voids every verify and review in the batch.", "For subagents in separate worktrees, use `task advance` instead.", "", "Delegate a subtask whose scope spans unknown files, broad scans, or an", "independent workstream instead of doing it inline. Name the bounded", "scope, the artifact to return, and the acceptance criteria it serves.", subagentInstruction(descriptor), "Read every returned artifact back yourself: integration, verification,", "and review stay with the coordinator.", "", "One writer at a time. `agent-ops verify` and `agent-ops review`", "fingerprint the working tree and report UNKNOWN when anything else", "writes it during the run, so delegate edits to one subagent at a time,", "or give each writer its own Git worktree and merge before verifying.", "Read-only delegation has no such limit and may run in parallel.", "", "Parallel conversations in one repository each need their own worktree.", "When `.agent-ops/config.json` sets `worktree.mode` to `auto`, run", "`agent-ops task create` from the main checkout before your first edit:", "it creates this session's worktree and puts the task there (`agent-ops", "worktree add <name> --session <session-id>` does the same without a", "task). Work only inside the printed path", "(Claude Code: EnterWorktree with that path; otherwise use it as every", "command's working directory). Commit, then run verify and review for", "each task with the printed `--base`; do not run `task complete` in the", "worktree. Leave it (Claude Code: ExitWorktree with action keep) and run", "`agent-ops worktree finish <name>` from the main checkout: it completes", "every task in the worktree, subtasks first, then merges. A finish that", "reports an incomplete task names it; verify or review that task again", "in the worktree and finish again. A finish that reports a conflict is", "resolved once, as a new task whose criteria cover both intents; if its", "verification or review fails, reset as instructed and report.", "", "For a session with writing subagents, record each child task's complete", "modification intent with `task create --intent <text>` before editing.", "Each child commits and runs local `verify --task` in its own worktree.", "From the main checkout run `agent-ops task advance --task <parent-id>", "--session <session-id> --yes` through the trusted outer host runner,", "unsetting `CODEX_SANDBOX_NETWORK_DISABLED` on Codex. Advance integrates", "child commits and intent into the coordinator worktree, verifies every", "task on the final candidate, then runs one two-round tree review.", "Only `NOT_RUN / scope-too-large` falls back to per-task two-round review;", "a substantive FAIL must be fixed. After PASS, advance writes a local", "receipt under the main checkout's Git common directory and calls finish.", "If the target moves, it retries the final gate once. An unresolved", "failure preserves worktrees and cannot be reported as finished.", "");
|
|
326
326
|
}
|
|
327
327
|
if (context.capabilities.includes("lifecycle-summary")) {
|
|
328
328
|
lines.push("Advisory lifecycle summaries and local logs are informational. Advisory", "failures must remain fail-open and cannot become verification evidence.", "");
|
|
@@ -24,11 +24,13 @@ export const CLAUDE_PREAUTH_ALLOW = [
|
|
|
24
24
|
"Bash(npx lint-staged *)"
|
|
25
25
|
];
|
|
26
26
|
/**
|
|
27
|
-
* review and
|
|
27
|
+
* review, batch and task advance talk to the reviewer's API, which the sandbox withholds;
|
|
28
28
|
* worktree commit and lint-staged write the main .git, which a worktree's
|
|
29
29
|
* sandbox cannot.
|
|
30
30
|
*/
|
|
31
31
|
export const CLAUDE_PREAUTH_UNSANDBOXED = [
|
|
32
|
+
"agent-ops task advance",
|
|
33
|
+
"agent-ops task advance *",
|
|
32
34
|
"agent-ops review",
|
|
33
35
|
"agent-ops review *",
|
|
34
36
|
"agent-ops batch",
|
|
@@ -110,6 +112,13 @@ export const CODEX_RULES_CONTENT = `${[
|
|
|
110
112
|
match: `${env} batch --parent task-x --yes --output batch.json`,
|
|
111
113
|
notMatch: `${env} trust grant --scope project --yes`
|
|
112
114
|
}),
|
|
115
|
+
rule({
|
|
116
|
+
withHost,
|
|
117
|
+
command: ["task", "advance"],
|
|
118
|
+
justification: "agent-ops task advance runs the final reviewer chain and finish outside the sandbox.",
|
|
119
|
+
match: `${env} task advance --task task-x --session session-x --yes`,
|
|
120
|
+
notMatch: `${env} trust grant --scope project --yes`
|
|
121
|
+
}),
|
|
113
122
|
rule({
|
|
114
123
|
withHost,
|
|
115
124
|
command: ["doctor", "--check-auth"],
|
|
@@ -1,10 +1,16 @@
|
|
|
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";
|
|
5
6
|
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";
|
|
9
|
+
import { prepareFinishReceipt } from "./receipt.js";
|
|
10
|
+
import { readRunIntegrationProof, readRunIntegrationReceiptBinding, markRunIntegration } from "../run/integration.js";
|
|
11
|
+
import { canonicalJson } from "../config/hash.js";
|
|
12
|
+
import { listWorktrees } from "./manage.js";
|
|
13
|
+
import { withPrivateFileLock } from "../security/permissions.js";
|
|
8
14
|
import { assertWorktreeName, NOTES_REF, noteSessionLine, parseWorktreeListPorcelain, readWorktreeRecord, removeCheckout, resolveCheckouts, worktreePath } from "./service.js";
|
|
9
15
|
const LOCK_NAME = "agent-ops-finish.lock";
|
|
10
16
|
const LOCK_POLL_MS = 2_000;
|
|
@@ -95,30 +101,50 @@ export async function withFinishLock(deps, commonDir, action) {
|
|
|
95
101
|
const path = join(commonDir, LOCK_NAME);
|
|
96
102
|
const deadline = Date.now() + (deps.lockWaitMs ?? LOCK_WAIT_MS);
|
|
97
103
|
const sleep = deps.sleep ?? (async (ms) => await new Promise((resolve) => setTimeout(resolve, ms)));
|
|
104
|
+
const token = randomUUID();
|
|
98
105
|
for (;;) {
|
|
106
|
+
let claimed = false;
|
|
99
107
|
try {
|
|
100
|
-
await
|
|
101
|
-
|
|
102
|
-
|
|
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
|
+
});
|
|
103
124
|
}
|
|
104
125
|
catch (error) {
|
|
105
|
-
if (error.code !== "
|
|
126
|
+
if (!(error instanceof AgentOpsError) || error.code !== "PRIVATE_STATE_LOCK_TIMEOUT")
|
|
106
127
|
throw error;
|
|
107
|
-
if (await staleLock(path)) {
|
|
108
|
-
await rm(path, { recursive: true, force: true });
|
|
109
|
-
continue;
|
|
110
|
-
}
|
|
111
|
-
if (Date.now() >= deadline) {
|
|
112
|
-
throw finishError("WORKTREE_FINISH_BUSY", "Another worktree finish is still running; try again later.");
|
|
113
|
-
}
|
|
114
|
-
await sleep(LOCK_POLL_MS);
|
|
115
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);
|
|
116
134
|
}
|
|
117
135
|
try {
|
|
118
136
|
return await action();
|
|
119
137
|
}
|
|
120
138
|
finally {
|
|
121
|
-
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
|
+
});
|
|
122
148
|
}
|
|
123
149
|
}
|
|
124
150
|
/**
|
|
@@ -184,6 +210,7 @@ function noteText(record, tasks) {
|
|
|
184
210
|
const indent = " ".repeat(depth);
|
|
185
211
|
return [
|
|
186
212
|
`${indent}agent-ops task ${task.task.id}: ${task.task.title}`,
|
|
213
|
+
...(task.task.intent === undefined ? [] : [`${indent}intent: ${task.task.intent}`]),
|
|
187
214
|
`${indent}criteria:`,
|
|
188
215
|
...task.task.criteria.map(({ id, description }) => `${indent}- ${id}: ${description}`)
|
|
189
216
|
];
|
|
@@ -206,6 +233,31 @@ export async function finishWorktree(deps, options) {
|
|
|
206
233
|
throw finishError("WORKTREE_NOT_FOUND", `No agent-ops worktree named ${name}; see agent-ops worktree list.`);
|
|
207
234
|
}
|
|
208
235
|
return await withFinishLock(deps, commonDir, async () => {
|
|
236
|
+
const sessionChildren = (await listWorktrees(deps, mainRoot))
|
|
237
|
+
.map(({ record: child }) => child)
|
|
238
|
+
.filter((child) => child.path !== record.path && child.sessionId === record.sessionId &&
|
|
239
|
+
(child.agentId !== undefined || (record.runId !== undefined && child.runId === record.runId)));
|
|
240
|
+
if (options.finalProof === undefined && sessionChildren.length > 0) {
|
|
241
|
+
throw finishError("WORKTREE_CHILDREN_REQUIRE_ADVANCE", "This session has child worktrees; run task advance for integrated final evidence before finish.");
|
|
242
|
+
}
|
|
243
|
+
if (options.finalProof !== undefined && options.finalProof.recovery !== true &&
|
|
244
|
+
(sessionChildren.length !== options.finalProof.children.length ||
|
|
245
|
+
sessionChildren.some((child) => !options.finalProof.children.some(({ name }) => name === child.name)))) {
|
|
246
|
+
throw finishError("WORKTREE_CHILDREN_CHANGED", "The child worktree set changed after final review.");
|
|
247
|
+
}
|
|
248
|
+
const assertPinnedChild = async (child) => {
|
|
249
|
+
const expected = options.finalProof?.children.find(({ name }) => name === child.name)?.commit;
|
|
250
|
+
const head = await git(deps, child.path, ["rev-parse", "HEAD"], "WORKTREE_CHILDREN_CHANGED", `Cannot read child ${child.name}.`);
|
|
251
|
+
if (expected === undefined || head !== expected ||
|
|
252
|
+
(await collectChangeSurface(runner(deps, child.path))).paths.length > 0) {
|
|
253
|
+
throw finishError("WORKTREE_CHILDREN_CHANGED", `Child ${child.name} changed after integration; preserve its worktree.`);
|
|
254
|
+
}
|
|
255
|
+
return head;
|
|
256
|
+
};
|
|
257
|
+
if (options.finalProof !== undefined) {
|
|
258
|
+
for (const child of sessionChildren)
|
|
259
|
+
await assertPinnedChild(child);
|
|
260
|
+
}
|
|
209
261
|
const mainRunner = runner(deps, mainRoot);
|
|
210
262
|
const worktreeRunner = runner(deps, record.path);
|
|
211
263
|
const onBranch = (await deps.git(mainRoot, ["symbolic-ref", "--short", "-q", "HEAD"])).stdout.trim();
|
|
@@ -230,38 +282,74 @@ export async function finishWorktree(deps, options) {
|
|
|
230
282
|
if ((await collectChangeSurface(worktreeRunner)).paths.length > 0) {
|
|
231
283
|
throw finishError("WORKTREE_DIRTY", `Commit or discard the uncommitted changes in ${record.path} first.`);
|
|
232
284
|
}
|
|
233
|
-
const
|
|
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) {
|
|
295
|
+
throw finishError("WORKTREE_TARGET_MOVED", "The target branch moved after final review; rerun the final evidence gate.");
|
|
296
|
+
}
|
|
234
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."));
|
|
235
|
-
|
|
298
|
+
const noChangeProof = options.finalProof?.noChange;
|
|
299
|
+
if (ahead === 0 && noChangeProof === undefined && !recovering) {
|
|
236
300
|
throw finishError("WORKTREE_NOTHING_TO_MERGE", `${record.branch} has no commits beyond ${record.targetBranch}; remove the worktree instead.`);
|
|
237
301
|
}
|
|
238
302
|
const worktreeConfig = await deps.loadConfig(record.path);
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
303
|
+
const headBeforeCompletion = await git(deps, record.path, ["rev-parse", "HEAD"], "WORKTREE_LOG_FAILED", "Git could not read HEAD.");
|
|
304
|
+
if (options.finalProof !== undefined) {
|
|
305
|
+
if (headBeforeCompletion !== options.finalProof.head) {
|
|
306
|
+
throw finishError("WORKTREE_SOURCE_MOVED", "The candidate changed after final review.");
|
|
307
|
+
}
|
|
308
|
+
if ((await deps.git(record.path, ["merge-base", "--is-ancestor", target, "HEAD"])).exitCode !== 0) {
|
|
309
|
+
throw finishError("WORKTREE_TARGET_MOVED", "The final candidate is not based on the target; rerun the final evidence gate.");
|
|
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
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// Direct finish completes before merge. A final-proof finish checks all
|
|
316
|
+
// evidence first, then completes only after the target's fast-forward:
|
|
317
|
+
// a target that moves before that point can still be re-reviewed once.
|
|
242
318
|
const tree = taskTree((await deps.tasks(record.path).list()).filter(({ status }) => status !== "archived"));
|
|
243
319
|
if (tree.length === 0) {
|
|
244
320
|
throw finishError("WORKTREE_TASK_INCOMPLETE", "The worktree has no task; create one, verify and review it before finishing.");
|
|
245
321
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
322
|
+
const completeTasks = async () => {
|
|
323
|
+
const noChangePaths = noChangeProof?.reviewScope.changedFiles;
|
|
324
|
+
for (const { task } of [...tree].sort((left, right) => right.depth - left.depth)) {
|
|
325
|
+
if (task.status === "complete")
|
|
326
|
+
continue;
|
|
327
|
+
const base = options.finalProof === undefined ? task.reviewBase ?? record.base : target;
|
|
328
|
+
try {
|
|
329
|
+
await deps.tasks(record.path, base, noChangePaths).complete(task.task.id, {});
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
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)} ` +
|
|
333
|
+
"A task another session left here is not this session's to archive: its owner finishes or archives it, and finish never does.");
|
|
334
|
+
}
|
|
252
335
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
336
|
+
};
|
|
337
|
+
const validateGate = async () => {
|
|
338
|
+
const worktreeGate = await deps.gate(record.path, worktreeConfig);
|
|
339
|
+
const problem = await worktreeGate.validate(record.sessionId);
|
|
340
|
+
if (problem !== null) {
|
|
341
|
+
throw finishError("WORKTREE_TASK_INCOMPLETE", `The worktree's task is not ready to merge (${problem.code}). ${problem.remedy ?? ""}`.trim());
|
|
256
342
|
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
throw finishError("WORKTREE_TASK_INCOMPLETE", `The worktree's task is not ready to merge (${problem.code}). ${problem.remedy ?? ""}`.trim());
|
|
343
|
+
};
|
|
344
|
+
if (options.finalProof === undefined) {
|
|
345
|
+
await completeTasks();
|
|
346
|
+
await validateGate();
|
|
262
347
|
}
|
|
263
348
|
const head = await git(deps, record.path, ["rev-parse", "HEAD"], "WORKTREE_LOG_FAILED", "Git could not read HEAD.");
|
|
264
349
|
const fastForward = (await deps.git(record.path, ["merge-base", "--is-ancestor", target, "HEAD"])).exitCode === 0;
|
|
350
|
+
if (options.finalProof !== undefined && !fastForward) {
|
|
351
|
+
throw finishError("WORKTREE_TARGET_MOVED", "The target branch moved after final review.");
|
|
352
|
+
}
|
|
265
353
|
if (!fastForward) {
|
|
266
354
|
const before = await patchHash(deps, record.path, target);
|
|
267
355
|
const rebase = await deps.git(record.path, ["rebase", "--no-autostash", target]);
|
|
@@ -287,19 +375,84 @@ export async function finishWorktree(deps, options) {
|
|
|
287
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.`);
|
|
288
376
|
}
|
|
289
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;
|
|
387
|
+
const receiptOptions = options.finalProof === undefined ? undefined : {
|
|
388
|
+
commonDir,
|
|
389
|
+
record,
|
|
390
|
+
candidateHead: head,
|
|
391
|
+
targetCommit: target,
|
|
392
|
+
expectedFingerprint: options.finalProof.sourceFingerprint,
|
|
393
|
+
children: options.finalProof.children,
|
|
394
|
+
config: worktreeConfig,
|
|
395
|
+
gitRunner: worktreeRunner,
|
|
396
|
+
...(noChangeProof === undefined ? {} : { noChange: noChangeProof }),
|
|
397
|
+
...(integrationJournal === undefined ? {} : { integrationJournal })
|
|
398
|
+
};
|
|
399
|
+
let receipt = receiptOptions === undefined ? undefined : await prepareFinishReceipt({
|
|
400
|
+
...receiptOptions,
|
|
401
|
+
tasks: await deps.tasks(record.path).list().then((tasks) => tasks.filter(({ status }) => status !== "archived"))
|
|
402
|
+
});
|
|
290
403
|
const mainConfig = await deps.loadConfig(mainRoot);
|
|
291
404
|
const gateEnabled = mainConfig.features.completionGate.enabled;
|
|
292
405
|
const before = gateEnabled ? await currentGateFingerprint(mainRoot, mainRunner) : "";
|
|
293
406
|
let mergedHead;
|
|
294
|
-
if (
|
|
295
|
-
|
|
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) {
|
|
414
|
+
if (options.finalProof !== undefined &&
|
|
415
|
+
await git(deps, mainRoot, ["rev-parse", "HEAD"], "WORKTREE_LOG_FAILED", "Git could not read HEAD.") !== target) {
|
|
416
|
+
throw finishError("WORKTREE_TARGET_MOVED", "The target changed after the final proof was prepared.");
|
|
417
|
+
}
|
|
418
|
+
const merge = await deps.git(mainRoot, ["merge", "--ff-only", record.branch]);
|
|
419
|
+
if (merge.exitCode !== 0) {
|
|
420
|
+
if (options.finalProof !== undefined &&
|
|
421
|
+
await git(deps, mainRoot, ["rev-parse", "HEAD"], "WORKTREE_LOG_FAILED", "Git could not read HEAD.") !== target) {
|
|
422
|
+
throw finishError("WORKTREE_TARGET_MOVED", "The target moved during finish; rerun the final evidence gate.");
|
|
423
|
+
}
|
|
424
|
+
throw finishError("WORKTREE_MERGE_FAILED", `Git could not fast-forward ${record.targetBranch}. ${merge.stderr.trim()}`);
|
|
425
|
+
}
|
|
296
426
|
mergedHead = await git(deps, mainRoot, ["rev-parse", "HEAD"], "WORKTREE_LOG_FAILED", "Git could not read HEAD.");
|
|
297
427
|
}
|
|
298
428
|
else {
|
|
299
429
|
// Compare-and-swap on the target's value read above: a target that moved
|
|
300
430
|
// since fails here instead of being overwritten.
|
|
301
431
|
mergedHead = await git(deps, mainRoot, ["rev-parse", "--verify", `${record.branch}^{commit}`], "WORKTREE_LOG_FAILED", `Git could not read ${record.branch}.`);
|
|
302
|
-
await git(
|
|
432
|
+
const update = await deps.git(mainRoot, ["update-ref", "-m", `agent-ops finish ${record.branch}`, `refs/heads/${record.targetBranch}`, mergedHead, target]);
|
|
433
|
+
if (update.exitCode !== 0) {
|
|
434
|
+
if (options.finalProof !== undefined &&
|
|
435
|
+
await git(deps, mainRoot, ["rev-parse", `${record.targetBranch}^{commit}`], "WORKTREE_LOG_FAILED", "Git could not read the target.") !== target) {
|
|
436
|
+
throw finishError("WORKTREE_TARGET_MOVED", "The target moved during finish; rerun the final evidence gate.");
|
|
437
|
+
}
|
|
438
|
+
throw finishError("WORKTREE_MERGE_FAILED", `Git could not fast-forward ${record.targetBranch}. ${update.stderr.trim()}`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
if (receiptOptions !== undefined) {
|
|
442
|
+
await markRunIntegration(commonDir, record, "target-moved", "target");
|
|
443
|
+
try {
|
|
444
|
+
await completeTasks();
|
|
445
|
+
await validateGate();
|
|
446
|
+
await markRunIntegration(commonDir, record, "tasks-completed", "tasks");
|
|
447
|
+
receipt = await prepareFinishReceipt({
|
|
448
|
+
...receiptOptions,
|
|
449
|
+
tasks: await deps.tasks(record.path).list().then((tasks) => tasks.filter(({ status }) => status !== "archived"))
|
|
450
|
+
});
|
|
451
|
+
await markRunIntegration(commonDir, record, "receipt-written", "receipt", receipt);
|
|
452
|
+
}
|
|
453
|
+
catch (error) {
|
|
454
|
+
throw finishError("WORKTREE_FINISH_PARTIAL", `Code merged at ${mergedHead}; final task state or receipt failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
455
|
+
}
|
|
303
456
|
}
|
|
304
457
|
// The merge happened; everything below is cleanup, reported, never undone.
|
|
305
458
|
const warnings = [];
|
|
@@ -308,10 +461,17 @@ export async function finishWorktree(deps, options) {
|
|
|
308
461
|
await step();
|
|
309
462
|
}
|
|
310
463
|
catch (error) {
|
|
311
|
-
|
|
464
|
+
const message = `${label}: ${error instanceof Error ? error.message : String(error)}`;
|
|
465
|
+
if (options.finalProof !== undefined) {
|
|
466
|
+
throw finishError("WORKTREE_FINISH_PARTIAL", `Code merged at ${mergedHead} with receipt ${receipt?.path}; ${message}`);
|
|
467
|
+
}
|
|
468
|
+
warnings.push(message);
|
|
312
469
|
}
|
|
313
470
|
};
|
|
314
|
-
|
|
471
|
+
const finishNote = `${noteText(record, tree)}${noChangeProof === undefined ? "" : "\nverified-no-change: true"}${receipt === undefined ? "" : `\nreceipt: ${receipt.path}\nreceipt-sha256: ${receipt.digest}`}`;
|
|
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");
|
|
315
475
|
if (gateEnabled) {
|
|
316
476
|
await attempt("gate", async () => {
|
|
317
477
|
const mainGate = await deps.gate(mainRoot, mainConfig);
|
|
@@ -321,10 +481,28 @@ export async function finishWorktree(deps, options) {
|
|
|
321
481
|
// this session began would leave it reading the merge as its own change.
|
|
322
482
|
// Only this worktree leaves the session; its other roots stay covered.
|
|
323
483
|
await mainGate.afterFinish(record.sessionId, record.path, after);
|
|
484
|
+
if (options.finalProof !== undefined) {
|
|
485
|
+
for (const child of sessionChildren) {
|
|
486
|
+
await mainGate.afterFinish(record.sessionId, child.path, after);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
324
489
|
});
|
|
325
490
|
}
|
|
326
491
|
await attempt("trust", async () => await deps.trust.revoke(record.path, worktreeConfig));
|
|
327
|
-
|
|
328
|
-
|
|
492
|
+
if (options.finalProof !== undefined) {
|
|
493
|
+
for (const child of sessionChildren) {
|
|
494
|
+
await attempt(`child ${child.name}`, async () => {
|
|
495
|
+
const pinnedHead = await assertPinnedChild(child);
|
|
496
|
+
await deps.trust.revoke(child.path, await deps.loadConfig(child.path));
|
|
497
|
+
await removeCheckout(deps, child, false, pinnedHead);
|
|
498
|
+
await markRunIntegration(commonDir, record, "receipt-written", "child:" + child.name);
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
}
|
|
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");
|
|
505
|
+
return { record, mergedHead, rebased: !fastForward, taskIds: tree.map(({ task }) => task.task.id), warnings,
|
|
506
|
+
...(receipt === undefined ? {} : { receipt: receipt.path }) };
|
|
329
507
|
});
|
|
330
508
|
}
|