@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
|
@@ -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
|
+
}
|
|
@@ -3,7 +3,7 @@ import { join, resolve } from "node:path";
|
|
|
3
3
|
import { sha256 } from "../fs/hash.js";
|
|
4
4
|
import { AgentOpsError } from "../fs/paths.js";
|
|
5
5
|
import { collectChangeSurface } from "../verify/change-surface.js";
|
|
6
|
-
import { assertSessionId, assertWorktreeName, bindSession, insideWorktreeDirectory, parseWorktreeListPorcelain, readWorktreeRecord, removeCheckout, resolveCheckouts, worktreePath, writeWorktreeRecord } from "./service.js";
|
|
6
|
+
import { assertSessionId, assertWorktreeName, bindSession, insideWorktreeDirectory, parseWorktreeListPorcelain, readWorktreeRecord, assertRunWorktreeOwnership, removeCheckout, resolveCheckouts, worktreePath, writeWorktreeRecord } from "./service.js";
|
|
7
7
|
/**
|
|
8
8
|
* A baseline no source state hashes to. A resumed session inherits whatever
|
|
9
9
|
* the last one left in the worktree, so none of it counts as "unchanged".
|
|
@@ -180,6 +180,9 @@ export async function commitWorktree(deps, options) {
|
|
|
180
180
|
if (record.sessionId !== options.sessionId) {
|
|
181
181
|
throw manageError("WORKTREE_COMMIT_FOREIGN", `${record.path} belongs to session ${record.sessionId}, not ${options.sessionId}.`);
|
|
182
182
|
}
|
|
183
|
+
if (options.runOwnership !== undefined) {
|
|
184
|
+
assertRunWorktreeOwnership(record, options.runOwnership);
|
|
185
|
+
}
|
|
183
186
|
const step = async (args, code, message) => {
|
|
184
187
|
const result = await deps.git(record.path, args);
|
|
185
188
|
if (result.exitCode !== 0) {
|
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
1
2
|
import { join } from "node:path";
|
|
2
3
|
import { sha256 } from "../fs/hash.js";
|
|
3
4
|
import { AgentOpsError } from "../fs/paths.js";
|
|
4
|
-
import { findReviewAttestation, readReviewReportArtifact } from "../review/attestation.js";
|
|
5
|
+
import { findReviewAttestation, readReviewReportArtifact, REVIEW_ATTESTATION_DIRECTORY } from "../review/attestation.js";
|
|
5
6
|
import { resolveReviewScope } from "../review/scope.js";
|
|
6
7
|
import { readPrivateFile, writePrivateFile } from "../security/permissions.js";
|
|
7
8
|
import { checkTaskCompletionEvidence } from "../task/completion.js";
|
|
8
9
|
import { FileEvidenceStore } from "../verify/evidence.js";
|
|
10
|
+
import { validateEvidence } from "../schema/validate.js";
|
|
9
11
|
import { calculateSourceFingerprint } from "../verify/source-fingerprint.js";
|
|
12
|
+
import { validateRunIntegrationReceipt } from "../run/integration.js";
|
|
10
13
|
function seal(value) {
|
|
11
14
|
return { digest: sha256(JSON.stringify(value)), value };
|
|
12
15
|
}
|
|
@@ -19,13 +22,22 @@ function receiptPath(commonDir, record, head) {
|
|
|
19
22
|
/** Complete local proof is copied into the Git common directory before the branch moves. */
|
|
20
23
|
export async function prepareFinishReceipt(options) {
|
|
21
24
|
const { record, candidateHead, targetCommit, tasks, config } = options;
|
|
22
|
-
|
|
25
|
+
if (options.integrationJournal !== undefined) {
|
|
26
|
+
validateRunIntegrationReceipt(options.integrationJournal, {
|
|
27
|
+
candidateHead, targetCommit, integrationJournal: options.integrationJournal
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
const scope = options.noChange?.reviewScope ?? await resolveReviewScope({ root: record.path, runner: options.gitRunner, base: targetCommit });
|
|
31
|
+
if (options.noChange !== undefined && options.noChange.sourceCommit !== candidateHead) {
|
|
32
|
+
throw new AgentOpsError("WORKTREE_NO_CHANGE_SOURCE_MISMATCH", "Verified-no-change receipt must pin the current candidate commit.");
|
|
33
|
+
}
|
|
23
34
|
const sourceFingerprint = await calculateSourceFingerprint(record.path, scope, options.gitRunner);
|
|
24
35
|
if (sourceFingerprint !== options.expectedFingerprint) {
|
|
25
36
|
throw new AgentOpsError("WORKTREE_FINAL_SOURCE_CHANGED", "The final candidate no longer matches the reviewed fingerprint.");
|
|
26
37
|
}
|
|
27
38
|
const evidenceStore = new FileEvidenceStore(record.path, record.path);
|
|
28
39
|
const verification = {};
|
|
40
|
+
const executionArtifacts = {};
|
|
29
41
|
const reviews = {};
|
|
30
42
|
const residualRisks = new Set();
|
|
31
43
|
const modes = new Set();
|
|
@@ -36,7 +48,8 @@ export async function prepareFinishReceipt(options) {
|
|
|
36
48
|
if (problem !== null) {
|
|
37
49
|
throw new AgentOpsError("WORKTREE_FINAL_EVIDENCE_MISSING", `Task ${task.task.id}: ${problem.remedy}`);
|
|
38
50
|
}
|
|
39
|
-
for (const references of
|
|
51
|
+
for (const references of [task.evidence, ...(task.revisions ?? []).map(revision => revision.previousEvidence)]
|
|
52
|
+
.flatMap(evidence => Object.values(evidence))) {
|
|
40
53
|
for (const reference of references) {
|
|
41
54
|
if (reference.startsWith("review:"))
|
|
42
55
|
continue;
|
|
@@ -44,11 +57,22 @@ export async function prepareFinishReceipt(options) {
|
|
|
44
57
|
if (value === null)
|
|
45
58
|
throw new AgentOpsError("WORKTREE_FINAL_EVIDENCE_MISSING", `Missing ${reference}.`);
|
|
46
59
|
verification[reference] = seal(value);
|
|
60
|
+
const checked = validateEvidence(value);
|
|
61
|
+
if (!checked.ok)
|
|
62
|
+
throw new AgentOpsError("WORKTREE_FINAL_EVIDENCE_MISSING", "Historical verification evidence is invalid.");
|
|
63
|
+
const artifact = checked.value.acceptance?.executionArtifact;
|
|
64
|
+
if (artifact !== undefined) {
|
|
65
|
+
const content = await readPrivateFile(join(record.path, artifact), record.path);
|
|
66
|
+
if (content === null || Buffer.byteLength(content) > 4 * 1024 * 1024 ||
|
|
67
|
+
artifact !== ".agent-ops/tasks/acceptance/" + sha256(content) + ".json")
|
|
68
|
+
throw new AgentOpsError("WORKTREE_FINAL_EVIDENCE_MISSING", "Paired execution artifact is missing or changed.");
|
|
69
|
+
executionArtifacts[artifact] = seal(content);
|
|
70
|
+
}
|
|
47
71
|
}
|
|
48
72
|
}
|
|
49
73
|
const attestation = await findReviewAttestation(record.path, sourceFingerprint, task.task.id);
|
|
50
74
|
const report = await readReviewReportArtifact(record.path, sourceFingerprint, task.task.id);
|
|
51
|
-
if (attestation === null || report === null || report.taskId !== task.task.id) {
|
|
75
|
+
if (attestation === null || report === null || report.status !== "PASS" || report.taskId !== task.task.id) {
|
|
52
76
|
throw new AgentOpsError("WORKTREE_FINAL_EVIDENCE_MISSING", `Missing review for ${task.task.id}.`);
|
|
53
77
|
}
|
|
54
78
|
modes.add(attestation.tree === undefined ? "per-task-fallback" : "tree");
|
|
@@ -61,6 +85,35 @@ export async function prepareFinishReceipt(options) {
|
|
|
61
85
|
.map(({ attestation }) => JSON.stringify(attestation.value.tree))).size !== 1)) {
|
|
62
86
|
throw new AgentOpsError("WORKTREE_FINAL_REVIEW_MISMATCH", "Final tasks do not share one complete review mode and tree scope.");
|
|
63
87
|
}
|
|
88
|
+
const historicalReviews = {};
|
|
89
|
+
let names;
|
|
90
|
+
try {
|
|
91
|
+
names = await readdir(join(record.path, REVIEW_ATTESTATION_DIRECTORY));
|
|
92
|
+
}
|
|
93
|
+
catch (cause) {
|
|
94
|
+
if (cause.code === "ENOENT")
|
|
95
|
+
names = [];
|
|
96
|
+
else
|
|
97
|
+
throw cause;
|
|
98
|
+
}
|
|
99
|
+
for (const name of names.filter(name => /^[a-f0-9]{64}\.[a-z][a-z0-9-]{0,127}\.reports\.json$/u.test(name))) {
|
|
100
|
+
const path = REVIEW_ATTESTATION_DIRECTORY + "/" + name;
|
|
101
|
+
const content = await readPrivateFile(join(record.path, path), record.path);
|
|
102
|
+
if (content === null || Buffer.byteLength(content) > 512 * 1024)
|
|
103
|
+
continue;
|
|
104
|
+
let artifact;
|
|
105
|
+
try {
|
|
106
|
+
artifact = JSON.parse(content);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (artifact === null || typeof artifact !== "object" || artifact.status !== "FAIL" ||
|
|
112
|
+
!tasks.some(task => task.task.id === artifact.taskId) ||
|
|
113
|
+
name !== artifact.sourceFingerprint + "." + artifact.taskId + ".reports.json")
|
|
114
|
+
continue;
|
|
115
|
+
historicalReviews[path] = seal(content);
|
|
116
|
+
}
|
|
64
117
|
const receipt = {
|
|
65
118
|
schemaVersion: 1,
|
|
66
119
|
sessionId: record.sessionId,
|
|
@@ -71,15 +124,28 @@ export async function prepareFinishReceipt(options) {
|
|
|
71
124
|
tasks,
|
|
72
125
|
children: options.children,
|
|
73
126
|
verification,
|
|
127
|
+
executionArtifacts,
|
|
128
|
+
historicalReviews,
|
|
74
129
|
reviews,
|
|
75
130
|
residualRisks: [...residualRisks],
|
|
76
|
-
createdAt: new Date().toISOString()
|
|
131
|
+
createdAt: new Date().toISOString(),
|
|
132
|
+
...(options.noChange === undefined ? {} : {
|
|
133
|
+
noChange: {
|
|
134
|
+
sourceCommit: options.noChange.sourceCommit,
|
|
135
|
+
contractDigest: options.noChange.contractDigest,
|
|
136
|
+
reviewScope: JSON.stringify(options.noChange.reviewScope),
|
|
137
|
+
artifactRefs: options.noChange.artifactRefs
|
|
138
|
+
}
|
|
139
|
+
}),
|
|
140
|
+
...(options.integrationJournal === undefined ? {} : { integrationJournal: options.integrationJournal })
|
|
77
141
|
};
|
|
78
142
|
const content = `${JSON.stringify(receipt, null, 2)}\n`;
|
|
79
143
|
const path = receiptPath(options.commonDir, record, candidateHead);
|
|
80
144
|
await writePrivateFile(path, content, options.commonDir);
|
|
81
145
|
const reread = await readPrivateFile(path, options.commonDir);
|
|
82
146
|
if (reread !== content || Object.values(verification).some((item) => !validSeal(item)) ||
|
|
147
|
+
Object.values(executionArtifacts).some(item => !validSeal(item)) ||
|
|
148
|
+
Object.values(historicalReviews).some(item => !validSeal(item)) ||
|
|
83
149
|
Object.values(reviews).some(({ attestation, report }) => !validSeal(attestation) || !validSeal(report))) {
|
|
84
150
|
throw new AgentOpsError("WORKTREE_RECEIPT_INVALID", "The local finish receipt could not be read back intact.");
|
|
85
151
|
}
|
|
@@ -28,6 +28,25 @@ const DEFAULT_SETUP_TIMEOUT_MS = 10 * 60 * 1000;
|
|
|
28
28
|
export function mayUseWorktree(record, sessionId, agentId) {
|
|
29
29
|
return record.sessionId === sessionId && (agentId === undefined || record.agentId === agentId);
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Run workers use an explicit coordinator mapping in addition to the legacy
|
|
33
|
+
* session/agent guard. A native session id alone cannot claim a worktree.
|
|
34
|
+
*/
|
|
35
|
+
export function mayUseRunWorktree(record, ownership, sessionId, agentId) {
|
|
36
|
+
return mayUseWorktree(record, sessionId, agentId) &&
|
|
37
|
+
record.runId === ownership.runId &&
|
|
38
|
+
record.workerId === ownership.workerId &&
|
|
39
|
+
record.ownerSessionId === ownership.ownerSessionId &&
|
|
40
|
+
record.workerGeneration === ownership.generation;
|
|
41
|
+
}
|
|
42
|
+
export function assertRunWorktreeOwnership(record, ownership) {
|
|
43
|
+
if (record.runId !== ownership.runId ||
|
|
44
|
+
record.workerId !== ownership.workerId ||
|
|
45
|
+
record.ownerSessionId !== ownership.ownerSessionId ||
|
|
46
|
+
record.workerGeneration !== ownership.generation) {
|
|
47
|
+
throw worktreeError("WORKTREE_RUN_OWNERSHIP_MISMATCH", `Worktree ${record.name} is not owned by run ${ownership.runId}, worker ${ownership.workerId}, generation ${ownership.generation}.`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
31
50
|
function worktreeError(code, message) {
|
|
32
51
|
return new AgentOpsError(code, message);
|
|
33
52
|
}
|
|
@@ -199,6 +218,11 @@ export async function readWorktreeRecord(root) {
|
|
|
199
218
|
const value = JSON.parse(source);
|
|
200
219
|
return value.schemaVersion === 1 && typeof value.name === "string" &&
|
|
201
220
|
(value.agentId === undefined || typeof value.agentId === "string") &&
|
|
221
|
+
(value.runId === undefined || typeof value.runId === "string") &&
|
|
222
|
+
(value.workerId === undefined || typeof value.workerId === "string") &&
|
|
223
|
+
(value.coordinatorId === undefined || typeof value.coordinatorId === "string") &&
|
|
224
|
+
(value.ownerSessionId === undefined || typeof value.ownerSessionId === "string") &&
|
|
225
|
+
(value.workerGeneration === undefined || (typeof value.workerGeneration === "number" && Number.isSafeInteger(value.workerGeneration) && value.workerGeneration > 0)) &&
|
|
202
226
|
typeof value.branch === "string" && typeof value.path === "string" &&
|
|
203
227
|
typeof value.mainRoot === "string" && typeof value.targetBranch === "string" &&
|
|
204
228
|
typeof value.base === "string" && typeof value.sessionId === "string" &&
|
|
@@ -319,10 +343,19 @@ export async function addWorktree(deps, options) {
|
|
|
319
343
|
base,
|
|
320
344
|
sessionId,
|
|
321
345
|
...(options.agentId === undefined ? {} : { agentId: options.agentId }),
|
|
346
|
+
...(options.runOwnership === undefined ? {} : {
|
|
347
|
+
runId: options.runOwnership.runId,
|
|
348
|
+
...(options.runOwnership.coordinatorId === undefined ? {} : { coordinatorId: options.runOwnership.coordinatorId }),
|
|
349
|
+
workerId: options.runOwnership.workerId,
|
|
350
|
+
ownerSessionId: options.runOwnership.ownerSessionId,
|
|
351
|
+
workerGeneration: options.runOwnership.generation
|
|
352
|
+
}),
|
|
322
353
|
createdAt: deps.now?.() ?? new Date().toISOString()
|
|
323
354
|
};
|
|
324
355
|
let trusted = false;
|
|
325
356
|
try {
|
|
357
|
+
if (options.runOwnership !== undefined)
|
|
358
|
+
await writeWorktreeRecord(record);
|
|
326
359
|
const copied = await copyMissing(mainRoot, record.path, await carriedFiles(deps, mainRoot));
|
|
327
360
|
const worktreeConfig = await deps.loadConfig(record.path);
|
|
328
361
|
// The same commands the user already trusted, and nothing else: any
|
|
@@ -332,6 +365,8 @@ export async function addWorktree(deps, options) {
|
|
|
332
365
|
trusted = true;
|
|
333
366
|
}
|
|
334
367
|
const setup = [];
|
|
368
|
+
if (options.runOwnership !== undefined)
|
|
369
|
+
await writePrivateFile(join(record.path, ".agent-ops/tasks/run-setup-attempt.json"), JSON.stringify({ startedAt: deps.now?.() ?? new Date().toISOString() }), record.path);
|
|
335
370
|
for (const step of setupSteps) {
|
|
336
371
|
const result = await deps.runSetup(record.path, {
|
|
337
372
|
...step,
|
|
@@ -339,15 +374,19 @@ export async function addWorktree(deps, options) {
|
|
|
339
374
|
});
|
|
340
375
|
const label = [step.command, ...step.args].join(" ");
|
|
341
376
|
if (result.exitCode !== 0) {
|
|
342
|
-
throw worktreeError("WORKTREE_SETUP_FAILED", `Setup step failed (${label}, exit ${String(result.exitCode)}); the worktree was removed. ${result.output.trim().split("\n").slice(-5).join("\n")}`.trim());
|
|
377
|
+
throw worktreeError(options.runOwnership === undefined ? "WORKTREE_SETUP_FAILED" : "RUN_SETUP_RECOVERY_REQUIRED", `Setup step failed (${label}, exit ${String(result.exitCode)}); ${options.runOwnership === undefined ? "the worktree was removed" : "the run checkout was preserved"}. ${result.output.trim().split("\n").slice(-5).join("\n")}`.trim());
|
|
343
378
|
}
|
|
344
379
|
setup.push(label);
|
|
345
380
|
}
|
|
381
|
+
if (options.runOwnership !== undefined)
|
|
382
|
+
await writePrivateFile(join(record.path, ".agent-ops/tasks/run-setup-complete.json"), JSON.stringify({ setupHash: sha256(JSON.stringify(setupSteps)) }), record.path);
|
|
346
383
|
await writeWorktreeRecord(record);
|
|
347
384
|
await bindSession(deps, record, mainConfig);
|
|
348
385
|
return { record, copied, trusted, setup };
|
|
349
386
|
}
|
|
350
387
|
catch (error) {
|
|
388
|
+
if (options.runOwnership !== undefined)
|
|
389
|
+
throw error;
|
|
351
390
|
if (mainConfig.features.completionGate.enabled) {
|
|
352
391
|
const mainGate = await deps.gate(mainRoot, mainConfig);
|
|
353
392
|
await (options.agentId === undefined ? mainGate.redirect(sessionId, null) : mainGate.removeRoot(sessionId, path))
|
|
@@ -387,6 +426,7 @@ export async function ensureSessionWorktree(deps, options) {
|
|
|
387
426
|
cwd: mainRoot,
|
|
388
427
|
name,
|
|
389
428
|
sessionId: options.sessionId,
|
|
429
|
+
...(options.runOwnership === undefined ? {} : { runOwnership: options.runOwnership }),
|
|
390
430
|
...(agentId === undefined ? {} : { agentId })
|
|
391
431
|
})).record;
|
|
392
432
|
}
|