@kylecheng3146/agent-ops 0.5.3 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -0
- package/dist/packages/cli/src/args.js +165 -2
- package/dist/packages/cli/src/bin.js +64 -17
- package/dist/packages/cli/src/cli.js +33 -6
- package/dist/packages/cli/src/commands/advance.js +23 -3
- 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 +111 -0
- package/dist/packages/cli/src/commands/task.js +61 -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/parallel/finish.js +85 -22
- package/dist/runtime/src/parallel/guard.js +5 -1
- 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/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 +67 -7
- 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/loop-engineering.md +49 -0
- package/docs/zh-TW/spec/acceptance-and-evidence.md +21 -1
- package/docs/zh-TW/spec/loop-engineering.md +22 -0
- package/package.json +1 -1
- package/schemas/config.schema.json +221 -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
|
@@ -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,
|
|
@@ -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";
|
|
@@ -54,7 +55,10 @@ agentId) {
|
|
|
54
55
|
// session's mixes two tasks' changes into one review and one merge.
|
|
55
56
|
const name = relative(join(mainRoot, WORKTREE_DIRECTORY), target).split(sep)[0] ?? "";
|
|
56
57
|
const record = await readWorktreeRecord(join(mainRoot, WORKTREE_DIRECTORY, name));
|
|
57
|
-
|
|
58
|
+
const permitted = record === null || (record.runId === undefined
|
|
59
|
+
? mayUseWorktree(record, sessionId, agentId)
|
|
60
|
+
: await registeredRunWriter(record, sessionId, agentId));
|
|
61
|
+
if (record !== null && !permitted) {
|
|
58
62
|
const owner = record.sessionId;
|
|
59
63
|
const sameSession = owner === sessionId;
|
|
60
64
|
let own = "";
|