@kylecheng3146/agent-ops 0.3.7 → 0.4.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/README.md +35 -0
- package/dist/packages/cli/src/args.js +93 -9
- package/dist/packages/cli/src/bin.js +151 -104
- package/dist/packages/cli/src/cli.js +54 -5
- package/dist/packages/cli/src/commands/batch.js +106 -0
- package/dist/packages/cli/src/commands/hook.js +4 -1
- package/dist/packages/cli/src/commands/init.js +1 -0
- package/dist/packages/cli/src/commands/parallel.js +16 -3
- package/dist/packages/cli/src/commands/review.js +22 -0
- package/dist/packages/cli/src/commands/uninstall.js +3 -2
- package/dist/packages/cli/src/commands/update.js +1 -0
- package/dist/packages/cli/src/context.js +28 -3
- package/dist/packages/cli/src/hook-process.js +14 -3
- package/dist/packages/cli/src/public-plan.js +32 -2
- package/dist/runtime/src/adapters/claude/config.js +1 -1
- package/dist/runtime/src/adapters/claude/input.js +17 -0
- package/dist/runtime/src/adapters/claude/output.js +9 -0
- package/dist/runtime/src/hooks/codex-loop.js +67 -7
- package/dist/runtime/src/hooks/completion-gate.js +27 -2
- package/dist/runtime/src/hooks/dispatch.js +5 -0
- package/dist/runtime/src/install/apply.js +2 -0
- package/dist/runtime/src/install/doctor.js +8 -1
- package/dist/runtime/src/install/harness.js +17 -6
- package/dist/runtime/src/install/plan.js +9 -0
- package/dist/runtime/src/install/preauth.js +276 -0
- package/dist/runtime/src/install/probes.js +32 -0
- package/dist/runtime/src/install/uninstall.js +29 -11
- package/dist/runtime/src/install/update.js +1 -0
- package/dist/runtime/src/parallel/enter.js +43 -0
- package/dist/runtime/src/parallel/finish.js +2 -1
- package/dist/runtime/src/parallel/guard.js +20 -1
- package/dist/runtime/src/parallel/manage.js +38 -0
- package/dist/runtime/src/parallel/service.js +3 -1
- package/dist/runtime/src/review/batch-guard.js +11 -0
- package/dist/runtime/src/review/batch-select.js +37 -0
- package/dist/runtime/src/review/batch.js +202 -0
- package/dist/runtime/src/review/execute.js +10 -7
- package/dist/runtime/src/review/host-sandbox.js +9 -0
- package/dist/runtime/src/review/invocation.js +15 -2
- package/dist/runtime/src/review/probe.js +51 -15
- package/dist/runtime/src/review/render.js +4 -0
- package/docs/en/guides/configuration.md +51 -1
- package/docs/en/spec/review.md +70 -0
- package/docs/zh-TW/guides/configuration.md +41 -0
- package/docs/zh-TW/spec/review.md +71 -1
- package/package.json +1 -1
|
@@ -6,6 +6,7 @@ import { formatInstallManifest, parseInstallManifest } from "../fs/manifest.js";
|
|
|
6
6
|
import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
|
|
7
7
|
import { FileTransaction } from "../fs/transaction.js";
|
|
8
8
|
import { planHookRemoval } from "./hooks.js";
|
|
9
|
+
import { applyPreauth, planPreauth } from "./preauth.js";
|
|
9
10
|
import { assertExpectedManagedBlock, assertSupportedManifestOwnership } from "./ownership.js";
|
|
10
11
|
import { isOpencodeManagedPlugin } from "../adapters/opencode/config.js";
|
|
11
12
|
import { isHarnessId } from "./harness.js";
|
|
@@ -207,7 +208,7 @@ async function planArtifactRemoval(root, artifact) {
|
|
|
207
208
|
expectedHash: current.hash
|
|
208
209
|
};
|
|
209
210
|
}
|
|
210
|
-
async function createFullUninstallPlan(root) {
|
|
211
|
+
async function createFullUninstallPlan(root, options = {}) {
|
|
211
212
|
const currentManifest = await readCurrentFile(root, MANIFEST_PATH);
|
|
212
213
|
if (currentManifest === null) {
|
|
213
214
|
return {
|
|
@@ -262,11 +263,19 @@ async function createFullUninstallPlan(root) {
|
|
|
262
263
|
path: MANIFEST_PATH,
|
|
263
264
|
expectedHash: currentManifest.hash
|
|
264
265
|
});
|
|
266
|
+
const preauthorization = await planPreauth({
|
|
267
|
+
root,
|
|
268
|
+
scope: manifest.scope,
|
|
269
|
+
harness: manifest.harness,
|
|
270
|
+
desired: false,
|
|
271
|
+
...(options.codexHome === undefined ? {} : { codexHome: options.codexHome })
|
|
272
|
+
});
|
|
265
273
|
return {
|
|
266
274
|
installed: true,
|
|
267
275
|
manifest,
|
|
268
276
|
manifestHash: currentManifest.hash,
|
|
269
|
-
operations
|
|
277
|
+
operations,
|
|
278
|
+
...(preauthorization.length === 0 ? {} : { preauthorization })
|
|
270
279
|
};
|
|
271
280
|
}
|
|
272
281
|
async function planLoopMarkerUpdate(root, marker, expected, remaining) {
|
|
@@ -300,7 +309,7 @@ async function planLoopMarkerUpdate(root, marker, expected, remaining) {
|
|
|
300
309
|
}
|
|
301
310
|
};
|
|
302
311
|
}
|
|
303
|
-
async function planSelectiveUninstall(root, currentManifest, manifest, expectedMarkers, selectedHarnesses) {
|
|
312
|
+
async function planSelectiveUninstall(root, currentManifest, manifest, expectedMarkers, selectedHarnesses, planOptions = {}) {
|
|
304
313
|
if (selectedHarnesses.length === 0 ||
|
|
305
314
|
selectedHarnesses.some((id) => !isHarnessId(id)) ||
|
|
306
315
|
new Set(selectedHarnesses).size !== selectedHarnesses.length ||
|
|
@@ -310,7 +319,7 @@ async function planSelectiveUninstall(root, currentManifest, manifest, expectedM
|
|
|
310
319
|
const selected = selectedSet(selectedHarnesses);
|
|
311
320
|
const remaining = manifest.harness.filter((id) => !selected.has(id));
|
|
312
321
|
if (remaining.length === 0) {
|
|
313
|
-
return await createFullUninstallPlan(root);
|
|
322
|
+
return await createFullUninstallPlan(root, planOptions);
|
|
314
323
|
}
|
|
315
324
|
const operations = [];
|
|
316
325
|
const removedArtifacts = manifest.artifacts.filter((artifact) => shouldRemove(artifactOwners(manifest, artifact), selected));
|
|
@@ -398,26 +407,34 @@ async function planSelectiveUninstall(root, currentManifest, manifest, expectedM
|
|
|
398
407
|
content: formatInstallManifest(resultingManifest),
|
|
399
408
|
expectedHash: currentManifest.hash
|
|
400
409
|
});
|
|
410
|
+
const preauthorization = await planPreauth({
|
|
411
|
+
root,
|
|
412
|
+
scope: manifest.scope,
|
|
413
|
+
harness: selectedHarnesses,
|
|
414
|
+
desired: false,
|
|
415
|
+
...(planOptions.codexHome === undefined ? {} : { codexHome: planOptions.codexHome })
|
|
416
|
+
});
|
|
401
417
|
return {
|
|
402
418
|
installed: true,
|
|
403
419
|
manifest,
|
|
404
420
|
manifestHash: currentManifest.hash,
|
|
405
421
|
operations,
|
|
406
422
|
selectedHarnesses,
|
|
407
|
-
resultingManifest
|
|
423
|
+
resultingManifest,
|
|
424
|
+
...(preauthorization.length === 0 ? {} : { preauthorization })
|
|
408
425
|
};
|
|
409
426
|
}
|
|
410
|
-
export async function createUninstallPlan(root, selectedHarnesses) {
|
|
427
|
+
export async function createUninstallPlan(root, selectedHarnesses, options = {}) {
|
|
411
428
|
if (selectedHarnesses === undefined) {
|
|
412
|
-
return await createFullUninstallPlan(root);
|
|
429
|
+
return await createFullUninstallPlan(root, options);
|
|
413
430
|
}
|
|
414
431
|
const currentManifest = await readCurrentFile(root, MANIFEST_PATH);
|
|
415
432
|
if (currentManifest === null) {
|
|
416
|
-
return await createFullUninstallPlan(root);
|
|
433
|
+
return await createFullUninstallPlan(root, options);
|
|
417
434
|
}
|
|
418
435
|
const manifest = parseInstallManifest(currentManifest.content);
|
|
419
436
|
const expectedMarkers = assertSupportedManifestOwnership(manifest, root);
|
|
420
|
-
return await planSelectiveUninstall(root, currentManifest, manifest, expectedMarkers, selectedHarnesses);
|
|
437
|
+
return await planSelectiveUninstall(root, currentManifest, manifest, expectedMarkers, selectedHarnesses, options);
|
|
421
438
|
}
|
|
422
439
|
function allowedPaths(plan) {
|
|
423
440
|
if (plan.manifest === null) {
|
|
@@ -559,9 +576,9 @@ async function validateUninstalled(root, manifest, resultingManifest) {
|
|
|
559
576
|
throw new AgentOpsError("UNINSTALL_VALIDATION_FAILED", "Installation manifest still exists.");
|
|
560
577
|
}
|
|
561
578
|
}
|
|
562
|
-
export async function applyUninstallPlan(root, plan) {
|
|
579
|
+
export async function applyUninstallPlan(root, plan, options = {}) {
|
|
563
580
|
assertUninstallPlan(plan);
|
|
564
|
-
const currentPlan = await createUninstallPlan(root, plan.selectedHarnesses);
|
|
581
|
+
const currentPlan = await createUninstallPlan(root, plan.selectedHarnesses, options);
|
|
565
582
|
if (JSON.stringify(currentPlan) !== JSON.stringify(plan)) {
|
|
566
583
|
throw new AgentOpsError("INVALID_UNINSTALL_PLAN", "Uninstall plan no longer matches the current managed installation.");
|
|
567
584
|
}
|
|
@@ -571,4 +588,5 @@ export async function applyUninstallPlan(root, plan) {
|
|
|
571
588
|
}
|
|
572
589
|
const transaction = new FileTransaction(root);
|
|
573
590
|
await transaction.apply({ operations: plan.operations }, async () => await validateUninstalled(root, manifest, plan.resultingManifest));
|
|
591
|
+
await applyPreauth(plan.preauthorization ?? []);
|
|
574
592
|
}
|
|
@@ -124,6 +124,7 @@ export async function createUpdatePlan(options) {
|
|
|
124
124
|
value: configPreview.migrated,
|
|
125
125
|
sourceHash: configPreview.sourceHash
|
|
126
126
|
},
|
|
127
|
+
...(options.codexHome === undefined ? {} : { codexHome: options.codexHome }),
|
|
127
128
|
...(options.worktree === undefined
|
|
128
129
|
? {}
|
|
129
130
|
: { worktree: options.worktree })
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { join, relative, sep } from "node:path";
|
|
3
|
+
import { canonicalPath } from "./guard.js";
|
|
4
|
+
import { readWorktreeRecord, WORKTREE_DIRECTORY } from "./service.js";
|
|
5
|
+
/**
|
|
6
|
+
* Whether Claude's EnterWorktree may switch this session into `path`. Only the
|
|
7
|
+
* worktree agent-ops recorded for the session is answered for the host, so an
|
|
8
|
+
* allow never lets the session move its permission root to a directory it did
|
|
9
|
+
* not get from `worktree add`.
|
|
10
|
+
*/
|
|
11
|
+
export async function evaluateWorktreeEnter(mainRoot, path, sessionId) {
|
|
12
|
+
if (sessionId === undefined) {
|
|
13
|
+
return { action: "continue", status: "UNKNOWN", code: "WORKTREE_ENTER_UNJUDGED" };
|
|
14
|
+
}
|
|
15
|
+
const directory = join(mainRoot, WORKTREE_DIRECTORY);
|
|
16
|
+
const target = await canonicalPath(path);
|
|
17
|
+
const name = relative(directory, target).split(sep)[0] ?? "";
|
|
18
|
+
const inside = !target.startsWith(`${directory}${sep}`) ? false : target === join(directory, name);
|
|
19
|
+
if (inside && (await readWorktreeRecord(target))?.sessionId === sessionId) {
|
|
20
|
+
return {
|
|
21
|
+
action: "continue",
|
|
22
|
+
status: "PASS",
|
|
23
|
+
code: "WORKTREE_ENTER_ALLOWED",
|
|
24
|
+
remedy: `${target} is the worktree agent-ops recorded for this session.`,
|
|
25
|
+
decision: "allow"
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const owned = [];
|
|
29
|
+
for (const entry of await readdir(directory).catch(() => [])) {
|
|
30
|
+
if ((await readWorktreeRecord(join(directory, entry)))?.sessionId === sessionId) {
|
|
31
|
+
owned.push(join(directory, entry));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
action: "block",
|
|
36
|
+
status: "FAIL",
|
|
37
|
+
code: "WORKTREE_ENTER_DENIED",
|
|
38
|
+
remedy: `EnterWorktree may enter only a worktree this session owns. ` +
|
|
39
|
+
(owned.length > 0
|
|
40
|
+
? `Yours: ${owned.join(", ")}.`
|
|
41
|
+
: `None yet: run agent-ops worktree add <name> --session ${sessionId} from ${mainRoot}.`)
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -251,7 +251,8 @@ export async function finishWorktree(deps, options) {
|
|
|
251
251
|
await deps.tasks(record.path, base).complete(task.task.id, {});
|
|
252
252
|
}
|
|
253
253
|
catch (error) {
|
|
254
|
-
throw finishError("WORKTREE_TASK_INCOMPLETE", `Task ${task.task.id} (${task.task.title}) could not be completed against ${base}: ${error instanceof Error ? error.message : String(error)}`
|
|
254
|
+
throw finishError("WORKTREE_TASK_INCOMPLETE", `Task ${task.task.id} (${task.task.title}) could not be completed against ${base}: ${error instanceof Error ? error.message : String(error)} ` +
|
|
255
|
+
"A task another session left here is not this session's to archive: its owner finishes or archives it, and finish never does.");
|
|
255
256
|
}
|
|
256
257
|
}
|
|
257
258
|
const worktreeGate = await deps.gate(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 } from "./service.js";
|
|
3
|
+
import { insideWorktreeDirectory, 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"]);
|
|
@@ -47,6 +47,25 @@ ensureWorktree) {
|
|
|
47
47
|
for (const path of paths) {
|
|
48
48
|
const target = await canonicalPath(path);
|
|
49
49
|
const inMain = target === mainRoot || target.startsWith(`${mainRoot}${sep}`);
|
|
50
|
+
if (inMain && insideWorktreeDirectory(mainRoot, target) && sessionId !== undefined) {
|
|
51
|
+
// A worktree belongs to the session that made it: writing into another
|
|
52
|
+
// session's mixes two tasks' changes into one review and one merge.
|
|
53
|
+
const name = relative(join(mainRoot, WORKTREE_DIRECTORY), target).split(sep)[0] ?? "";
|
|
54
|
+
const owner = (await readWorktreeRecord(join(mainRoot, WORKTREE_DIRECTORY, name)))?.sessionId;
|
|
55
|
+
if (owner !== undefined && owner !== sessionId) {
|
|
56
|
+
let own = "";
|
|
57
|
+
if (ensureWorktree !== undefined) {
|
|
58
|
+
own = await ensureWorktree(sessionId).then((worktree) => ` Your own worktree is ${worktree}: edit there instead.`, () => "");
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
action: "block",
|
|
62
|
+
status: "FAIL",
|
|
63
|
+
code: "WORKTREE_OWNED_BY_OTHER_SESSION",
|
|
64
|
+
remedy: `${join(mainRoot, WORKTREE_DIRECTORY, name)} belongs to another session (${owner}); ` +
|
|
65
|
+
`this session is ${sessionId}.${own || " Run agent-ops worktree add <name> --session " + sessionId + " from " + mainRoot + " and edit in the printed path."}`
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
50
69
|
if (inMain && !insideWorktreeDirectory(mainRoot, target)) {
|
|
51
70
|
let failure = "";
|
|
52
71
|
if (sessionId !== undefined && ensureWorktree !== undefined) {
|
|
@@ -154,6 +154,44 @@ export async function resumeWorktree(deps, options) {
|
|
|
154
154
|
await bindSession(deps, record, mainConfig, RESUMED_BASELINE);
|
|
155
155
|
return record;
|
|
156
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* Commits everything in the calling session's own worktree. Git runs from
|
|
159
|
+
* here rather than the shell, where a host cannot prove a command stays inside
|
|
160
|
+
* the worktree; that is also why it refuses anything that is not the caller's.
|
|
161
|
+
*
|
|
162
|
+
* ponytail: stages all changes, so an untracked file Git does not ignore goes
|
|
163
|
+
* in too; narrow it with a path list if that ever commits something unwanted.
|
|
164
|
+
*/
|
|
165
|
+
export async function commitWorktree(deps, options) {
|
|
166
|
+
const { mainRoot, currentRoot } = await resolveCheckouts(deps, options.cwd);
|
|
167
|
+
if (currentRoot === mainRoot) {
|
|
168
|
+
throw manageError("WORKTREE_COMMIT_MAIN", `worktree commit works only inside an agent-ops worktree; this is the main checkout (${mainRoot}).`);
|
|
169
|
+
}
|
|
170
|
+
const record = await readWorktreeRecord(currentRoot);
|
|
171
|
+
if (record === null) {
|
|
172
|
+
throw manageError("WORKTREE_COMMIT_UNRECORDED", `${currentRoot} is not an agent-ops worktree; see agent-ops worktree list.`);
|
|
173
|
+
}
|
|
174
|
+
if (options.sessionId === undefined) {
|
|
175
|
+
throw manageError("WORKTREE_COMMIT_SESSION_REQUIRED", "Cannot tell which session is committing; pass --session <id> or set AGENT_OPS_SESSION_ID.");
|
|
176
|
+
}
|
|
177
|
+
if (record.sessionId !== options.sessionId) {
|
|
178
|
+
throw manageError("WORKTREE_COMMIT_FOREIGN", `${record.path} belongs to session ${record.sessionId}, not ${options.sessionId}.`);
|
|
179
|
+
}
|
|
180
|
+
const step = async (args, code, message) => {
|
|
181
|
+
const result = await deps.git(record.path, args);
|
|
182
|
+
if (result.exitCode !== 0) {
|
|
183
|
+
const detail = result.stderr.trim().split("\n")[0];
|
|
184
|
+
throw manageError(code, detail === undefined || detail === "" ? message : `${message} ${detail}`);
|
|
185
|
+
}
|
|
186
|
+
return result.stdout.trim();
|
|
187
|
+
};
|
|
188
|
+
await step(["add", "-A"], "WORKTREE_COMMIT_FAILED", "Git could not stage the worktree.");
|
|
189
|
+
if ((await deps.git(record.path, ["diff", "--cached", "--quiet"])).exitCode === 0) {
|
|
190
|
+
throw manageError("WORKTREE_COMMIT_NOTHING", `${record.path} has no changes to commit.`);
|
|
191
|
+
}
|
|
192
|
+
await step(["commit", "-m", options.message], "WORKTREE_COMMIT_FAILED", "Git refused the commit.");
|
|
193
|
+
return { record, commit: await step(["rev-parse", "HEAD"], "WORKTREE_COMMIT_FAILED", "Git could not read the new commit.") };
|
|
194
|
+
}
|
|
157
195
|
/**
|
|
158
196
|
* Deletes a worktree and its branch. Work that exists nowhere else — an
|
|
159
197
|
* uncommitted change or a commit the target lacks — needs `force`.
|
|
@@ -291,7 +291,9 @@ export async function addWorktree(deps, options) {
|
|
|
291
291
|
const mainTrust = await deps.trust.status(mainRoot, mainConfig);
|
|
292
292
|
const setupSteps = mainConfig.worktree?.setup ?? [];
|
|
293
293
|
if (setupSteps.length > 0 && mainTrust !== "TRUSTED") {
|
|
294
|
-
throw worktreeError("WORKTREE_SETUP_UNTRUSTED", "worktree.setup runs repository commands
|
|
294
|
+
throw worktreeError("WORKTREE_SETUP_UNTRUSTED", "worktree.setup runs repository commands and the main checkout is not trusted: trust is bound to the config hash, " +
|
|
295
|
+
"so a hand edit of .agent-ops/config.json (adding worktree.setup, say) leaves it untrusted. " +
|
|
296
|
+
"Trust is the user's to grant, once: run agent-ops update in the main checkout, which lists the commands it will trust and asks.");
|
|
295
297
|
}
|
|
296
298
|
await ensureExcluded(deps, mainRoot, commonDir);
|
|
297
299
|
await git(deps, mainRoot, ["worktree", "add", "-b", branch, path, base], "WORKTREE_ADD_FAILED", "Git could not create the worktree.");
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { collectChangeSurface, resolveGitCommit } from "../verify/change-surface.js";
|
|
2
|
+
/**
|
|
3
|
+
* Remembers HEAD now and answers whether the batch may go on: HEAD is still
|
|
4
|
+
* that commit and the working tree has no change. The first answer doubles as
|
|
5
|
+
* the clean-tree check, since a batch reviews committed ranges only.
|
|
6
|
+
*/
|
|
7
|
+
export async function createSourceGuard(runner) {
|
|
8
|
+
const head = await resolveGitCommit(runner, "HEAD");
|
|
9
|
+
return async () => await resolveGitCommit(runner, "HEAD") === head &&
|
|
10
|
+
(await collectChangeSurface(runner)).paths.length === 0;
|
|
11
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
2
|
+
/**
|
|
3
|
+
* The tasks one `batch --parent` covers: the parent's active subtasks in
|
|
4
|
+
* creation order, then the parent itself. Archived and completed tasks, and
|
|
5
|
+
* tasks under another parent, are left out.
|
|
6
|
+
*/
|
|
7
|
+
export function selectBatchTasks(records, parentId) {
|
|
8
|
+
const parent = records.find((record) => record.task.id === parentId);
|
|
9
|
+
if (parent === undefined) {
|
|
10
|
+
throw new AgentOpsError("BATCH_PARENT_NOT_FOUND", `Parent task not found: ${parentId}`);
|
|
11
|
+
}
|
|
12
|
+
if (parent.status !== "active") {
|
|
13
|
+
throw new AgentOpsError("BATCH_PARENT_NOT_ACTIVE", `Parent task is not active: ${parentId}`);
|
|
14
|
+
}
|
|
15
|
+
const subtasks = records
|
|
16
|
+
.filter((record) => record.task.parentTaskId === parentId && record.status === "active")
|
|
17
|
+
.sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.task.id.localeCompare(right.task.id));
|
|
18
|
+
return [...subtasks, parent];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* An explicit flag wins, then the commit the task's last PASS review measured
|
|
22
|
+
* against (so a rerun reproduces the same fingerprint), then the worktree base.
|
|
23
|
+
* A parent's base is a human choice that cannot be derived from the repository.
|
|
24
|
+
*/
|
|
25
|
+
export function resolveBatchBase(record, isParent, input) {
|
|
26
|
+
const flag = isParent ? input.parentBase : input.base;
|
|
27
|
+
if (flag !== undefined) {
|
|
28
|
+
return { base: flag, source: "flag" };
|
|
29
|
+
}
|
|
30
|
+
if (record.reviewBase !== undefined) {
|
|
31
|
+
return { base: record.reviewBase, source: "recorded" };
|
|
32
|
+
}
|
|
33
|
+
if (input.worktreeBase !== undefined) {
|
|
34
|
+
return { base: input.worktreeBase, source: "worktree" };
|
|
35
|
+
}
|
|
36
|
+
throw new AgentOpsError("BATCH_BASE_UNKNOWN", `No base for task ${record.task.id}: pass ${isParent ? "--parent-base" : "--base"} or run from an agent-ops worktree.`);
|
|
37
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
2
|
+
/**
|
|
3
|
+
* NOT_RUN reasons that mean the reviewer was briefly unavailable. Anything
|
|
4
|
+
* else (a sandboxed host, stale verification, a changed policy) fails the same
|
|
5
|
+
* way on a second attempt, so it is reported instead of retried.
|
|
6
|
+
*/
|
|
7
|
+
export const TRANSIENT_REVIEW_REASONS = new Set([
|
|
8
|
+
"probe-failed",
|
|
9
|
+
"stalled",
|
|
10
|
+
"timeout",
|
|
11
|
+
"quota-exhausted",
|
|
12
|
+
"network-unreachable"
|
|
13
|
+
]);
|
|
14
|
+
/** Reviews allowed at once when the caller names no width. */
|
|
15
|
+
export const DEFAULT_BATCH_WIDTH = 2;
|
|
16
|
+
/**
|
|
17
|
+
* Verify runs one task at a time (a full test run per task; several at once
|
|
18
|
+
* starve each other of CPU and fake a failure). Each task's review starts as
|
|
19
|
+
* soon as its own verify has passed, so the next verify overlaps it. Reviews
|
|
20
|
+
* run up to `width` at a time; a transient NOT_RUN drops the width to 1 for
|
|
21
|
+
* good and that task gets one more try alone after everything else finished.
|
|
22
|
+
*/
|
|
23
|
+
export async function runBatch(ids, deps, options) {
|
|
24
|
+
if (!Number.isInteger(options.width) || options.width < 1) {
|
|
25
|
+
throw new AgentOpsError("BATCH_INVALID_WIDTH", "Batch width must be a positive integer.");
|
|
26
|
+
}
|
|
27
|
+
const progress = options.onProgress ?? (() => { });
|
|
28
|
+
const controller = new AbortController();
|
|
29
|
+
const outer = options.signal;
|
|
30
|
+
if (outer?.aborted === true) {
|
|
31
|
+
controller.abort();
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
outer?.addEventListener("abort", () => controller.abort(), { once: true });
|
|
35
|
+
}
|
|
36
|
+
const slots = new Map(ids.map((id) => [
|
|
37
|
+
id,
|
|
38
|
+
{ id, verify: "not-run", review: null, retried: false }
|
|
39
|
+
]));
|
|
40
|
+
const slot = (id) => slots.get(id);
|
|
41
|
+
const stop = (why) => {
|
|
42
|
+
if (!controller.signal.aborted) {
|
|
43
|
+
progress(why);
|
|
44
|
+
controller.abort();
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
const guard = async () => {
|
|
48
|
+
try {
|
|
49
|
+
return await deps.guard();
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
let width = options.width;
|
|
56
|
+
let running = 0;
|
|
57
|
+
let verifying = true;
|
|
58
|
+
const ready = [];
|
|
59
|
+
const retry = [];
|
|
60
|
+
let finish;
|
|
61
|
+
const done = new Promise((resolve) => {
|
|
62
|
+
finish = resolve;
|
|
63
|
+
});
|
|
64
|
+
const review = async (id, final) => {
|
|
65
|
+
if (!await guard()) {
|
|
66
|
+
stop("source changed; aborting the batch");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const entry = slot(id);
|
|
70
|
+
if (final) {
|
|
71
|
+
entry.retried = true;
|
|
72
|
+
}
|
|
73
|
+
let result;
|
|
74
|
+
try {
|
|
75
|
+
result = await deps.review(id, controller.signal);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
progress(`review ${id} failed to run: ${error instanceof Error ? error.message : String(error)}`);
|
|
79
|
+
result = { status: "NOT_RUN", reason: "review-error" };
|
|
80
|
+
}
|
|
81
|
+
if (controller.signal.aborted) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
entry.review = result;
|
|
85
|
+
if (!final &&
|
|
86
|
+
result.status === "NOT_RUN" &&
|
|
87
|
+
result.reason !== undefined &&
|
|
88
|
+
TRANSIENT_REVIEW_REASONS.has(result.reason)) {
|
|
89
|
+
if (width > 1) {
|
|
90
|
+
width = 1;
|
|
91
|
+
progress(`degrade: review width -> 1 (${id}: ${result.reason})`);
|
|
92
|
+
}
|
|
93
|
+
retry.push(id);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
const pump = () => {
|
|
97
|
+
while (!controller.signal.aborted && running < width && ready.length > 0) {
|
|
98
|
+
const id = ready.shift();
|
|
99
|
+
running += 1;
|
|
100
|
+
void review(id, false).finally(() => {
|
|
101
|
+
running -= 1;
|
|
102
|
+
pump();
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
if (!verifying && running === 0 && (ready.length === 0 || controller.signal.aborted)) {
|
|
106
|
+
finish();
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
const fresh = await Promise.all(ids.map((id) => deps.isVerified(id).catch(() => false)));
|
|
110
|
+
const stale = [];
|
|
111
|
+
ids.forEach((id, index) => {
|
|
112
|
+
if (fresh[index] === true) {
|
|
113
|
+
slot(id).verify = "skipped";
|
|
114
|
+
ready.push(id);
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
stale.push(id);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
const verifyAll = async () => {
|
|
121
|
+
for (const id of stale) {
|
|
122
|
+
if (controller.signal.aborted) {
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
if (!await guard()) {
|
|
126
|
+
stop("source changed; aborting the batch");
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
let status;
|
|
130
|
+
try {
|
|
131
|
+
status = await deps.verify(id, controller.signal);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
progress(`verify ${id} failed to run: ${error instanceof Error ? error.message : String(error)}`);
|
|
135
|
+
status = "FAIL";
|
|
136
|
+
}
|
|
137
|
+
if (controller.signal.aborted) {
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
slot(id).verify = status;
|
|
141
|
+
if (status === "PASS") {
|
|
142
|
+
ready.push(id);
|
|
143
|
+
pump();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
verifying = false;
|
|
147
|
+
pump();
|
|
148
|
+
};
|
|
149
|
+
void verifyAll();
|
|
150
|
+
pump();
|
|
151
|
+
await done;
|
|
152
|
+
if (!controller.signal.aborted) {
|
|
153
|
+
for (const id of retry) {
|
|
154
|
+
if (controller.signal.aborted) {
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
await review(id, true);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const interrupted = controller.signal.aborted;
|
|
161
|
+
return ids.map((id) => {
|
|
162
|
+
const entry = slot(id);
|
|
163
|
+
const pendingRetry = retry.includes(id) && !entry.retried;
|
|
164
|
+
const unfinished = entry.review === null
|
|
165
|
+
? entry.verify !== "FAIL"
|
|
166
|
+
: pendingRetry;
|
|
167
|
+
return {
|
|
168
|
+
id,
|
|
169
|
+
verify: entry.verify,
|
|
170
|
+
review: entry.review,
|
|
171
|
+
retried: entry.retried,
|
|
172
|
+
aborted: interrupted && unfinished
|
|
173
|
+
};
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* One probe per target for a whole batch. Concurrent callers share the probe
|
|
178
|
+
* in flight and a success is kept, but a failure is forgotten so the retry of
|
|
179
|
+
* a task re-probes instead of inheriting the failure.
|
|
180
|
+
*/
|
|
181
|
+
export function memoizePreflight(probe) {
|
|
182
|
+
const cache = new Map();
|
|
183
|
+
return (target, budget) => {
|
|
184
|
+
const cached = cache.get(target);
|
|
185
|
+
if (cached !== undefined) {
|
|
186
|
+
return cached;
|
|
187
|
+
}
|
|
188
|
+
const pending = probe(target, budget);
|
|
189
|
+
cache.set(target, pending);
|
|
190
|
+
const forget = () => {
|
|
191
|
+
if (cache.get(target) === pending) {
|
|
192
|
+
cache.delete(target);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
pending.then((answer) => {
|
|
196
|
+
if ((typeof answer === "string" ? answer : answer.result) !== "ok") {
|
|
197
|
+
forget();
|
|
198
|
+
}
|
|
199
|
+
}, forget);
|
|
200
|
+
return pending;
|
|
201
|
+
};
|
|
202
|
+
}
|
|
@@ -8,7 +8,7 @@ import { answeredNothing, conversationIdOf, deniedToolsOf, extractFinalMessage,
|
|
|
8
8
|
import { buildTargetInvocation } from "./invocation.js";
|
|
9
9
|
import { reviewReportResults, reviewReportStatus, validateReviewReport } from "./report.js";
|
|
10
10
|
import { detectHostTarget, planReviewTargets } from "./roles.js";
|
|
11
|
-
import { detectHostRestriction, isNetworkFailure } from "./host-sandbox.js";
|
|
11
|
+
import { detectHostRestriction, isLoginFailure, isNetworkFailure } from "./host-sandbox.js";
|
|
12
12
|
import { buildAdversarialPrompt, buildReviewPrompt } from "./runner.js";
|
|
13
13
|
import { MAX_ADVERSARIAL_PROMPT_BYTES } from "./runner.js";
|
|
14
14
|
import { extractUsage, sumUsage } from "./usage.js";
|
|
@@ -149,6 +149,8 @@ function preflightUnavailableReason(result) {
|
|
|
149
149
|
return "capability-unavailable";
|
|
150
150
|
case "capability-unavailable":
|
|
151
151
|
return "capability-unavailable";
|
|
152
|
+
case "probe-failed":
|
|
153
|
+
return "probe-failed";
|
|
152
154
|
}
|
|
153
155
|
}
|
|
154
156
|
/**
|
|
@@ -157,7 +159,7 @@ function preflightUnavailableReason(result) {
|
|
|
157
159
|
* suppressed under `--json`: a machine consumer would otherwise be left with
|
|
158
160
|
* the bare authentication guess this exists to qualify. Never evidence.
|
|
159
161
|
*/
|
|
160
|
-
function firstComplaint(...streams) {
|
|
162
|
+
export function firstComplaint(...streams) {
|
|
161
163
|
for (const stream of streams) {
|
|
162
164
|
const line = redactSecrets(stream)
|
|
163
165
|
.split(/\r?\n/u)
|
|
@@ -176,9 +178,7 @@ function rejectedCallReason(output) {
|
|
|
176
178
|
if (isNetworkFailure(output)) {
|
|
177
179
|
return "network-unreachable";
|
|
178
180
|
}
|
|
179
|
-
if (
|
|
180
|
-
/\b(?:invalid|expired)\s+(?:api key|token|credential)/iu.test(output) ||
|
|
181
|
-
/\b401\b/u.test(output)) {
|
|
181
|
+
if (isLoginFailure(output)) {
|
|
182
182
|
return "login-required";
|
|
183
183
|
}
|
|
184
184
|
return "capability-unavailable";
|
|
@@ -740,15 +740,18 @@ export function createReviewExecutor(options) {
|
|
|
740
740
|
attempts: []
|
|
741
741
|
};
|
|
742
742
|
}
|
|
743
|
-
const
|
|
743
|
+
const answer = await options.preflightTarget(target, {
|
|
744
744
|
timeoutMs: Math.max(1, remainingMs())
|
|
745
745
|
});
|
|
746
|
+
const result = typeof answer === "string" ? answer : answer.result;
|
|
746
747
|
if (result === "ok") {
|
|
747
748
|
preflight.push({ target, status: "PASS" });
|
|
748
749
|
continue;
|
|
749
750
|
}
|
|
750
751
|
const reason = preflightUnavailableReason(result);
|
|
751
|
-
const
|
|
752
|
+
const complaint = typeof answer === "string" ? undefined : answer.diagnostic;
|
|
753
|
+
const diagnostic = `target preflight returned ${result}` +
|
|
754
|
+
(complaint === undefined ? "" : `: ${complaint}`);
|
|
752
755
|
preflight.push({ target, status: "NOT_RUN", reason, diagnostic });
|
|
753
756
|
report(`${target}: preflight ${reason} (${diagnostic})`);
|
|
754
757
|
return {
|
|
@@ -11,6 +11,15 @@ const NETWORK_FAILURE = /\b(?:ENOTFOUND|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EAI_AG
|
|
|
11
11
|
export function isNetworkFailure(output) {
|
|
12
12
|
return NETWORK_FAILURE.test(output);
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Only output that says so is a login problem. A CLI that dies for any other
|
|
16
|
+
* reason (a sandbox denying its state directory, a crash) used to be reported
|
|
17
|
+
* as "not logged in" too, which sent users to re-login a working install.
|
|
18
|
+
*/
|
|
19
|
+
const LOGIN_FAILURE = /\b(?:not logged in|login required|log in to|authentication required|unauthenticated|unauthorized)\b|\b(?:invalid|expired)\s+(?:api key|token|credential)|\b401\b/iu;
|
|
20
|
+
export function isLoginFailure(output) {
|
|
21
|
+
return LOGIN_FAILURE.test(output);
|
|
22
|
+
}
|
|
14
23
|
/** The host each reviewer talks to, so a reachability check tests its real path. */
|
|
15
24
|
export const TARGET_API_HOST = {
|
|
16
25
|
agy: "cloudcode-pa.googleapis.com",
|
|
@@ -161,11 +161,24 @@ export function buildTargetInvocation(request) {
|
|
|
161
161
|
};
|
|
162
162
|
}
|
|
163
163
|
/** A deep doctor probe uses stdin but keeps its simple text response contract. */
|
|
164
|
+
/**
|
|
165
|
+
* What the probe runs with where it differs from a review. The probe asks for
|
|
166
|
+
* one word, not a review, and agy's plan mode tells the model to research the
|
|
167
|
+
* task first: on a real project it explores, runs a command the headless user
|
|
168
|
+
* never allowed, is soft-denied, and prints nothing, which reads as
|
|
169
|
+
* `probe-failed`. In 40 replays, plan mode failed 4 of 20 and without it all 20
|
|
170
|
+
* answered in exactly two model calls. Only the probe drops it; a review keeps
|
|
171
|
+
* `READ_ONLY_ARGS`, and the sandbox stays on for both.
|
|
172
|
+
*/
|
|
173
|
+
const PROBE_ARGS = {
|
|
174
|
+
agy: ["--sandbox"]
|
|
175
|
+
};
|
|
164
176
|
export function buildProbeInvocation(request) {
|
|
165
|
-
const
|
|
166
|
-
if (
|
|
177
|
+
const eligible = READ_ONLY_ARGS[request.target];
|
|
178
|
+
if (eligible === undefined || eligible.length === 0) {
|
|
167
179
|
return undefined;
|
|
168
180
|
}
|
|
181
|
+
const readOnly = PROBE_ARGS[request.target] ?? eligible;
|
|
169
182
|
if (request.target === "codex") {
|
|
170
183
|
return {
|
|
171
184
|
command: "codex",
|