@kylecheng3146/agent-ops 0.5.0 → 0.5.1
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/dist/packages/cli/src/bin.js +22 -2
- package/dist/packages/cli/src/commands/parallel.js +1 -0
- package/dist/packages/cli/src/commands/update.js +13 -1
- package/dist/packages/cli/src/hook-process.js +2 -2
- package/dist/packages/cli/src/public-plan.js +1 -0
- package/dist/runtime/src/adapters/claude/input.js +8 -2
- package/dist/runtime/src/hooks/completion-gate.js +81 -8
- package/dist/runtime/src/install/doctor.js +9 -2
- package/dist/runtime/src/install/ownership.js +7 -3
- package/dist/runtime/src/install/plan.js +38 -7
- package/dist/runtime/src/parallel/enter.js +6 -4
- package/dist/runtime/src/parallel/finish.js +2 -1
- package/dist/runtime/src/parallel/guard.js +16 -10
- package/dist/runtime/src/parallel/manage.js +8 -2
- package/dist/runtime/src/parallel/service.js +34 -8
- package/dist/runtime/src/review/slots.js +84 -0
- package/docs/en/spec/delegation.md +10 -0
- package/docs/en/spec/maintenance.md +10 -0
- package/docs/en/spec/troubleshooting.md +10 -0
- package/docs/zh-TW/spec/delegation.md +10 -0
- package/docs/zh-TW/spec/maintenance.md +10 -0
- package/docs/zh-TW/spec/troubleshooting.md +10 -0
- package/package.json +1 -1
|
@@ -34,6 +34,7 @@ import { runTaskCommand } from "./commands/task.js";
|
|
|
34
34
|
import { hasFreshVerification, runReviewCommand } from "./commands/review.js";
|
|
35
35
|
import { runBatchCommand } from "./commands/batch.js";
|
|
36
36
|
import { createSourceGuard } from "../../../runtime/src/review/batch-guard.js";
|
|
37
|
+
import { withReviewSlot } from "../../../runtime/src/review/slots.js";
|
|
37
38
|
import { memoizePreflight } from "../../../runtime/src/review/batch.js";
|
|
38
39
|
import { createReviewExecutor, ReviewInterruptedError } from "../../../runtime/src/review/execute.js";
|
|
39
40
|
import { probeReviewTargetDetailed } from "../../../runtime/src/review/probe.js";
|
|
@@ -452,6 +453,25 @@ else {
|
|
|
452
453
|
const reviewConfig = (await loadEffectiveConfig(root, reviewArgs.scope === "user" ? "user" : "project")).config;
|
|
453
454
|
const reviewRole = resolveReviewRole("independent-review", reviewConfig.reviewRoles ?? []);
|
|
454
455
|
const configuredReviewTargets = reviewRole?.targets ?? [];
|
|
456
|
+
// Every worktree of the repository shares this directory, so the
|
|
457
|
+
// slots bound reviews across writers, not just within one process.
|
|
458
|
+
const commonDirResult = await reviewGit.run([
|
|
459
|
+
"rev-parse",
|
|
460
|
+
"--path-format=absolute",
|
|
461
|
+
"--git-common-dir"
|
|
462
|
+
]);
|
|
463
|
+
const slotDir = commonDirResult.exitCode === 0
|
|
464
|
+
? new TextDecoder().decode(commonDirResult.stdout).trim()
|
|
465
|
+
: undefined;
|
|
466
|
+
const inSlot = (executor) => slotDir === undefined
|
|
467
|
+
? executor
|
|
468
|
+
: async (request) => await withReviewSlot({
|
|
469
|
+
dir: slotDir,
|
|
470
|
+
signal,
|
|
471
|
+
onWait: (line) => {
|
|
472
|
+
process.stderr.write(`${progressPrefix}${line}\n`);
|
|
473
|
+
}
|
|
474
|
+
}, async () => await executor(request));
|
|
455
475
|
return {
|
|
456
476
|
args: reviewArgs,
|
|
457
477
|
authorized: reviewArgs.yes,
|
|
@@ -470,7 +490,7 @@ else {
|
|
|
470
490
|
currentPolicyConfigHash: async () => calculateConfigHash((await loadEffectiveConfig(root, reviewArgs.scope === "user" ? "user" : "project")).config),
|
|
471
491
|
config: reviewConfig,
|
|
472
492
|
evidenceStore: new FileEvidenceStore(root, root),
|
|
473
|
-
execute: createReviewExecutor({
|
|
493
|
+
execute: inSlot(createReviewExecutor({
|
|
474
494
|
targets: configuredReviewTargets,
|
|
475
495
|
cwd: root,
|
|
476
496
|
...(reviewRole?.model === undefined
|
|
@@ -495,7 +515,7 @@ else {
|
|
|
495
515
|
onProgress: (line) => {
|
|
496
516
|
process.stderr.write(`${progressPrefix}${line}\n`);
|
|
497
517
|
}
|
|
498
|
-
})
|
|
518
|
+
}))
|
|
499
519
|
};
|
|
500
520
|
};
|
|
501
521
|
if (args.command === "review") {
|
|
@@ -11,6 +11,7 @@ function listText(statuses, now) {
|
|
|
11
11
|
`${record.name}${idle.has(record.name) ? " (idle)" : ""}`,
|
|
12
12
|
` path: ${record.path}`,
|
|
13
13
|
` session: ${record.sessionId}`,
|
|
14
|
+
...(record.agentId === undefined ? [] : [` agent: ${record.agentId}`]),
|
|
14
15
|
` branch: ${record.branch}, ${ahead} commit(s) ahead of ${record.targetBranch}${dirty ? ", uncommitted changes" : ""}`,
|
|
15
16
|
` task: ${taskStatus}`,
|
|
16
17
|
` last activity: ${lastActivity}`
|
|
@@ -6,6 +6,9 @@ import { okEnvelope } from "../output.js";
|
|
|
6
6
|
import { formatOperationPlan } from "../plan-output.js";
|
|
7
7
|
import { toPublicUpdatePlan } from "../public-plan.js";
|
|
8
8
|
import { formatTrustChange, planTrustGrant } from "./trust.js";
|
|
9
|
+
function repairedLines(plan) {
|
|
10
|
+
return plan.installation.repaired.map(({ path, reason }) => `repaired: ${path} (${reason.replace("-", " ")})`);
|
|
11
|
+
}
|
|
9
12
|
export function formatUpdatePlan(plan, trust) {
|
|
10
13
|
const detectedVerification = plan.installation.detectedVerification;
|
|
11
14
|
return formatOperationPlan({
|
|
@@ -21,6 +24,7 @@ export function formatUpdatePlan(plan, trust) {
|
|
|
21
24
|
: ""}`
|
|
22
25
|
]),
|
|
23
26
|
...(trust === undefined ? [] : formatTrustChange(trust)),
|
|
27
|
+
...repairedLines(plan),
|
|
24
28
|
`Schema migrations: ${plan.migrationSteps.length === 0
|
|
25
29
|
? "none"
|
|
26
30
|
: plan.migrationSteps
|
|
@@ -146,6 +150,14 @@ export async function runUpdateCommand(options) {
|
|
|
146
150
|
return okEnvelope("UPDATE_APPLIED", {
|
|
147
151
|
applied: true,
|
|
148
152
|
plan: toPublicUpdatePlan(plan, trust),
|
|
149
|
-
message: `Managed installation updated to ${plan.targetVersion}
|
|
153
|
+
message: `Managed installation updated to ${plan.targetVersion}.`,
|
|
154
|
+
...(plan.installation.repaired.length === 0
|
|
155
|
+
? {}
|
|
156
|
+
: {
|
|
157
|
+
text: [
|
|
158
|
+
`Managed installation updated to ${plan.targetVersion}.`,
|
|
159
|
+
...repairedLines(plan)
|
|
160
|
+
].join("\n")
|
|
161
|
+
})
|
|
150
162
|
});
|
|
151
163
|
}
|
|
@@ -353,7 +353,7 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
|
|
|
353
353
|
? { action: "continue", status: "UNKNOWN", code: "WORKTREE_GUARD_UNAVAILABLE" }
|
|
354
354
|
: await evaluateWorktreeWrite(mainRoot, write.paths, write.sessionId, dependencies.worktree === undefined
|
|
355
355
|
? undefined
|
|
356
|
-
: async (sessionId) => (await ensureSessionWorktree(dependencies.worktree, { cwd: mainRoot, sessionId })).path);
|
|
356
|
+
: async (sessionId, agentId) => (await ensureSessionWorktree(dependencies.worktree, { cwd: mainRoot, sessionId, ...(agentId === undefined ? {} : { agentId }) })).path, write.agentId);
|
|
357
357
|
}
|
|
358
358
|
: undefined;
|
|
359
359
|
const worktreeEnter = config.worktree?.mode === "auto"
|
|
@@ -361,7 +361,7 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
|
|
|
361
361
|
const mainRoot = await resolveMainRoot(gitRunner);
|
|
362
362
|
return mainRoot === null
|
|
363
363
|
? { action: "continue", status: "UNKNOWN", code: "WORKTREE_GUARD_UNAVAILABLE" }
|
|
364
|
-
: await evaluateWorktreeEnter(mainRoot, enter.path, enter.sessionId);
|
|
364
|
+
: await evaluateWorktreeEnter(mainRoot, enter.path, enter.sessionId, enter.agentId);
|
|
365
365
|
}
|
|
366
366
|
: undefined;
|
|
367
367
|
if (hookEvent === "SessionStart" && config.worktree?.mode === "auto" &&
|
|
@@ -70,6 +70,7 @@ export function toPublicInstallPlan(plan, trust) {
|
|
|
70
70
|
...toPublicOperations(plan.operations),
|
|
71
71
|
...toPublicPreauthorization(plan.preauthorization)
|
|
72
72
|
],
|
|
73
|
+
repaired: plan.repaired,
|
|
73
74
|
detectedVerification: plan.detectedVerification,
|
|
74
75
|
...(trust === undefined ? {} : { trust })
|
|
75
76
|
};
|
|
@@ -27,6 +27,10 @@ export function normalizeClaudeHookInput(input) {
|
|
|
27
27
|
const sessionId = typeof input.session_id === "string"
|
|
28
28
|
? input.session_id
|
|
29
29
|
: undefined;
|
|
30
|
+
// Present only inside a subagent, which shares the parent's session_id.
|
|
31
|
+
const agentId = typeof input.agent_id === "string" && /^[A-Za-z0-9_-]{1,128}$/u.test(input.agent_id)
|
|
32
|
+
? input.agent_id
|
|
33
|
+
: undefined;
|
|
30
34
|
if (input.hook_event_name === "SessionStart") {
|
|
31
35
|
return {
|
|
32
36
|
...normalizeHookEvent({ event: "session-start", projectRoot }),
|
|
@@ -59,7 +63,8 @@ export function normalizeClaudeHookInput(input) {
|
|
|
59
63
|
event: "file-write",
|
|
60
64
|
projectRoot,
|
|
61
65
|
paths: [resolve(projectRoot, target)],
|
|
62
|
-
...(sessionId === undefined ? {} : { sessionId })
|
|
66
|
+
...(sessionId === undefined ? {} : { sessionId }),
|
|
67
|
+
...(agentId === undefined ? {} : { agentId })
|
|
63
68
|
};
|
|
64
69
|
}
|
|
65
70
|
}
|
|
@@ -76,7 +81,8 @@ export function normalizeClaudeHookInput(input) {
|
|
|
76
81
|
event: "enter-worktree",
|
|
77
82
|
projectRoot,
|
|
78
83
|
path: resolve(projectRoot, target),
|
|
79
|
-
...(sessionId === undefined ? {} : { sessionId })
|
|
84
|
+
...(sessionId === undefined ? {} : { sessionId }),
|
|
85
|
+
...(agentId === undefined ? {} : { agentId })
|
|
80
86
|
};
|
|
81
87
|
}
|
|
82
88
|
}
|
|
@@ -47,9 +47,11 @@ function parseState(source, sessionId) {
|
|
|
47
47
|
throw new AgentOpsError("COMPLETION_GATE_STATE_INVALID", "Completion-gate state is invalid.");
|
|
48
48
|
}
|
|
49
49
|
const record = value;
|
|
50
|
-
const keys = Object.keys(record).filter((key) => key !== "root").sort().join(",");
|
|
50
|
+
const keys = Object.keys(record).filter((key) => key !== "root" && key !== "extraRoots").sort().join(",");
|
|
51
51
|
if (keys !== "baselineFingerprint,permitFingerprint,schemaVersion,sessionId" ||
|
|
52
52
|
("root" in record && !validRoot(record.root)) ||
|
|
53
|
+
("extraRoots" in record &&
|
|
54
|
+
(!Array.isArray(record.extraRoots) || record.extraRoots.length > 64 || !record.extraRoots.every(validRoot))) ||
|
|
53
55
|
record.schemaVersion !== 1 ||
|
|
54
56
|
record.sessionId !== sessionId ||
|
|
55
57
|
typeof record.baselineFingerprint !== "string" ||
|
|
@@ -253,12 +255,75 @@ export class CompletionGateService {
|
|
|
253
255
|
}
|
|
254
256
|
async grantPermit(sessionId) {
|
|
255
257
|
const fingerprint = await this.#fingerprint();
|
|
256
|
-
await this.#store.mutate(sessionId, (state) => {
|
|
258
|
+
const granted = await this.#store.mutate(sessionId, (state) => {
|
|
257
259
|
if (state === null) {
|
|
258
260
|
throw new AgentOpsError("COMPLETION_GATE_NOT_INITIALIZED", "The session has no completion-gate baseline.");
|
|
259
261
|
}
|
|
260
262
|
return { ...state, permitFingerprint: fingerprint };
|
|
261
263
|
});
|
|
264
|
+
// One approved Stop covers every worktree the session works in.
|
|
265
|
+
if (this.#options.forRoot !== undefined) {
|
|
266
|
+
for (const root of this.#delegatedRoots(granted)) {
|
|
267
|
+
if (await isDirectory(root))
|
|
268
|
+
await (await this.#options.forRoot(root)).grantPermit(sessionId);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/** The worktrees whose gates decide for this session, other than this checkout. */
|
|
273
|
+
#delegatedRoots(state) {
|
|
274
|
+
const roots = [...(state.root === undefined ? [] : [state.root]), ...(state.extraRoots ?? [])];
|
|
275
|
+
return [...new Set(roots)].filter((root) => root !== this.#options.root);
|
|
276
|
+
}
|
|
277
|
+
/** Adds a subagent's worktree to the roots this session's Stop must cover. */
|
|
278
|
+
async addRoot(sessionId, root) {
|
|
279
|
+
const fingerprint = await this.#fingerprint();
|
|
280
|
+
await this.#store.mutate(sessionId, (current) => {
|
|
281
|
+
const base = current ?? {
|
|
282
|
+
schemaVersion: 1,
|
|
283
|
+
sessionId,
|
|
284
|
+
baselineFingerprint: fingerprint,
|
|
285
|
+
permitFingerprint: null
|
|
286
|
+
};
|
|
287
|
+
return { ...base, extraRoots: [...new Set([...(base.extraRoots ?? []), root])] };
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
/** Drops one worktree from the session, leaving its other roots in place. */
|
|
291
|
+
async removeRoot(sessionId, root) {
|
|
292
|
+
if (await this.#store.read(sessionId) === null)
|
|
293
|
+
return;
|
|
294
|
+
await this.#store.mutate(sessionId, (current) => {
|
|
295
|
+
if (current === null)
|
|
296
|
+
throw new AgentOpsError("COMPLETION_GATE_NOT_INITIALIZED", "The session baseline disappeared.");
|
|
297
|
+
const { root: own, extraRoots, ...rest } = current;
|
|
298
|
+
const remaining = (extraRoots ?? []).filter((candidate) => candidate !== root);
|
|
299
|
+
return {
|
|
300
|
+
...rest,
|
|
301
|
+
...(own === undefined || own === root ? {} : { root: own }),
|
|
302
|
+
...(remaining.length === 0 ? {} : { extraRoots: remaining })
|
|
303
|
+
};
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* After `worktree finish` merged one worktree: moves the baseline past the
|
|
308
|
+
* merge and forgets that worktree, keeping the session's other roots.
|
|
309
|
+
*/
|
|
310
|
+
async afterFinish(sessionId, finishedRoot, baselineFingerprint) {
|
|
311
|
+
await this.#store.mutate(sessionId, (current) => {
|
|
312
|
+
const { root: own, extraRoots, ...rest } = current ?? {
|
|
313
|
+
schemaVersion: 1,
|
|
314
|
+
sessionId,
|
|
315
|
+
baselineFingerprint,
|
|
316
|
+
permitFingerprint: null
|
|
317
|
+
};
|
|
318
|
+
const remaining = (extraRoots ?? []).filter((candidate) => candidate !== finishedRoot);
|
|
319
|
+
return {
|
|
320
|
+
...rest,
|
|
321
|
+
baselineFingerprint,
|
|
322
|
+
permitFingerprint: null,
|
|
323
|
+
...(own === undefined || own === finishedRoot ? {} : { root: own }),
|
|
324
|
+
...(remaining.length === 0 ? {} : { extraRoots: remaining })
|
|
325
|
+
};
|
|
326
|
+
});
|
|
262
327
|
}
|
|
263
328
|
#isPermitCommand(event) {
|
|
264
329
|
const commands = event.event === "command" ? [event] :
|
|
@@ -360,16 +425,24 @@ export class CompletionGateService {
|
|
|
360
425
|
: "The session baseline is unavailable: its SessionStart never reached this checkout. The next tool call records one; if this repeats, run agent-ops doctor.");
|
|
361
426
|
}
|
|
362
427
|
const changed = state.baselineFingerprint !== fingerprint && state.permitFingerprint !== fingerprint;
|
|
363
|
-
|
|
428
|
+
const delegated = this.#delegatedRoots(state);
|
|
429
|
+
if (delegated.length > 0) {
|
|
364
430
|
// This checkout only has to stay untouched; the work, its task and its
|
|
365
|
-
// evidence live in the
|
|
431
|
+
// evidence live in the worktrees, and each of their gates decides.
|
|
366
432
|
if (changed) {
|
|
367
|
-
return gateResult("block", "FAIL", "COMPLETION_GATE_MAIN_CHANGED", `This session works in ${
|
|
433
|
+
return gateResult("block", "FAIL", "COMPLETION_GATE_MAIN_CHANGED", `This session works in ${delegated.join(", ")}, but this checkout changed too. Move those edits into a worktree, or ask the user for a one-time permit.`);
|
|
368
434
|
}
|
|
369
|
-
|
|
370
|
-
|
|
435
|
+
let last = null;
|
|
436
|
+
for (const root of delegated) {
|
|
437
|
+
if (!await isDirectory(root) || this.#options.forRoot === undefined) {
|
|
438
|
+
return gateResult("block", "UNKNOWN", "COMPLETION_GATE_WORKTREE_MISSING", `The worktree ${root} is unavailable. Run agent-ops worktree list, then resume or remove it.`);
|
|
439
|
+
}
|
|
440
|
+
const result = await (await this.#options.forRoot(root)).handle(event);
|
|
441
|
+
if (result !== null && result.action === "block")
|
|
442
|
+
return result;
|
|
443
|
+
last = result;
|
|
371
444
|
}
|
|
372
|
-
return
|
|
445
|
+
return last;
|
|
373
446
|
}
|
|
374
447
|
if (changed) {
|
|
375
448
|
const failure = await this.#validateTask(sessionId);
|
|
@@ -5,7 +5,7 @@ import { parseInstallManifest, PROJECT_MANIFEST_PATH } from "../fs/manifest.js";
|
|
|
5
5
|
import { resolveContainedPath } from "../fs/paths.js";
|
|
6
6
|
import { validateConfig } from "../schema/validate.js";
|
|
7
7
|
import { detectHostRestriction } from "../review/host-sandbox.js";
|
|
8
|
-
import { assertExpectedManagedBlock, assertSupportedManifestOwnership } from "./ownership.js";
|
|
8
|
+
import { assertExpectedManagedBlock, managedBlockBoundariesIntact, assertSupportedManifestOwnership } from "./ownership.js";
|
|
9
9
|
import { isOpencodeManagedPlugin } from "../adapters/opencode/config.js";
|
|
10
10
|
import { harnessDescriptor, managedRules } from "./harness.js";
|
|
11
11
|
import { resolveCapabilities, resolveProfiles } from "./profiles.js";
|
|
@@ -214,6 +214,7 @@ async function checkMarkers(root, manifest) {
|
|
|
214
214
|
}
|
|
215
215
|
const failures = [];
|
|
216
216
|
const legacyPaths = [];
|
|
217
|
+
const driftedPaths = [];
|
|
217
218
|
const expectedMarkers = assertSupportedManifestOwnership(manifest, root);
|
|
218
219
|
for (const marker of manifest.markers) {
|
|
219
220
|
try {
|
|
@@ -229,12 +230,18 @@ async function checkMarkers(root, manifest) {
|
|
|
229
230
|
}
|
|
230
231
|
}
|
|
231
232
|
catch {
|
|
232
|
-
|
|
233
|
+
// Intact markers with changed content are repaired by update; broken
|
|
234
|
+
// boundaries are not, since the owned span cannot be located.
|
|
235
|
+
const intact = await readContainedText(root, marker.path).then((source) => managedBlockBoundariesIntact(source, marker), () => false);
|
|
236
|
+
(intact ? driftedPaths : failures).push(marker.path);
|
|
233
237
|
}
|
|
234
238
|
}
|
|
235
239
|
if (failures.length > 0) {
|
|
236
240
|
return check("markers", "FAIL", `Managed block markers failed verification: ${failures.join(", ")}.`, "UPDATE_REQUIRED", "Run `agent-ops update`.");
|
|
237
241
|
}
|
|
242
|
+
if (driftedPaths.length > 0) {
|
|
243
|
+
return check("markers", "DEGRADED", `Managed block content changed after installation: ${driftedPaths.join(", ")}.`, "UPDATE_REQUIRED", "Run `agent-ops update`; it rewrites the content between the markers and leaves the rest of the file.");
|
|
244
|
+
}
|
|
238
245
|
if (legacyPaths.length > 0) {
|
|
239
246
|
return check("markers", "DEGRADED", `Legacy managed routing blocks need migration: ${legacyPaths.join(", ")}.`, "UPDATE_REQUIRED", "Run `agent-ops update`.");
|
|
240
247
|
}
|
|
@@ -221,12 +221,16 @@ function exactMarkerCount(source, marker) {
|
|
|
221
221
|
}
|
|
222
222
|
return count;
|
|
223
223
|
}
|
|
224
|
+
/** The start and end markers each appear exactly once, in order. */
|
|
225
|
+
export function managedBlockBoundariesIntact(source, marker) {
|
|
226
|
+
return (exactMarkerCount(source, marker.startMarker) === 1 &&
|
|
227
|
+
exactMarkerCount(source, marker.endMarker) === 1 &&
|
|
228
|
+
source.indexOf(marker.startMarker) < source.indexOf(marker.endMarker));
|
|
229
|
+
}
|
|
224
230
|
export function assertExpectedManagedBlock(source, marker, expected) {
|
|
225
231
|
const startIndex = source.indexOf(marker.startMarker);
|
|
226
232
|
const endIndex = source.indexOf(marker.endMarker);
|
|
227
|
-
if (
|
|
228
|
-
exactMarkerCount(source, marker.endMarker) !== 1 ||
|
|
229
|
-
startIndex >= endIndex) {
|
|
233
|
+
if (!managedBlockBoundariesIntact(source, marker)) {
|
|
230
234
|
throw new AgentOpsError("MANAGED_BLOCK_CHANGED", `Managed block boundaries changed after installation: ${marker.path}`);
|
|
231
235
|
}
|
|
232
236
|
const currentBlock = normalizeToLF(source.slice(startIndex, endIndex + marker.endMarker.length));
|
|
@@ -7,7 +7,7 @@ import { formatInstallManifest, parseInstallManifest } from "../fs/manifest.js";
|
|
|
7
7
|
import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
|
|
8
8
|
import { validateConfig } from "../schema/validate.js";
|
|
9
9
|
import { planHarnessContributions, harnessDescriptor, selectHarnessHookSurface } from "./harness.js";
|
|
10
|
-
import { assertExpectedManagedBlock, assertSupportedManifestOwnership } from "./ownership.js";
|
|
10
|
+
import { assertExpectedManagedBlock, managedBlockBoundariesIntact, assertSupportedManifestOwnership } from "./ownership.js";
|
|
11
11
|
import { codexHooksExplicitlyDisabled, loopLauncherArtifactId, loopSeeds, planLoopContribution, selectedLoopHarnesses } from "./codex-loop.js";
|
|
12
12
|
import { isOpencodeManagedPlugin } from "../adapters/opencode/config.js";
|
|
13
13
|
import { planHookRegistration, planHookRemoval } from "./hooks.js";
|
|
@@ -227,13 +227,13 @@ async function planArtifact(root, artifact, existingManifest) {
|
|
|
227
227
|
if (current !== null && owned === undefined) {
|
|
228
228
|
throw new AgentOpsError("UNMANAGED_INSTALL_PATH", `Refusing to replace an unmanaged install artifact: ${artifact.path}`);
|
|
229
229
|
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
owned.hash !== current.hash) {
|
|
233
|
-
throw new AgentOpsError("MANAGED_ARTIFACT_CHANGED", `Managed artifact changed after installation: ${artifact.path}`);
|
|
234
|
-
}
|
|
230
|
+
// Drifted managed artifacts are agent-ops owned: rewrite them and report it.
|
|
231
|
+
const drifted = current !== null && owned !== undefined && owned.hash !== current.hash;
|
|
235
232
|
const hash = sha256(artifact.content);
|
|
236
233
|
return {
|
|
234
|
+
...(drifted
|
|
235
|
+
? { repaired: { path: artifact.path, reason: "artifact-drift" } }
|
|
236
|
+
: {}),
|
|
237
237
|
operation: {
|
|
238
238
|
kind: "write",
|
|
239
239
|
path: artifact.path,
|
|
@@ -335,6 +335,7 @@ async function planBlocks(root, blocks, removals = [], expectedMarkers = new Map
|
|
|
335
335
|
}
|
|
336
336
|
const operations = [];
|
|
337
337
|
const records = [];
|
|
338
|
+
const repaired = [];
|
|
338
339
|
for (const { path, blocks: pathBlocks, removals: pathRemovals } of grouped.values()) {
|
|
339
340
|
const current = await readCurrentFile(root, path);
|
|
340
341
|
if (pathRemovals.length > 0 && current === null) {
|
|
@@ -350,6 +351,30 @@ async function planBlocks(root, blocks, removals = [], expectedMarkers = new Map
|
|
|
350
351
|
content = removeManagedBlock(content, marker.id, expected.markerStyle);
|
|
351
352
|
}
|
|
352
353
|
for (const block of pathBlocks) {
|
|
354
|
+
const expected = expectedMarkers.get(block.id);
|
|
355
|
+
if (current !== null && expected !== undefined) {
|
|
356
|
+
const { start, end } = managedBlockMarkers(block.id, block.version, block.markerStyle);
|
|
357
|
+
const marker = {
|
|
358
|
+
id: block.id,
|
|
359
|
+
path,
|
|
360
|
+
hash: sha256(content),
|
|
361
|
+
owner: "agent-ops",
|
|
362
|
+
startMarker: start,
|
|
363
|
+
endMarker: end
|
|
364
|
+
};
|
|
365
|
+
try {
|
|
366
|
+
assertExpectedManagedBlock(content, marker, expected);
|
|
367
|
+
}
|
|
368
|
+
catch (error) {
|
|
369
|
+
// Content drift is repaired below; broken boundaries stay an error.
|
|
370
|
+
if (!managedBlockBoundariesIntact(content, marker)) {
|
|
371
|
+
throw error;
|
|
372
|
+
}
|
|
373
|
+
if (!repaired.some((entry) => entry.path === path)) {
|
|
374
|
+
repaired.push({ path, reason: "block-drift" });
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
353
378
|
content = applyManagedBlock(content, block);
|
|
354
379
|
}
|
|
355
380
|
const hash = sha256(content);
|
|
@@ -380,7 +405,7 @@ async function planBlocks(root, blocks, removals = [], expectedMarkers = new Map
|
|
|
380
405
|
});
|
|
381
406
|
}
|
|
382
407
|
}
|
|
383
|
-
return { operations, records };
|
|
408
|
+
return { operations, records, repaired };
|
|
384
409
|
}
|
|
385
410
|
export async function createInstallPlan(options) {
|
|
386
411
|
if (options.toolkitVersion !== undefined &&
|
|
@@ -498,10 +523,14 @@ export async function createInstallPlan(options) {
|
|
|
498
523
|
const config = await planConfig(options.root, resolved.profiles, existing?.manifest ?? null, options.existingConfig, options.reviewTargets ?? [], completionGateEnabled, options.worktree);
|
|
499
524
|
operations.push(config.operation);
|
|
500
525
|
artifacts.push(config.record);
|
|
526
|
+
const repaired = [];
|
|
501
527
|
for (const artifact of contribution.artifacts) {
|
|
502
528
|
const planned = await planArtifact(options.root, artifact, existing?.manifest ?? null);
|
|
503
529
|
operations.push(planned.operation);
|
|
504
530
|
artifacts.push(planned.record);
|
|
531
|
+
if (planned.repaired !== undefined) {
|
|
532
|
+
repaired.push(planned.repaired);
|
|
533
|
+
}
|
|
505
534
|
}
|
|
506
535
|
if (resolved.capabilities.includes("project-loop")) {
|
|
507
536
|
const seedHarnesses = loopHarnessesNeedingSeeds(options.harness, existing?.manifest ?? null);
|
|
@@ -513,6 +542,7 @@ export async function createInstallPlan(options) {
|
|
|
513
542
|
}
|
|
514
543
|
const plannedBlocks = await planBlocks(options.root, contribution.blocks, markersToRemove, expectedExistingMarkers);
|
|
515
544
|
operations.push(...plannedBlocks.operations);
|
|
545
|
+
repaired.push(...plannedBlocks.repaired);
|
|
516
546
|
const hooks = [];
|
|
517
547
|
if (options.hookRuntimePath !== undefined) {
|
|
518
548
|
for (const harness of options.harness) {
|
|
@@ -614,6 +644,7 @@ export async function createInstallPlan(options) {
|
|
|
614
644
|
config: config.config,
|
|
615
645
|
manifest,
|
|
616
646
|
operations,
|
|
647
|
+
repaired,
|
|
617
648
|
...(preauthorization.length === 0 ? {} : { preauthorization }),
|
|
618
649
|
detectedVerification: config.detectedVerification,
|
|
619
650
|
verificationBlockers: config.verificationBlockers
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { readdir } from "node:fs/promises";
|
|
2
2
|
import { join, relative, sep } from "node:path";
|
|
3
3
|
import { canonicalPath } from "./guard.js";
|
|
4
|
-
import { readWorktreeRecord, WORKTREE_DIRECTORY } from "./service.js";
|
|
4
|
+
import { mayUseWorktree, readWorktreeRecord, WORKTREE_DIRECTORY } from "./service.js";
|
|
5
5
|
/**
|
|
6
6
|
* Whether Claude's EnterWorktree may switch this session into `path`. Only the
|
|
7
7
|
* worktree agent-ops recorded for the session is answered for the host, so an
|
|
8
8
|
* allow never lets the session move its permission root to a directory it did
|
|
9
9
|
* not get from `worktree add`.
|
|
10
10
|
*/
|
|
11
|
-
export async function evaluateWorktreeEnter(mainRoot, path, sessionId) {
|
|
11
|
+
export async function evaluateWorktreeEnter(mainRoot, path, sessionId, agentId) {
|
|
12
12
|
if (sessionId === undefined) {
|
|
13
13
|
return { action: "continue", status: "UNKNOWN", code: "WORKTREE_ENTER_UNJUDGED" };
|
|
14
14
|
}
|
|
@@ -16,7 +16,8 @@ export async function evaluateWorktreeEnter(mainRoot, path, sessionId) {
|
|
|
16
16
|
const target = await canonicalPath(path);
|
|
17
17
|
const name = relative(directory, target).split(sep)[0] ?? "";
|
|
18
18
|
const inside = !target.startsWith(`${directory}${sep}`) ? false : target === join(directory, name);
|
|
19
|
-
|
|
19
|
+
const entered = inside ? await readWorktreeRecord(target) : null;
|
|
20
|
+
if (entered !== null && mayUseWorktree(entered, sessionId, agentId)) {
|
|
20
21
|
return {
|
|
21
22
|
action: "continue",
|
|
22
23
|
status: "PASS",
|
|
@@ -27,7 +28,8 @@ export async function evaluateWorktreeEnter(mainRoot, path, sessionId) {
|
|
|
27
28
|
}
|
|
28
29
|
const owned = [];
|
|
29
30
|
for (const entry of await readdir(directory).catch(() => [])) {
|
|
30
|
-
|
|
31
|
+
const record = await readWorktreeRecord(join(directory, entry));
|
|
32
|
+
if (record !== null && mayUseWorktree(record, sessionId, agentId)) {
|
|
31
33
|
owned.push(join(directory, entry));
|
|
32
34
|
}
|
|
33
35
|
}
|
|
@@ -319,7 +319,8 @@ export async function finishWorktree(deps, options) {
|
|
|
319
319
|
await mainGate.rebase(before, after);
|
|
320
320
|
// rebase only moves baselines equal to `before`; a main that moved since
|
|
321
321
|
// this session began would leave it reading the merge as its own change.
|
|
322
|
-
|
|
322
|
+
// Only this worktree leaves the session; its other roots stay covered.
|
|
323
|
+
await mainGate.afterFinish(record.sessionId, record.path, after);
|
|
323
324
|
});
|
|
324
325
|
}
|
|
325
326
|
await attempt("trust", async () => await deps.trust.revoke(record.path, worktreeConfig));
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { realpath } from "node:fs/promises";
|
|
2
2
|
import { basename, dirname, join, relative, sep } from "node:path";
|
|
3
|
-
import { insideWorktreeDirectory, readWorktreeRecord, WORKTREE_DIRECTORY } from "./service.js";
|
|
3
|
+
import { insideWorktreeDirectory, mayUseWorktree, readWorktreeRecord, WORKTREE_DIRECTORY } from "./service.js";
|
|
4
4
|
/** The main checkout of the repository `runner` runs in, or null. */
|
|
5
5
|
export async function resolveMainRoot(runner) {
|
|
6
6
|
const result = await runner.run(["rev-parse", "--path-format=absolute", "--git-common-dir"]);
|
|
@@ -42,8 +42,10 @@ export async function canonicalPath(path) {
|
|
|
42
42
|
* business.
|
|
43
43
|
*/
|
|
44
44
|
export async function evaluateWorktreeWrite(mainRoot, paths, sessionId,
|
|
45
|
-
/** Creates or reuses this
|
|
46
|
-
ensureWorktree
|
|
45
|
+
/** Creates or reuses this writer's worktree and returns its path. */
|
|
46
|
+
ensureWorktree,
|
|
47
|
+
/** The subagent making the write, when there is one: it has a worktree of its own. */
|
|
48
|
+
agentId) {
|
|
47
49
|
for (const path of paths) {
|
|
48
50
|
const target = await canonicalPath(path);
|
|
49
51
|
const inMain = target === mainRoot || target.startsWith(`${mainRoot}${sep}`);
|
|
@@ -51,17 +53,19 @@ ensureWorktree) {
|
|
|
51
53
|
// A worktree belongs to the session that made it: writing into another
|
|
52
54
|
// session's mixes two tasks' changes into one review and one merge.
|
|
53
55
|
const name = relative(join(mainRoot, WORKTREE_DIRECTORY), target).split(sep)[0] ?? "";
|
|
54
|
-
const
|
|
55
|
-
if (
|
|
56
|
+
const record = await readWorktreeRecord(join(mainRoot, WORKTREE_DIRECTORY, name));
|
|
57
|
+
if (record !== null && !mayUseWorktree(record, sessionId, agentId)) {
|
|
58
|
+
const owner = record.sessionId;
|
|
59
|
+
const sameSession = owner === sessionId;
|
|
56
60
|
let own = "";
|
|
57
61
|
if (ensureWorktree !== undefined) {
|
|
58
|
-
own = await ensureWorktree(sessionId).then((worktree) => ` Your own worktree is ${worktree}: edit there instead.`, () => "");
|
|
62
|
+
own = await ensureWorktree(sessionId, agentId).then((worktree) => ` Your own worktree is ${worktree}: edit there instead${agentId === undefined ? "" : ", with absolute paths under it"}.`, () => "");
|
|
59
63
|
}
|
|
60
64
|
return {
|
|
61
65
|
action: "block",
|
|
62
66
|
status: "FAIL",
|
|
63
|
-
code: "WORKTREE_OWNED_BY_OTHER_SESSION",
|
|
64
|
-
remedy: `${join(mainRoot, WORKTREE_DIRECTORY, name)} belongs to another session (${owner}); ` +
|
|
67
|
+
code: sameSession ? "WORKTREE_OWNED_BY_OTHER_AGENT" : "WORKTREE_OWNED_BY_OTHER_SESSION",
|
|
68
|
+
remedy: `${join(mainRoot, WORKTREE_DIRECTORY, name)} belongs to ${sameSession ? `another writer of this session (${record.agentId ?? "its main thread"})` : `another session (${owner})`}; ` +
|
|
65
69
|
`this session is ${sessionId}.${own || " Run agent-ops worktree add <name> --session " + sessionId + " from " + mainRoot + " and edit in the printed path."}`
|
|
66
70
|
};
|
|
67
71
|
}
|
|
@@ -70,12 +74,14 @@ ensureWorktree) {
|
|
|
70
74
|
let failure = "";
|
|
71
75
|
if (sessionId !== undefined && ensureWorktree !== undefined) {
|
|
72
76
|
try {
|
|
73
|
-
const worktree = await ensureWorktree(sessionId);
|
|
77
|
+
const worktree = await ensureWorktree(sessionId, agentId);
|
|
74
78
|
return {
|
|
75
79
|
action: "block",
|
|
76
80
|
status: "FAIL",
|
|
77
81
|
code: "WORKTREE_CREATED",
|
|
78
|
-
remedy:
|
|
82
|
+
remedy: agentId === undefined
|
|
83
|
+
? `worktree.mode is auto, so this session now has its own worktree at ${worktree}. Enter it (Claude Code: EnterWorktree with path ${worktree}; otherwise use the worktree path as every command's working directory) and redo this edit there, at ${join(worktree, relative(mainRoot, target))}.`
|
|
84
|
+
: `worktree.mode is auto, so this subagent now has its own worktree at ${worktree}. Redo this edit there with an absolute path, at ${join(worktree, relative(mainRoot, target))}, and use absolute paths under ${worktree} for every later edit.`
|
|
79
85
|
};
|
|
80
86
|
}
|
|
81
87
|
catch (error) {
|
|
@@ -149,7 +149,10 @@ export async function resumeWorktree(deps, options) {
|
|
|
149
149
|
await writeWorktreeRecord(record);
|
|
150
150
|
const mainConfig = await deps.loadConfig(mainRoot);
|
|
151
151
|
if (mainConfig.features.completionGate.enabled && previous.sessionId !== sessionId) {
|
|
152
|
-
|
|
152
|
+
const mainGate = await deps.gate(mainRoot, mainConfig);
|
|
153
|
+
await (previous.agentId === undefined
|
|
154
|
+
? mainGate.redirect(previous.sessionId, null)
|
|
155
|
+
: mainGate.removeRoot(previous.sessionId, previous.path));
|
|
153
156
|
}
|
|
154
157
|
await bindSession(deps, record, mainConfig, RESUMED_BASELINE);
|
|
155
158
|
return record;
|
|
@@ -206,7 +209,10 @@ export async function removeWorktree(deps, options) {
|
|
|
206
209
|
const { record } = status;
|
|
207
210
|
const mainConfig = await deps.loadConfig(mainRoot);
|
|
208
211
|
if (mainConfig.features.completionGate.enabled) {
|
|
209
|
-
|
|
212
|
+
const mainGate = await deps.gate(mainRoot, mainConfig);
|
|
213
|
+
await (record.agentId === undefined
|
|
214
|
+
? mainGate.redirect(record.sessionId, null)
|
|
215
|
+
: mainGate.removeRoot(record.sessionId, record.path));
|
|
210
216
|
}
|
|
211
217
|
await deps.trust.revoke(record.path, await deps.loadConfig(record.path)).catch(() => undefined);
|
|
212
218
|
await removeCheckout(deps, record, true);
|
|
@@ -24,6 +24,10 @@ const NAME = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/u;
|
|
|
24
24
|
const SESSION = /^[^\0\r\n]{1,256}$/u;
|
|
25
25
|
const TARGET_BRANCH = /^[A-Za-z0-9._/-]{1,128}$/u;
|
|
26
26
|
const DEFAULT_SETUP_TIMEOUT_MS = 10 * 60 * 1000;
|
|
27
|
+
/** The session's own worktree is open to its main thread; a subagent's only to that subagent. */
|
|
28
|
+
export function mayUseWorktree(record, sessionId, agentId) {
|
|
29
|
+
return record.sessionId === sessionId && (agentId === undefined || record.agentId === agentId);
|
|
30
|
+
}
|
|
27
31
|
function worktreeError(code, message) {
|
|
28
32
|
return new AgentOpsError(code, message);
|
|
29
33
|
}
|
|
@@ -194,6 +198,7 @@ export async function readWorktreeRecord(root) {
|
|
|
194
198
|
try {
|
|
195
199
|
const value = JSON.parse(source);
|
|
196
200
|
return value.schemaVersion === 1 && typeof value.name === "string" &&
|
|
201
|
+
(value.agentId === undefined || typeof value.agentId === "string") &&
|
|
197
202
|
typeof value.branch === "string" && typeof value.path === "string" &&
|
|
198
203
|
typeof value.mainRoot === "string" && typeof value.targetBranch === "string" &&
|
|
199
204
|
typeof value.base === "string" && typeof value.sessionId === "string" &&
|
|
@@ -219,7 +224,12 @@ export async function bindSession(deps, record, mainConfig, baselineFingerprint)
|
|
|
219
224
|
return;
|
|
220
225
|
const worktreeConfig = await deps.loadConfig(record.path);
|
|
221
226
|
await (await deps.gate(record.path, worktreeConfig)).seed(record.sessionId, baselineFingerprint);
|
|
222
|
-
|
|
227
|
+
const mainGate = await deps.gate(record.mainRoot, mainConfig);
|
|
228
|
+
// A subagent's worktree joins the session's roots instead of replacing its own.
|
|
229
|
+
if (record.agentId === undefined)
|
|
230
|
+
await mainGate.redirect(record.sessionId, record.path);
|
|
231
|
+
else
|
|
232
|
+
await mainGate.addRoot(record.sessionId, record.path);
|
|
223
233
|
}
|
|
224
234
|
export async function removeCheckout(deps, record, force) {
|
|
225
235
|
await git(deps, record.mainRoot, ["worktree", "remove", ...(force ? ["--force"] : []), record.path], "WORKTREE_REMOVE_FAILED", `Git could not remove ${record.path}.`);
|
|
@@ -306,6 +316,7 @@ export async function addWorktree(deps, options) {
|
|
|
306
316
|
targetBranch,
|
|
307
317
|
base,
|
|
308
318
|
sessionId,
|
|
319
|
+
...(options.agentId === undefined ? {} : { agentId: options.agentId }),
|
|
309
320
|
createdAt: deps.now?.() ?? new Date().toISOString()
|
|
310
321
|
};
|
|
311
322
|
let trusted = false;
|
|
@@ -336,7 +347,9 @@ export async function addWorktree(deps, options) {
|
|
|
336
347
|
}
|
|
337
348
|
catch (error) {
|
|
338
349
|
if (mainConfig.features.completionGate.enabled) {
|
|
339
|
-
|
|
350
|
+
const mainGate = await deps.gate(mainRoot, mainConfig);
|
|
351
|
+
await (options.agentId === undefined ? mainGate.redirect(sessionId, null) : mainGate.removeRoot(sessionId, path))
|
|
352
|
+
.catch(() => undefined);
|
|
340
353
|
}
|
|
341
354
|
if (trusted)
|
|
342
355
|
await deps.trust.revoke(record.path, mainConfig).catch(() => undefined);
|
|
@@ -349,18 +362,31 @@ export function sessionWorktreeName(sessionId) {
|
|
|
349
362
|
const slug = sessionId.toLowerCase().replace(/[^a-z0-9]/gu, "").slice(0, 8);
|
|
350
363
|
return `session-${slug === "" ? "0" : slug}`;
|
|
351
364
|
}
|
|
365
|
+
/** The session's worktree name plus the first eight name-safe characters of the subagent id. */
|
|
366
|
+
export function agentWorktreeName(sessionId, agentId) {
|
|
367
|
+
const slug = agentId.toLowerCase().replace(/[^a-z0-9]/gu, "").slice(0, 8);
|
|
368
|
+
return `${sessionWorktreeName(sessionId)}-${slug === "" ? "0" : slug}`;
|
|
369
|
+
}
|
|
352
370
|
/**
|
|
353
|
-
* The worktree `sessionId` works in, created on
|
|
354
|
-
* to the same session is reused, so a
|
|
355
|
-
* still gets exactly one worktree.
|
|
371
|
+
* The worktree `sessionId` (or one of its subagents) works in, created on
|
|
372
|
+
* first use. One already bound to the same session and agent is reused, so a
|
|
373
|
+
* writer blocked twice before it moves still gets exactly one worktree.
|
|
356
374
|
*/
|
|
357
375
|
export async function ensureSessionWorktree(deps, options) {
|
|
358
376
|
const { mainRoot } = await resolveCheckouts(deps, options.cwd);
|
|
359
|
-
const
|
|
377
|
+
const { agentId } = options;
|
|
378
|
+
const name = agentId === undefined
|
|
379
|
+
? sessionWorktreeName(options.sessionId)
|
|
380
|
+
: agentWorktreeName(options.sessionId, agentId);
|
|
360
381
|
const existing = await readWorktreeRecord(worktreePath(mainRoot, name));
|
|
361
|
-
if (existing?.sessionId === options.sessionId)
|
|
382
|
+
if (existing?.sessionId === options.sessionId && existing.agentId === agentId)
|
|
362
383
|
return existing;
|
|
363
|
-
return (await addWorktree(deps, {
|
|
384
|
+
return (await addWorktree(deps, {
|
|
385
|
+
cwd: mainRoot,
|
|
386
|
+
name,
|
|
387
|
+
sessionId: options.sessionId,
|
|
388
|
+
...(agentId === undefined ? {} : { agentId })
|
|
389
|
+
})).record;
|
|
364
390
|
}
|
|
365
391
|
function sessionOriginPath(mainRoot, sessionId) {
|
|
366
392
|
return join(mainRoot, ".agent-ops", "tasks", "session-origin", sha256(sessionId));
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
4
|
+
import { ReviewInterruptedError } from "./execute.js";
|
|
5
|
+
/** Reviews allowed at once across every worktree of a repository. */
|
|
6
|
+
export const REVIEW_SLOT_WIDTH = 2;
|
|
7
|
+
const POLL_MS = 1000;
|
|
8
|
+
// A review is two reviewer sessions of at most 15 minutes each plus probes.
|
|
9
|
+
const STALE_MS = 2 * 60 * 60 * 1000;
|
|
10
|
+
function alive(pid) {
|
|
11
|
+
try {
|
|
12
|
+
process.kill(pid, 0);
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
return error.code === "EPERM";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// ponytail: same scheme as withFinishLock in parallel/finish.ts; unify if a third user appears.
|
|
20
|
+
async function stale(path, staleMs) {
|
|
21
|
+
try {
|
|
22
|
+
const owner = JSON.parse(await readFile(join(path, "owner.json"), "utf8"));
|
|
23
|
+
if (typeof owner.pid !== "number" || typeof owner.at !== "number")
|
|
24
|
+
return true;
|
|
25
|
+
return !alive(owner.pid) || Date.now() - owner.at > staleMs;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// A holder between mkdir and writing its owner file looks like this too.
|
|
29
|
+
try {
|
|
30
|
+
return Date.now() - (await stat(path)).mtimeMs > 60_000;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Runs `action` once one of `width` shared slots is free. Reviewer CLIs are
|
|
39
|
+
* metered and flaky under load, and writers in separate worktrees each start
|
|
40
|
+
* their own review, so this is what keeps the total bounded. A slot held by a
|
|
41
|
+
* dead process is reclaimed.
|
|
42
|
+
*/
|
|
43
|
+
export async function withReviewSlot(options, action) {
|
|
44
|
+
const width = options.width ?? REVIEW_SLOT_WIDTH;
|
|
45
|
+
if (!Number.isInteger(width) || width < 1) {
|
|
46
|
+
throw new AgentOpsError("REVIEW_SLOT_INVALID_WIDTH", "Review slot width must be a positive integer.");
|
|
47
|
+
}
|
|
48
|
+
const sleep = options.sleep ?? (async (ms) => await new Promise((resolve) => setTimeout(resolve, ms)));
|
|
49
|
+
let waiting = false;
|
|
50
|
+
let held;
|
|
51
|
+
while (held === undefined) {
|
|
52
|
+
if (options.signal?.aborted === true)
|
|
53
|
+
throw new ReviewInterruptedError(String(options.signal.reason ?? ""));
|
|
54
|
+
for (let index = 0; index < width && held === undefined; index += 1) {
|
|
55
|
+
const path = join(options.dir, `agent-ops-review-slot-${index}.lock`);
|
|
56
|
+
try {
|
|
57
|
+
await mkdir(path);
|
|
58
|
+
await writeFile(join(path, "owner.json"), JSON.stringify({ pid: process.pid, at: Date.now() }));
|
|
59
|
+
held = path;
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (error.code !== "EEXIST")
|
|
63
|
+
throw error;
|
|
64
|
+
if (await stale(path, options.staleMs ?? STALE_MS)) {
|
|
65
|
+
await rm(path, { recursive: true, force: true });
|
|
66
|
+
index -= 1; // try the reclaimed slot again at once
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (held === undefined) {
|
|
71
|
+
if (!waiting) {
|
|
72
|
+
waiting = true;
|
|
73
|
+
options.onWait?.(`review: waiting for one of ${String(width)} shared review slots`);
|
|
74
|
+
}
|
|
75
|
+
await sleep(options.pollMs ?? POLL_MS);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
return await action();
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
await rm(held, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -39,3 +39,13 @@ Parallel editing conversations in one repository MUST each work in their own age
|
|
|
39
39
|
- Evidence: `agent-ops worktree list` shows one worktree per editing session, and each finish reports a fast-forward merge.
|
|
40
40
|
- Positive: `Two conversations edit .worktrees/login and .worktrees/cart; each verifies, reviews and finishes on its own.`
|
|
41
41
|
- Negative: `Two conversations edit the main checkout at once, so each one's verification and review is voided by the other's writes.`
|
|
42
|
+
|
|
43
|
+
## DELEGATE-ISOLATION-003
|
|
44
|
+
|
|
45
|
+
Subagents that write files in one session MUST each work in their own agent-ops worktree, with their own task, verification and review.
|
|
46
|
+
|
|
47
|
+
- Trigger: `worktree.mode` is `auto` and a subagent writes a file. Hook calls made inside a subagent carry its `agent_id` under the parent's `session_id`; a payload without `agent_id` is treated as the session's main thread.
|
|
48
|
+
- Action: The first blocked write allocates a worktree owned by that session and agent; edit there using absolute paths, because the hook's `cwd` does not follow a `cd`. The subagent commits and runs `agent-ops verify` and `agent-ops review` in its worktree. At most two reviews run at once across all worktrees of the repository; the rest wait. The coordinator runs `agent-ops worktree finish <name>` for each worktree in turn, and Stop is allowed only when every worktree of the session has a completed task.
|
|
49
|
+
- Evidence: `agent-ops worktree list` shows an `agent:` line for each subagent worktree, writing into another writer's worktree is refused with `WORKTREE_OWNED_BY_OTHER_AGENT`, and each finish reports a fast-forward merge.
|
|
50
|
+
- Positive: `Two subagents implement independent tasks in .worktrees/session-ab12cd34-a9230363 and .worktrees/session-ab12cd34-b1111111; each reviews itself and the coordinator finishes them one after the other.`
|
|
51
|
+
- Negative: `A subagent edits the coordinator's worktree, or two subagents edit one worktree, so one review covers two tasks' changes.`
|
|
@@ -30,3 +30,13 @@ hand-edited in place.
|
|
|
30
30
|
- Evidence: The artifact hash and shim import tests pass; release documentation records the revalidation condition.
|
|
31
31
|
- Positive: `agent-ops update` rewrites a changed plugin after ownership checks pass.
|
|
32
32
|
- Negative: `Patch the opencode plugin manually and retain the old manifest hash.`
|
|
33
|
+
|
|
34
|
+
## MAINTAIN-DRIFT-001
|
|
35
|
+
|
|
36
|
+
`agent-ops update` MUST rewrite a managed artifact or managed block whose content drifted from the manifest, MUST report each rewrite, and MUST NOT rewrite anything it cannot prove it owns.
|
|
37
|
+
|
|
38
|
+
- Trigger: `agent-ops update` finds a managed whole-file artifact whose hash differs from the manifest, or a managed block whose markers are intact but whose content changed, for example after a Git operation restored an older tracked revision.
|
|
39
|
+
- Action: Rewrite the artifact, or only the content between the markers, and list it as `repaired: <path> (<reason>)`. Keep `.agent-ops/config.json`, `agent-ops uninstall`, paths absent from the manifest, and blocks with missing, duplicated or reordered markers as errors; the last needs a person to locate the owned span.
|
|
40
|
+
- Evidence: `InstallPlan.repaired` and the update output name each rewritten path; text outside the markers is byte-identical afterwards.
|
|
41
|
+
- Positive: `A tracked .agent-ops/GEMINI.md restored to an older version is rewritten by update, with "repaired: .agent-ops/GEMINI.md (artifact drift)".`
|
|
42
|
+
- Negative: `Rewrite a block whose end marker was deleted, or overwrite .agent-ops/config.json.`
|
|
@@ -19,3 +19,13 @@ The operator MUST preserve the failing evidence before applying a fix.
|
|
|
19
19
|
- Evidence: The test fails before the fix and passes after it.
|
|
20
20
|
- Positive: `RED parser test → GREEN parser test.`
|
|
21
21
|
- Negative: `Delete the failing test because it is inconvenient.`
|
|
22
|
+
|
|
23
|
+
## TROUBLESHOOT-DRIFT-001
|
|
24
|
+
|
|
25
|
+
An update that reports `repaired:` lines or a doctor check that reports drift MUST be read as managed content that no longer matched the manifest, not as a failed update.
|
|
26
|
+
|
|
27
|
+
- Trigger: `agent-ops doctor` reports `UPDATE_REQUIRED` for managed artifacts or DEGRADED managed blocks, or `agent-ops update` prints `repaired: <path> (<reason>)`.
|
|
28
|
+
- Action: Run `agent-ops update`. It rewrites a drifted managed artifact or the content between intact block markers. It still fails on `.agent-ops/config.json`, `agent-ops uninstall`, paths absent from the manifest, and blocks with missing, duplicated or reordered markers; fix those by hand, then update again. See MAINTAIN-DRIFT-001.
|
|
29
|
+
- Evidence: The update output names each repaired path and a second `agent-ops doctor` reports no drift.
|
|
30
|
+
- Positive: `A restored older GEMINI.md is rewritten and listed as "repaired: .agent-ops/GEMINI.md (artifact drift)".`
|
|
31
|
+
- Negative: `Delete the manifest to silence MANAGED_BLOCK_CHANGED on a block whose end marker is missing.`
|
|
@@ -41,3 +41,13 @@ English source version: 2026-09-24. Revalidate: when the English specification c
|
|
|
41
41
|
- Evidence: `agent-ops worktree list` 顯示每個修改中的 session 各有一個 worktree,且每次 finish 都回報 fast-forward 合併。
|
|
42
42
|
- Positive: `兩個對話分別修改 .worktrees/login 與 .worktrees/cart;各自驗證、review 並 finish。`
|
|
43
43
|
- Negative: `兩個對話同時修改主 checkout,彼此的寫入讓對方的驗證與 review 全部作廢。`
|
|
44
|
+
|
|
45
|
+
## DELEGATE-ISOLATION-003
|
|
46
|
+
|
|
47
|
+
同一個 session 中會寫入檔案的 subagent MUST 各自在自己的 agent-ops worktree 中工作,並各自擁有 task、驗證與 review。
|
|
48
|
+
|
|
49
|
+
- Trigger: `worktree.mode` 為 `auto`,且 subagent 寫入檔案。subagent 內的 hook 呼叫會在父對話的 `session_id` 之下帶有自己的 `agent_id`;沒有 `agent_id` 的 payload 視為該 session 的主執行緒。
|
|
50
|
+
- Action: 第一次被擋下的寫入會配發一個屬於該 session 與該 agent 的 worktree;之後用絕對路徑在那裡編輯,因為 hook 的 `cwd` 不會跟著 `cd` 改變。subagent 在自己的 worktree 內 commit,並執行 `agent-ops verify` 與 `agent-ops review`。整個 repository 的所有 worktree 同時最多進行兩個 review,其餘排隊等待。協調者依序對每個 worktree 執行 `agent-ops worktree finish <name>`;只有該 session 的每個 worktree 都有已完成的 task,Stop 才會放行。
|
|
51
|
+
- Evidence: `agent-ops worktree list` 為每個 subagent worktree 顯示一行 `agent:`,寫入其他 writer 的 worktree 會以 `WORKTREE_OWNED_BY_OTHER_AGENT` 被拒絕,且每次 finish 都回報 fast-forward 合併。
|
|
52
|
+
- Positive: `兩個 subagent 在 .worktrees/session-ab12cd34-a9230363 與 .worktrees/session-ab12cd34-b1111111 各自實作獨立的 task;各自 review,協調者依序 finish。`
|
|
53
|
+
- Negative: `subagent 修改協調者的 worktree,或兩個 subagent 修改同一個 worktree,導致一次 review 涵蓋兩個 task 的變更。`
|
|
@@ -31,3 +31,13 @@ English source version: 2026-07-31. Revalidate: when the English specification c
|
|
|
31
31
|
- Evidence: artifact hash 與 shim import 測試通過;release 文件記錄重驗條件。
|
|
32
32
|
- Positive: `agent-ops update` 通過 ownership checks 後重寫已變更的 plugin。
|
|
33
33
|
- Negative: `手動修改 .opencode/plugins/agent-ops.js,卻保留舊的 manifest hash。`
|
|
34
|
+
|
|
35
|
+
## MAINTAIN-DRIFT-001
|
|
36
|
+
|
|
37
|
+
`agent-ops update` MUST 重寫內容已偏離 manifest 的 managed artifact 或 managed block,MUST 回報每一次重寫,且 MUST NOT 重寫無法證明歸其所有的內容。
|
|
38
|
+
|
|
39
|
+
- Trigger: `agent-ops update` 發現 managed 整檔 artifact 的 hash 與 manifest 不同,或 managed block 的標記完整但內容已變更,例如 Git 操作把已追蹤檔案還原成舊版。
|
|
40
|
+
- Action: 重寫該 artifact,或只重寫標記之間的內容,並以 `repaired: <path> (<reason>)` 列出。`.agent-ops/config.json`、`agent-ops uninstall`、不在 manifest 內的路徑,以及標記缺失、重複或順序顛倒的 block 仍視為錯誤;最後一種需要人來判斷哪一段歸 agent-ops。
|
|
41
|
+
- Evidence: `InstallPlan.repaired` 與 update 輸出列出每個被重寫的路徑;標記以外的文字前後逐位元組相同。
|
|
42
|
+
- Positive: `被還原成舊版的已追蹤 .agent-ops/GEMINI.md 由 update 重寫,並輸出 "repaired: .agent-ops/GEMINI.md (artifact drift)"。`
|
|
43
|
+
- Negative: `重寫結束標記已被刪除的 block,或覆寫 .agent-ops/config.json。`
|
|
@@ -21,3 +21,13 @@ English source version: 2026-07-23. Revalidate: when the English specification c
|
|
|
21
21
|
- Evidence: 測試在修正前失敗、修正後通過。
|
|
22
22
|
- Positive: `RED parser test → GREEN parser test。`
|
|
23
23
|
- Negative: `因為不方便就刪除失敗測試。`
|
|
24
|
+
|
|
25
|
+
## TROUBLESHOOT-DRIFT-001
|
|
26
|
+
|
|
27
|
+
update 輸出 `repaired:` 行,或 doctor 回報漂移時,MUST 視為 managed 內容與 manifest 不符,而不是 update 失敗。
|
|
28
|
+
|
|
29
|
+
- Trigger: `agent-ops doctor` 對 managed artifact 回報 `UPDATE_REQUIRED`,或對 managed block 回報 DEGRADED,或 `agent-ops update` 輸出 `repaired: <path> (<reason>)`。
|
|
30
|
+
- Action: 執行 `agent-ops update`。它會重寫已漂移的 managed artifact,或標記完整的 block 標記之間的內容。`.agent-ops/config.json`、`agent-ops uninstall`、不在 manifest 內的路徑,以及標記缺失、重複或順序顛倒的 block 仍會失敗;這些要手動修正後再 update。見 MAINTAIN-DRIFT-001。
|
|
31
|
+
- Evidence: update 輸出列出每個被修復的路徑,且再跑一次 `agent-ops doctor` 不再回報漂移。
|
|
32
|
+
- Positive: `被還原的舊版 GEMINI.md 被重寫,並列出 "repaired: .agent-ops/GEMINI.md (artifact drift)"。`
|
|
33
|
+
- Negative: `為了消除結束標記缺失的 MANAGED_BLOCK_CHANGED 而刪除 manifest。`
|