@kylecheng3146/agent-ops 0.2.8 → 0.3.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/args.js +48 -2
- package/dist/packages/cli/src/bin.js +55 -35
- package/dist/packages/cli/src/cli.js +31 -0
- package/dist/packages/cli/src/commands/hook.js +4 -1
- package/dist/packages/cli/src/commands/parallel.js +88 -0
- package/dist/packages/cli/src/commands/review.js +6 -4
- package/dist/packages/cli/src/commands/task.js +27 -3
- package/dist/packages/cli/src/hook-entry.js +2 -1
- package/dist/packages/cli/src/hook-process.js +34 -7
- package/dist/packages/cli/src/parallel-deps.js +89 -0
- package/dist/runtime/src/adapters/agy/output.js +2 -1
- package/dist/runtime/src/adapters/claude/config.js +6 -2
- package/dist/runtime/src/adapters/claude/input.js +24 -0
- package/dist/runtime/src/adapters/claude/output.js +2 -1
- package/dist/runtime/src/config/merge.js +10 -2
- package/dist/runtime/src/hooks/codex-loop.js +11 -1
- package/dist/runtime/src/hooks/completion-gate.js +141 -7
- package/dist/runtime/src/hooks/dispatch.js +5 -0
- package/dist/runtime/src/install/doctor.js +4 -1
- package/dist/runtime/src/install/harness.js +1 -1
- package/dist/runtime/src/install/plan.js +4 -1
- package/dist/runtime/src/parallel/finish.js +303 -0
- package/dist/runtime/src/parallel/guard.js +76 -0
- package/dist/runtime/src/parallel/manage.js +147 -0
- package/dist/runtime/src/parallel/service.js +281 -0
- package/dist/runtime/src/review/attestation.js +125 -50
- package/dist/runtime/src/review/execute.js +32 -5
- package/dist/runtime/src/schema/validate.js +50 -1
- package/dist/runtime/src/task/completion.js +1 -1
- package/dist/runtime/src/task/service.js +20 -4
- package/dist/runtime/src/task/store.js +7 -2
- package/docs/en/guides/configuration.md +63 -0
- package/docs/en/spec/delegation.md +10 -0
- package/docs/zh-TW/guides/configuration.md +55 -0
- package/docs/zh-TW/spec/delegation.md +11 -1
- package/package.json +1 -1
- package/schemas/config.schema.json +35 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { chmod, copyFile, lstat, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join, sep } from "node:path";
|
|
3
|
+
import { calculateConfigHash } from "../config/hash.js";
|
|
4
|
+
import { parseInstallManifest } from "../fs/manifest.js";
|
|
5
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
6
|
+
import { writeSessionMarker } from "../hooks/codex-loop.js";
|
|
7
|
+
import { readPrivateFile, writePrivateFile } from "../security/permissions.js";
|
|
8
|
+
/**
|
|
9
|
+
* One Git worktree per writing session, so parallel sessions stop sharing the
|
|
10
|
+
* change surface every fingerprint is taken over. Worktrees live inside the
|
|
11
|
+
* main checkout, kept out of its surface by `.git/info/exclude` — never by
|
|
12
|
+
* `.gitignore`, whose edit would itself be a change the gate has to explain.
|
|
13
|
+
*/
|
|
14
|
+
export const WORKTREE_DIRECTORY = ".worktrees";
|
|
15
|
+
export const WORKTREE_BRANCH_PREFIX = "agent-ops/";
|
|
16
|
+
export const WORKTREE_RECORD_PATH = ".agent-ops/tasks/worktree.json";
|
|
17
|
+
const EXCLUDE_LINE = `/${WORKTREE_DIRECTORY}/`;
|
|
18
|
+
const NAME = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/u;
|
|
19
|
+
const SESSION = /^[^\0\r\n]{1,256}$/u;
|
|
20
|
+
const DEFAULT_SETUP_TIMEOUT_MS = 10 * 60 * 1000;
|
|
21
|
+
function worktreeError(code, message) {
|
|
22
|
+
return new AgentOpsError(code, message);
|
|
23
|
+
}
|
|
24
|
+
async function git(deps, cwd, args, code, message) {
|
|
25
|
+
const result = await deps.git(cwd, args);
|
|
26
|
+
if (result.exitCode !== 0) {
|
|
27
|
+
const detail = result.stderr.trim().split("\n")[0];
|
|
28
|
+
throw worktreeError(code, detail === undefined || detail === "" ? message : `${message} ${detail}`);
|
|
29
|
+
}
|
|
30
|
+
return result.stdout.trim();
|
|
31
|
+
}
|
|
32
|
+
export function assertWorktreeName(name) {
|
|
33
|
+
if (name === undefined || !NAME.test(name) || name.includes("..")) {
|
|
34
|
+
throw worktreeError("WORKTREE_NAME_INVALID", "A worktree name is 1-64 lowercase letters, digits, '.', '_' or '-', starting and ending with a letter or digit.");
|
|
35
|
+
}
|
|
36
|
+
return name;
|
|
37
|
+
}
|
|
38
|
+
export function assertSessionId(sessionId) {
|
|
39
|
+
if (sessionId === undefined || !SESSION.test(sessionId)) {
|
|
40
|
+
throw worktreeError("WORKTREE_SESSION_REQUIRED", "Pass --session <id>: the conversation that will work in this worktree.");
|
|
41
|
+
}
|
|
42
|
+
return sessionId;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The main checkout and the checkout `cwd` is in. They differ inside a linked
|
|
46
|
+
* worktree. A repository whose common directory is not `<root>/.git` (bare,
|
|
47
|
+
* or a separate git dir) has no main checkout to put worktrees in.
|
|
48
|
+
*/
|
|
49
|
+
export async function resolveCheckouts(deps, cwd) {
|
|
50
|
+
const commonDir = await realpath(await git(deps, cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"], "WORKTREE_NOT_GIT", "Worktrees require a Git repository."));
|
|
51
|
+
const currentRoot = await realpath(await git(deps, cwd, ["rev-parse", "--show-toplevel"], "WORKTREE_NOT_GIT", "Worktrees require a Git checkout."));
|
|
52
|
+
if (basename(commonDir) !== ".git") {
|
|
53
|
+
throw worktreeError("WORKTREE_LAYOUT_UNSUPPORTED", "Worktrees need a main checkout whose Git directory is <root>/.git.");
|
|
54
|
+
}
|
|
55
|
+
return { mainRoot: dirname(commonDir), currentRoot, commonDir };
|
|
56
|
+
}
|
|
57
|
+
export function worktreePath(mainRoot, name) {
|
|
58
|
+
return join(mainRoot, WORKTREE_DIRECTORY, name);
|
|
59
|
+
}
|
|
60
|
+
/** True when `path` is inside `<mainRoot>/.worktrees/`. */
|
|
61
|
+
export function insideWorktreeDirectory(mainRoot, path) {
|
|
62
|
+
return path.startsWith(`${join(mainRoot, WORKTREE_DIRECTORY)}${sep}`);
|
|
63
|
+
}
|
|
64
|
+
async function exists(path) {
|
|
65
|
+
try {
|
|
66
|
+
await lstat(path);
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async function ensureExcluded(deps, mainRoot, commonDir) {
|
|
74
|
+
const probe = await deps.git(mainRoot, ["check-ignore", "-q", "--no-index", `${WORKTREE_DIRECTORY}/probe`]);
|
|
75
|
+
if (probe.exitCode === 0)
|
|
76
|
+
return;
|
|
77
|
+
const path = join(commonDir, "info", "exclude");
|
|
78
|
+
let current = "";
|
|
79
|
+
try {
|
|
80
|
+
current = await readFile(path, "utf8");
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
await mkdir(dirname(path), { recursive: true });
|
|
84
|
+
}
|
|
85
|
+
if (current.split(/\r?\n/u).includes(EXCLUDE_LINE))
|
|
86
|
+
return;
|
|
87
|
+
const separator = current.length === 0 || current.endsWith("\n") ? "" : "\n";
|
|
88
|
+
await writeFile(path, `${current}${separator}${EXCLUDE_LINE}\n`);
|
|
89
|
+
}
|
|
90
|
+
function portableRelative(path) {
|
|
91
|
+
return path.length > 0 && !path.startsWith("/") && !path.startsWith("~") &&
|
|
92
|
+
!path.includes("\\") && !path.includes("\0") &&
|
|
93
|
+
path.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..");
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Files Git does not carry into a new worktree but the session needs there:
|
|
97
|
+
* what agent-ops installed (rules, hooks, its own config) and whatever
|
|
98
|
+
* `.worktreeinclude` names (gitignore syntax: `.env`, `local.properties`).
|
|
99
|
+
* Only files missing from the new checkout are copied, so a tracked file
|
|
100
|
+
* always keeps the branch's version.
|
|
101
|
+
*/
|
|
102
|
+
async function carriedFiles(deps, mainRoot) {
|
|
103
|
+
// The config is what makes the new checkout an agent-ops checkout at all:
|
|
104
|
+
// carried even when no manifest lists it.
|
|
105
|
+
const paths = new Set([".agent-ops/config.json"]);
|
|
106
|
+
const manifestSource = await readFile(join(mainRoot, ".agent-ops", "manifest.json"), "utf8").catch(() => null);
|
|
107
|
+
if (manifestSource !== null) {
|
|
108
|
+
paths.add(".agent-ops/manifest.json");
|
|
109
|
+
try {
|
|
110
|
+
const manifest = parseInstallManifest(manifestSource);
|
|
111
|
+
for (const { path } of [...manifest.artifacts, ...manifest.markers, ...(manifest.hooks ?? [])]) {
|
|
112
|
+
paths.add(path);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// An unreadable manifest still leaves the manifest itself to copy.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (await exists(join(mainRoot, ".worktreeinclude"))) {
|
|
120
|
+
const listed = await git(deps, mainRoot, ["ls-files", "-z", "--others", "--ignored", "--exclude-from=.worktreeinclude"], "WORKTREE_INCLUDE_INVALID", ".worktreeinclude could not be read.");
|
|
121
|
+
for (const path of listed.split("\0"))
|
|
122
|
+
paths.add(path);
|
|
123
|
+
}
|
|
124
|
+
return [...paths].filter((path) => portableRelative(path) &&
|
|
125
|
+
!path.startsWith(`${WORKTREE_DIRECTORY}/`) &&
|
|
126
|
+
!path.startsWith(".agent-ops/tasks/") &&
|
|
127
|
+
!path.startsWith(".agent-ops/reviews/")).sort();
|
|
128
|
+
}
|
|
129
|
+
async function copyMissing(mainRoot, target, paths) {
|
|
130
|
+
const copied = [];
|
|
131
|
+
for (const path of paths) {
|
|
132
|
+
const source = join(mainRoot, ...path.split("/"));
|
|
133
|
+
const destination = join(target, ...path.split("/"));
|
|
134
|
+
let entry;
|
|
135
|
+
try {
|
|
136
|
+
entry = await lstat(source);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (!entry.isFile() || await exists(destination))
|
|
142
|
+
continue;
|
|
143
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
144
|
+
await copyFile(source, destination);
|
|
145
|
+
await chmod(destination, entry.mode & 0o777);
|
|
146
|
+
copied.push(path);
|
|
147
|
+
}
|
|
148
|
+
return copied;
|
|
149
|
+
}
|
|
150
|
+
export async function readWorktreeRecord(root) {
|
|
151
|
+
const source = await readPrivateFile(join(root, ...WORKTREE_RECORD_PATH.split("/")), root).catch(() => null);
|
|
152
|
+
if (source === null)
|
|
153
|
+
return null;
|
|
154
|
+
try {
|
|
155
|
+
const value = JSON.parse(source);
|
|
156
|
+
return value.schemaVersion === 1 && typeof value.name === "string" &&
|
|
157
|
+
typeof value.branch === "string" && typeof value.path === "string" &&
|
|
158
|
+
typeof value.mainRoot === "string" && typeof value.targetBranch === "string" &&
|
|
159
|
+
typeof value.base === "string" && typeof value.sessionId === "string" &&
|
|
160
|
+
typeof value.createdAt === "string"
|
|
161
|
+
? value
|
|
162
|
+
: null;
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
export async function writeWorktreeRecord(record) {
|
|
169
|
+
await writePrivateFile(join(record.path, ...WORKTREE_RECORD_PATH.split("/")), `${JSON.stringify(record, null, 2)}\n`, record.path);
|
|
170
|
+
}
|
|
171
|
+
/** Points the session at the worktree from both sides of the gate. */
|
|
172
|
+
export async function bindSession(deps, record, mainConfig, baselineFingerprint) {
|
|
173
|
+
for (const harness of ["claude", "codex"]) {
|
|
174
|
+
if (await exists(join(record.mainRoot, `.${harness}`))) {
|
|
175
|
+
await writeSessionMarker({ root: record.path, harness, sessionId: record.sessionId });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (!mainConfig.features.completionGate.enabled)
|
|
179
|
+
return;
|
|
180
|
+
const worktreeConfig = await deps.loadConfig(record.path);
|
|
181
|
+
await (await deps.gate(record.path, worktreeConfig)).seed(record.sessionId, baselineFingerprint);
|
|
182
|
+
await (await deps.gate(record.mainRoot, mainConfig)).redirect(record.sessionId, record.path);
|
|
183
|
+
}
|
|
184
|
+
export async function removeCheckout(deps, record, force) {
|
|
185
|
+
await git(deps, record.mainRoot, ["worktree", "remove", ...(force ? ["--force"] : []), record.path], "WORKTREE_REMOVE_FAILED", `Git could not remove ${record.path}.`);
|
|
186
|
+
await git(deps, record.mainRoot, ["branch", force ? "-D" : "-d", record.branch], "WORKTREE_REMOVE_FAILED", `Git could not delete ${record.branch}.`);
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Creates `.worktrees/<name>` on `agent-ops/<name>` from the main checkout's
|
|
190
|
+
* HEAD and makes it a working agent-ops checkout for `sessionId`.
|
|
191
|
+
*/
|
|
192
|
+
export async function addWorktree(deps, options) {
|
|
193
|
+
const name = assertWorktreeName(options.name);
|
|
194
|
+
const sessionId = assertSessionId(options.sessionId);
|
|
195
|
+
const { mainRoot, currentRoot, commonDir } = await resolveCheckouts(deps, options.cwd);
|
|
196
|
+
if (currentRoot !== mainRoot) {
|
|
197
|
+
throw worktreeError("WORKTREE_NESTED", `Run worktree commands from the main checkout (${mainRoot}), not from inside a worktree.`);
|
|
198
|
+
}
|
|
199
|
+
const targetBranch = await git(deps, mainRoot, ["symbolic-ref", "--short", "-q", "HEAD"], "WORKTREE_DETACHED_HEAD", "The main checkout must be on a branch to merge back into.");
|
|
200
|
+
const base = await git(deps, mainRoot, ["rev-parse", "--verify", "HEAD^{commit}"], "WORKTREE_NO_COMMIT", "The main checkout has no commit to branch from.");
|
|
201
|
+
const path = worktreePath(mainRoot, name);
|
|
202
|
+
const branch = `${WORKTREE_BRANCH_PREFIX}${name}`;
|
|
203
|
+
if (await exists(path)) {
|
|
204
|
+
throw worktreeError("WORKTREE_EXISTS", `${path} already exists; pick another name or resume it.`);
|
|
205
|
+
}
|
|
206
|
+
if ((await deps.git(mainRoot, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`])).exitCode === 0) {
|
|
207
|
+
throw worktreeError("WORKTREE_EXISTS", `Branch ${branch} already exists; pick another name.`);
|
|
208
|
+
}
|
|
209
|
+
const mainConfig = await deps.loadConfig(mainRoot);
|
|
210
|
+
const mainTrust = await deps.trust.status(mainRoot, mainConfig);
|
|
211
|
+
const setupSteps = mainConfig.worktree?.setup ?? [];
|
|
212
|
+
if (setupSteps.length > 0 && mainTrust !== "TRUSTED") {
|
|
213
|
+
throw worktreeError("WORKTREE_SETUP_UNTRUSTED", "worktree.setup runs repository commands; run agent-ops trust grant in the main checkout first.");
|
|
214
|
+
}
|
|
215
|
+
await ensureExcluded(deps, mainRoot, commonDir);
|
|
216
|
+
await git(deps, mainRoot, ["worktree", "add", "-b", branch, path, base], "WORKTREE_ADD_FAILED", "Git could not create the worktree.");
|
|
217
|
+
const record = {
|
|
218
|
+
schemaVersion: 1,
|
|
219
|
+
name,
|
|
220
|
+
branch,
|
|
221
|
+
path: await realpath(path),
|
|
222
|
+
mainRoot,
|
|
223
|
+
targetBranch,
|
|
224
|
+
base,
|
|
225
|
+
sessionId,
|
|
226
|
+
createdAt: deps.now?.() ?? new Date().toISOString()
|
|
227
|
+
};
|
|
228
|
+
let trusted = false;
|
|
229
|
+
try {
|
|
230
|
+
const copied = await copyMissing(mainRoot, record.path, await carriedFiles(deps, mainRoot));
|
|
231
|
+
const worktreeConfig = await deps.loadConfig(record.path);
|
|
232
|
+
// The same commands the user already trusted, and nothing else: any
|
|
233
|
+
// difference in config leaves the new checkout untrusted.
|
|
234
|
+
if (mainTrust === "TRUSTED" && calculateConfigHash(worktreeConfig) === calculateConfigHash(mainConfig)) {
|
|
235
|
+
await deps.trust.grant(record.path, worktreeConfig);
|
|
236
|
+
trusted = true;
|
|
237
|
+
}
|
|
238
|
+
const setup = [];
|
|
239
|
+
for (const step of setupSteps) {
|
|
240
|
+
const result = await deps.runSetup(record.path, {
|
|
241
|
+
...step,
|
|
242
|
+
timeoutMs: step.timeoutMs ?? DEFAULT_SETUP_TIMEOUT_MS
|
|
243
|
+
});
|
|
244
|
+
const label = [step.command, ...step.args].join(" ");
|
|
245
|
+
if (result.exitCode !== 0) {
|
|
246
|
+
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());
|
|
247
|
+
}
|
|
248
|
+
setup.push(label);
|
|
249
|
+
}
|
|
250
|
+
await writeWorktreeRecord(record);
|
|
251
|
+
await bindSession(deps, record, mainConfig);
|
|
252
|
+
return { record, copied, trusted, setup };
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
if (mainConfig.features.completionGate.enabled) {
|
|
256
|
+
await (await deps.gate(mainRoot, mainConfig)).redirect(sessionId, null).catch(() => undefined);
|
|
257
|
+
}
|
|
258
|
+
if (trusted)
|
|
259
|
+
await deps.trust.revoke(record.path, mainConfig).catch(() => undefined);
|
|
260
|
+
await removeCheckout(deps, record, true).catch(() => undefined);
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/** `session-` plus the session id's first eight name-safe characters. */
|
|
265
|
+
export function sessionWorktreeName(sessionId) {
|
|
266
|
+
const slug = sessionId.toLowerCase().replace(/[^a-z0-9]/gu, "").slice(0, 8);
|
|
267
|
+
return `session-${slug === "" ? "0" : slug}`;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* The worktree `sessionId` works in, created on first use. One already bound
|
|
271
|
+
* to the same session is reused, so a session blocked twice before it moves
|
|
272
|
+
* still gets exactly one worktree.
|
|
273
|
+
*/
|
|
274
|
+
export async function ensureSessionWorktree(deps, options) {
|
|
275
|
+
const { mainRoot } = await resolveCheckouts(deps, options.cwd);
|
|
276
|
+
const name = sessionWorktreeName(options.sessionId);
|
|
277
|
+
const existing = await readWorktreeRecord(worktreePath(mainRoot, name));
|
|
278
|
+
if (existing?.sessionId === options.sessionId)
|
|
279
|
+
return existing;
|
|
280
|
+
return (await addWorktree(deps, { cwd: mainRoot, name, sessionId: options.sessionId })).record;
|
|
281
|
+
}
|
|
@@ -39,8 +39,18 @@ function safePreflight(attempt) {
|
|
|
39
39
|
...(attempt.diagnostic === undefined ? {} : { diagnostic: safeText(attempt.diagnostic) })
|
|
40
40
|
};
|
|
41
41
|
}
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
/**
|
|
43
|
+
* A task's review records live under `<fingerprint>.<task id>`, so a parent
|
|
44
|
+
* and its subtask reviewed on the same source each keep their own PASS until
|
|
45
|
+
* `worktree finish` completes both. A bare `<fingerprint>` is a record with no
|
|
46
|
+
* task, or one written before records were kept per task; readers fall back
|
|
47
|
+
* to it.
|
|
48
|
+
*/
|
|
49
|
+
function recordKey(fingerprint, taskId) {
|
|
50
|
+
return taskId === undefined ? fingerprint : `${fingerprint}.${taskId}`;
|
|
51
|
+
}
|
|
52
|
+
function artifactPath(root, fingerprint, taskId) {
|
|
53
|
+
return join(root, ...REVIEW_ATTESTATION_DIRECTORY.split("/"), `${recordKey(fingerprint, taskId)}${REVIEW_REPORT_ARTIFACT_SUFFIX}`);
|
|
44
54
|
}
|
|
45
55
|
export function reviewReportDigest(report) {
|
|
46
56
|
return sha256(JSON.stringify(redactReviewReport(report)));
|
|
@@ -127,7 +137,8 @@ function matchingReportArtifact(value, attestation) {
|
|
|
127
137
|
attempts.every((attempt, index) => attempt.sessionId === attestation.reviewSessionIds[index]));
|
|
128
138
|
}
|
|
129
139
|
async function hasMatchingReportArtifact(root, attestation) {
|
|
130
|
-
|
|
140
|
+
// `reportArtifact` was validated to name this fingerprint's own record.
|
|
141
|
+
const source = await readPrivateFile(join(root, ...attestation.reportArtifact.split("/")), root);
|
|
131
142
|
if (source === null) {
|
|
132
143
|
return false;
|
|
133
144
|
}
|
|
@@ -150,12 +161,12 @@ export async function saveReviewReportArtifact(root, result, sourceFingerprint,
|
|
|
150
161
|
if (Buffer.byteLength(serialized, "utf8") > REPORT_ARTIFACT_MAX_BYTES) {
|
|
151
162
|
throw new AgentOpsError("REVIEW_REPORT_ARTIFACT_TOO_LARGE", "Review report artifact exceeds the capture limit.");
|
|
152
163
|
}
|
|
153
|
-
const relativePath = `${REVIEW_ATTESTATION_DIRECTORY}/${sourceFingerprint}${REVIEW_REPORT_ARTIFACT_SUFFIX}`;
|
|
154
|
-
await writePrivateFile(artifactPath(root, sourceFingerprint), serialized, root);
|
|
164
|
+
const relativePath = `${REVIEW_ATTESTATION_DIRECTORY}/${recordKey(sourceFingerprint, taskId)}${REVIEW_REPORT_ARTIFACT_SUFFIX}`;
|
|
165
|
+
await writePrivateFile(artifactPath(root, sourceFingerprint, taskId), serialized, root);
|
|
155
166
|
return relativePath;
|
|
156
167
|
}
|
|
157
|
-
function attestationPath(root, fingerprint) {
|
|
158
|
-
return join(root, ...REVIEW_ATTESTATION_DIRECTORY.split("/"), `${fingerprint}.json`);
|
|
168
|
+
function attestationPath(root, fingerprint, taskId) {
|
|
169
|
+
return join(root, ...REVIEW_ATTESTATION_DIRECTORY.split("/"), `${recordKey(fingerprint, taskId)}.json`);
|
|
159
170
|
}
|
|
160
171
|
function parseAttestation(value) {
|
|
161
172
|
if (typeof value !== "object" || value === null) {
|
|
@@ -183,8 +194,12 @@ function parseAttestation(value) {
|
|
|
183
194
|
typeof record.adversarialReportDigest !== "string" ||
|
|
184
195
|
!DIGEST_PATTERN.test(record.adversarialReportDigest) ||
|
|
185
196
|
typeof record.reportArtifact !== "string" ||
|
|
186
|
-
record.
|
|
187
|
-
|
|
197
|
+
// Its own per-task record, or the bare one an older review wrote.
|
|
198
|
+
![
|
|
199
|
+
recordKey(record.sourceFingerprint, record.taskId),
|
|
200
|
+
record.sourceFingerprint
|
|
201
|
+
].some((key) => record.reportArtifact ===
|
|
202
|
+
`${REVIEW_ATTESTATION_DIRECTORY}/${key}${REVIEW_REPORT_ARTIFACT_SUFFIX}`) ||
|
|
188
203
|
(record.hostTarget !== undefined && !isReviewTarget(record.hostTarget)) ||
|
|
189
204
|
typeof record.createdAt !== "string" ||
|
|
190
205
|
!Number.isFinite(Date.parse(record.createdAt))) {
|
|
@@ -214,33 +229,61 @@ export async function saveReviewAttestation(root, attestation) {
|
|
|
214
229
|
if (!(await hasMatchingReportArtifact(root, validated))) {
|
|
215
230
|
throw new AgentOpsError("REVIEW_ATTESTATION_INVALID", "Review attestation has no matching report artifact.");
|
|
216
231
|
}
|
|
217
|
-
const relativePath = `${REVIEW_ATTESTATION_DIRECTORY}/${validated.sourceFingerprint}.json`;
|
|
218
|
-
await writePrivateFile(attestationPath(root, validated.sourceFingerprint), `${JSON.stringify(validated, null, 2)}\n`, root);
|
|
232
|
+
const relativePath = `${REVIEW_ATTESTATION_DIRECTORY}/${recordKey(validated.sourceFingerprint, validated.taskId)}.json`;
|
|
233
|
+
await writePrivateFile(attestationPath(root, validated.sourceFingerprint, validated.taskId), `${JSON.stringify(validated, null, 2)}\n`, root);
|
|
219
234
|
return relativePath;
|
|
220
235
|
}
|
|
221
236
|
/**
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
* treat it as a passing review.
|
|
237
|
+
* The record keys that may hold `taskId`'s review of this source: its own,
|
|
238
|
+
* then the bare one. Without a task, every task's record for the source.
|
|
225
239
|
*/
|
|
226
|
-
|
|
227
|
-
if (
|
|
228
|
-
return
|
|
229
|
-
}
|
|
230
|
-
const source = await readPrivateFile(attestationPath(root, sourceFingerprint), root);
|
|
231
|
-
if (source === null) {
|
|
232
|
-
return null;
|
|
240
|
+
async function candidateKeys(root, sourceFingerprint, taskId, suffix) {
|
|
241
|
+
if (taskId !== undefined) {
|
|
242
|
+
return [recordKey(sourceFingerprint, taskId), sourceFingerprint];
|
|
233
243
|
}
|
|
244
|
+
let names = [];
|
|
234
245
|
try {
|
|
235
|
-
|
|
236
|
-
return attestation?.sourceFingerprint === sourceFingerprint &&
|
|
237
|
-
await hasMatchingReportArtifact(root, attestation)
|
|
238
|
-
? attestation
|
|
239
|
-
: null;
|
|
246
|
+
names = await readdir(join(root, ...REVIEW_ATTESTATION_DIRECTORY.split("/")));
|
|
240
247
|
}
|
|
241
248
|
catch {
|
|
249
|
+
// No reviews yet.
|
|
250
|
+
}
|
|
251
|
+
const own = names
|
|
252
|
+
.filter((name) => name.startsWith(`${sourceFingerprint}.`) && name.endsWith(suffix))
|
|
253
|
+
.map((name) => name.slice(0, -suffix.length))
|
|
254
|
+
.filter((key) => key !== sourceFingerprint && !key.endsWith(".reports") &&
|
|
255
|
+
TASK_ID_PATTERN.test(key.slice(sourceFingerprint.length + 1)));
|
|
256
|
+
return [sourceFingerprint, ...own];
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Returns the attestation recorded for this exact source state, or null. A
|
|
260
|
+
* malformed file reads as absent: a gate must fail closed on garbage, never
|
|
261
|
+
* treat it as a passing review. With `taskId`, only that task's record or the
|
|
262
|
+
* bare one; callers still check the task the attestation names.
|
|
263
|
+
*/
|
|
264
|
+
export async function findReviewAttestation(root, sourceFingerprint, taskId) {
|
|
265
|
+
if (!FINGERPRINT_PATTERN.test(sourceFingerprint) ||
|
|
266
|
+
(taskId !== undefined && !TASK_ID_PATTERN.test(taskId))) {
|
|
242
267
|
return null;
|
|
243
268
|
}
|
|
269
|
+
for (const key of await candidateKeys(root, sourceFingerprint, taskId, ".json")) {
|
|
270
|
+
const source = await readPrivateFile(join(root, ...REVIEW_ATTESTATION_DIRECTORY.split("/"), `${key}.json`), root);
|
|
271
|
+
if (source === null) {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
const attestation = parseAttestation(JSON.parse(source));
|
|
276
|
+
if (attestation?.sourceFingerprint === sourceFingerprint &&
|
|
277
|
+
recordKey(sourceFingerprint, key === sourceFingerprint ? undefined : attestation.taskId) === key &&
|
|
278
|
+
await hasMatchingReportArtifact(root, attestation)) {
|
|
279
|
+
return attestation;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return null;
|
|
244
287
|
}
|
|
245
288
|
/**
|
|
246
289
|
* The stored report behind an attestation, for a caller that wants to reuse
|
|
@@ -248,35 +291,64 @@ export async function findReviewAttestation(root, sourceFingerprint) {
|
|
|
248
291
|
* parses, matches this exact source and records a PASS: a reuse path must fail
|
|
249
292
|
* closed exactly like the gates that read the attestation.
|
|
250
293
|
*/
|
|
251
|
-
export async function readReviewReportArtifact(root, sourceFingerprint) {
|
|
252
|
-
if (!FINGERPRINT_PATTERN.test(sourceFingerprint)
|
|
294
|
+
export async function readReviewReportArtifact(root, sourceFingerprint, taskId) {
|
|
295
|
+
if (!FINGERPRINT_PATTERN.test(sourceFingerprint) ||
|
|
296
|
+
(taskId !== undefined && !TASK_ID_PATTERN.test(taskId))) {
|
|
253
297
|
return null;
|
|
254
298
|
}
|
|
255
|
-
const
|
|
299
|
+
const keys = taskId === undefined
|
|
300
|
+
? [sourceFingerprint]
|
|
301
|
+
: [recordKey(sourceFingerprint, taskId), sourceFingerprint];
|
|
302
|
+
for (const key of keys) {
|
|
303
|
+
const source = await readPrivateFile(join(root, ...REVIEW_ATTESTATION_DIRECTORY.split("/"), `${key}${REVIEW_REPORT_ARTIFACT_SUFFIX}`), root);
|
|
304
|
+
if (source === null) {
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
const value = JSON.parse(source);
|
|
309
|
+
if (value.sourceFingerprint === sourceFingerprint &&
|
|
310
|
+
value.status === "PASS" &&
|
|
311
|
+
value.report !== undefined &&
|
|
312
|
+
value.adversarial !== undefined) {
|
|
313
|
+
return value;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* A new authorized attempt supersedes any earlier PASS for this source: the
|
|
324
|
+
* task's own record, and the bare one when it is this task's (or no task's).
|
|
325
|
+
* Another task's bare record from before per-task records is left alone.
|
|
326
|
+
*/
|
|
327
|
+
export async function invalidateReviewAttestation(root, sourceFingerprint, taskId) {
|
|
328
|
+
if (!FINGERPRINT_PATTERN.test(sourceFingerprint) ||
|
|
329
|
+
(taskId !== undefined && !TASK_ID_PATTERN.test(taskId))) {
|
|
330
|
+
throw new AgentOpsError("REVIEW_ATTESTATION_INVALID", "Invalid source fingerprint.");
|
|
331
|
+
}
|
|
332
|
+
if (taskId !== undefined) {
|
|
333
|
+
const own = attestationPath(root, sourceFingerprint, taskId);
|
|
334
|
+
if (await readPrivateFile(own, root) !== null) {
|
|
335
|
+
await writePrivateFile(own, "null\n", root);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const bare = attestationPath(root, sourceFingerprint);
|
|
339
|
+
const source = await readPrivateFile(bare, root);
|
|
256
340
|
if (source === null) {
|
|
257
|
-
return
|
|
341
|
+
return;
|
|
258
342
|
}
|
|
343
|
+
let owner;
|
|
259
344
|
try {
|
|
260
|
-
|
|
261
|
-
return value.sourceFingerprint === sourceFingerprint &&
|
|
262
|
-
value.status === "PASS" &&
|
|
263
|
-
value.report !== undefined &&
|
|
264
|
-
value.adversarial !== undefined
|
|
265
|
-
? value
|
|
266
|
-
: null;
|
|
345
|
+
owner = JSON.parse(source)?.taskId;
|
|
267
346
|
}
|
|
268
347
|
catch {
|
|
269
|
-
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
/** A new authorized attempt supersedes any earlier PASS for this source. */
|
|
273
|
-
export async function invalidateReviewAttestation(root, sourceFingerprint) {
|
|
274
|
-
if (!FINGERPRINT_PATTERN.test(sourceFingerprint)) {
|
|
275
|
-
throw new AgentOpsError("REVIEW_ATTESTATION_INVALID", "Invalid source fingerprint.");
|
|
348
|
+
owner = undefined;
|
|
276
349
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
await writePrivateFile(path, "null\n", root);
|
|
350
|
+
if (taskId === undefined || owner === undefined || owner === taskId) {
|
|
351
|
+
await writePrivateFile(bare, "null\n", root);
|
|
280
352
|
}
|
|
281
353
|
}
|
|
282
354
|
/** Prior findings stay a short checklist, never a second report to digest. */
|
|
@@ -328,12 +400,15 @@ export async function findPriorFailingFindings(root, taskId) {
|
|
|
328
400
|
// ponytail: linear scan of every report; index by task if the directory grows large.
|
|
329
401
|
let latest;
|
|
330
402
|
for (const name of names) {
|
|
331
|
-
|
|
332
|
-
|
|
403
|
+
// `<fingerprint>` or `<fingerprint>.<task id>`, then the suffix.
|
|
404
|
+
const key = name.slice(0, -REVIEW_REPORT_ARTIFACT_SUFFIX.length);
|
|
405
|
+
const fingerprint = key.slice(0, 64);
|
|
406
|
+
if (!name.endsWith(REVIEW_REPORT_ARTIFACT_SUFFIX) || !FINGERPRINT_PATTERN.test(fingerprint) ||
|
|
407
|
+
(key !== fingerprint && key !== recordKey(fingerprint, taskId))) {
|
|
333
408
|
continue;
|
|
334
409
|
}
|
|
335
410
|
try {
|
|
336
|
-
const source = await readPrivateFile(
|
|
411
|
+
const source = await readPrivateFile(join(directory, name), root);
|
|
337
412
|
if (source === null) {
|
|
338
413
|
continue;
|
|
339
414
|
}
|
|
@@ -28,6 +28,17 @@ export const DEFAULT_REVIEW_TIMEOUT_MS = 900_000;
|
|
|
28
28
|
* thinking hard is never cut off.
|
|
29
29
|
*/
|
|
30
30
|
export const DEFAULT_STALL_IDLE_MS = 90_000;
|
|
31
|
+
/**
|
|
32
|
+
* agy writes nothing to stdout, stderr or its log while one generation
|
|
33
|
+
* streams, so a long think is indistinguishable from a wedge. Its own logs
|
|
34
|
+
* show normal generations silent for up to ~177s; 90s cut real reviews off.
|
|
35
|
+
*/
|
|
36
|
+
export const AGY_STALL_IDLE_MS = 240_000;
|
|
37
|
+
/**
|
|
38
|
+
* A call the service dropped before answering. It produced no verdict, so
|
|
39
|
+
* one fresh retry is not review shopping.
|
|
40
|
+
*/
|
|
41
|
+
const TRANSIENT_NETWORK = /\b(?:network issue|503|service is currently unavailable|ECONNRESET|ETIMEDOUT|socket hang up)\b/iu;
|
|
31
42
|
/** Two rounds at the per-target default: the previous worst case, now bounded. */
|
|
32
43
|
export const DEFAULT_REVIEW_CHAIN_TIMEOUT_MS = 2 * DEFAULT_REVIEW_TIMEOUT_MS;
|
|
33
44
|
/**
|
|
@@ -425,7 +436,8 @@ async function attemptTarget(request, options) {
|
|
|
425
436
|
}
|
|
426
437
|
throwIfInterrupted(target, options);
|
|
427
438
|
options.onProgress?.(`${target}: review started (timeout: ${Math.ceil((options.timeoutMs ?? DEFAULT_REVIEW_TIMEOUT_MS) / 1_000)}s)`);
|
|
428
|
-
const stallIdleMs = options.stallIdleMs ??
|
|
439
|
+
const stallIdleMs = options.stallIdleMs ??
|
|
440
|
+
(target === "agy" ? AGY_STALL_IDLE_MS : DEFAULT_STALL_IDLE_MS);
|
|
429
441
|
const stallWatch = watchForStall(heartbeatLog, options.signal, stallIdleMs);
|
|
430
442
|
let spawned;
|
|
431
443
|
try {
|
|
@@ -481,8 +493,11 @@ async function attemptTarget(request, options) {
|
|
|
481
493
|
}
|
|
482
494
|
if (spawned.failureClass === "nonzero-exit") {
|
|
483
495
|
const output = `${spawned.stderr}\n${spawned.stdout}`;
|
|
484
|
-
return
|
|
485
|
-
|
|
496
|
+
return {
|
|
497
|
+
...skip(rejectedCallReason(output), firstComplaint(spawned.stderr, spawned.stdout) ??
|
|
498
|
+
`the call was rejected with exit ${spawned.exitCode ?? "unknown"} and no output`),
|
|
499
|
+
...(TRANSIENT_NETWORK.test(output) ? { transient: true } : {})
|
|
500
|
+
};
|
|
486
501
|
}
|
|
487
502
|
if (spawned.failureClass === "signal-exit") {
|
|
488
503
|
return skip("capability-unavailable", `the reviewer exited after ${spawned.signal ?? "an unknown signal"}`);
|
|
@@ -522,6 +537,18 @@ async function attemptTarget(request, options) {
|
|
|
522
537
|
await rm(attemptDirectory, { recursive: true, force: true });
|
|
523
538
|
}
|
|
524
539
|
}
|
|
540
|
+
/**
|
|
541
|
+
* `attemptTarget`, retried once in a fresh session when the service dropped
|
|
542
|
+
* the call. Only once: a service that is down stays down.
|
|
543
|
+
*/
|
|
544
|
+
async function attemptTargetWithRetry(request, options) {
|
|
545
|
+
const first = await attemptTarget(request, options);
|
|
546
|
+
if (first.kind !== "skip" || first.transient !== true || budgetSpent(options)) {
|
|
547
|
+
return first;
|
|
548
|
+
}
|
|
549
|
+
options.onProgress?.(`${request.target}: ${first.diagnostic} → retrying once`);
|
|
550
|
+
return attemptTarget(request, options);
|
|
551
|
+
}
|
|
525
552
|
/** Builds the `execute` callback with one necessary and one adversarial review. */
|
|
526
553
|
export function createReviewExecutor(options) {
|
|
527
554
|
const report = options.onProgress ?? (() => { });
|
|
@@ -630,7 +657,7 @@ export function createReviewExecutor(options) {
|
|
|
630
657
|
report("chain: review deadline reached before the first round");
|
|
631
658
|
return { status: "NOT_RUN", reason: "timeout", attempts: [] };
|
|
632
659
|
}
|
|
633
|
-
const primaryOutcome = await
|
|
660
|
+
const primaryOutcome = await attemptTargetWithRetry({
|
|
634
661
|
...shared,
|
|
635
662
|
target: primaryTarget,
|
|
636
663
|
label: `${primaryTarget}-primary`,
|
|
@@ -739,7 +766,7 @@ export function createReviewExecutor(options) {
|
|
|
739
766
|
};
|
|
740
767
|
}
|
|
741
768
|
// The second round draws on what is left, never a fresh full budget.
|
|
742
|
-
const adversarialOutcome = await
|
|
769
|
+
const adversarialOutcome = await attemptTargetWithRetry({
|
|
743
770
|
...shared,
|
|
744
771
|
target: adversarialTarget,
|
|
745
772
|
label: `${adversarialTarget}-adversarial`,
|
|
@@ -300,7 +300,8 @@ export function validateConfig(value) {
|
|
|
300
300
|
"reviewRoles",
|
|
301
301
|
"schemaVersion",
|
|
302
302
|
"securityExceptions",
|
|
303
|
-
"verification"
|
|
303
|
+
"verification",
|
|
304
|
+
"worktree"
|
|
304
305
|
], CONFIG_SCHEMA_VERSION);
|
|
305
306
|
if (isFailure(root)) {
|
|
306
307
|
return root;
|
|
@@ -411,8 +412,56 @@ export function validateConfig(value) {
|
|
|
411
412
|
roles.add(role.value.role);
|
|
412
413
|
}
|
|
413
414
|
}
|
|
415
|
+
if (root.worktree !== undefined) {
|
|
416
|
+
const worktree = validateWorktree(root.worktree, "$.worktree");
|
|
417
|
+
if (!worktree.ok) {
|
|
418
|
+
return worktree;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
414
421
|
return success(root);
|
|
415
422
|
}
|
|
423
|
+
function validateWorktree(value, path) {
|
|
424
|
+
if (!isRecord(value)) {
|
|
425
|
+
return failure("INVALID_TYPE", path, "worktree must be an object.");
|
|
426
|
+
}
|
|
427
|
+
const unknown = unknownFieldFailure(value, ["mode", "setup"], path);
|
|
428
|
+
if (unknown !== undefined) {
|
|
429
|
+
return unknown;
|
|
430
|
+
}
|
|
431
|
+
if (value.mode !== "auto" && value.mode !== "off") {
|
|
432
|
+
return failure("INVALID_WORKTREE_MODE", `${path}.mode`, `Unsupported worktree mode: ${String(value.mode)}`);
|
|
433
|
+
}
|
|
434
|
+
if (value.setup === undefined) {
|
|
435
|
+
return success(value);
|
|
436
|
+
}
|
|
437
|
+
if (!Array.isArray(value.setup)) {
|
|
438
|
+
return failure("INVALID_TYPE", `${path}.setup`, "setup must be an array.");
|
|
439
|
+
}
|
|
440
|
+
for (const [index, step] of value.setup.entries()) {
|
|
441
|
+
const stepPath = `${path}.setup[${index}]`;
|
|
442
|
+
if (!isRecord(step)) {
|
|
443
|
+
return failure("INVALID_TYPE", stepPath, "Expected a setup command object.");
|
|
444
|
+
}
|
|
445
|
+
const stepUnknown = unknownFieldFailure(step, ["args", "command", "timeoutMs"], stepPath);
|
|
446
|
+
if (stepUnknown !== undefined) {
|
|
447
|
+
return stepUnknown;
|
|
448
|
+
}
|
|
449
|
+
if (!isNonEmptyString(step.command)) {
|
|
450
|
+
return failure("INVALID_COMMAND", `${stepPath}.command`, "command must be a non-empty string.");
|
|
451
|
+
}
|
|
452
|
+
if (!isStringArray(step.args) ||
|
|
453
|
+
step.args.some((argument) => argument.includes("\0"))) {
|
|
454
|
+
return failure("INVALID_ARGS", `${stepPath}.args`, "args must be an array of strings.");
|
|
455
|
+
}
|
|
456
|
+
if (step.timeoutMs !== undefined &&
|
|
457
|
+
(!Number.isSafeInteger(step.timeoutMs) ||
|
|
458
|
+
step.timeoutMs <= 0 ||
|
|
459
|
+
step.timeoutMs > MAX_TIMEOUT_MS)) {
|
|
460
|
+
return failure("INVALID_TIMEOUT", `${stepPath}.timeoutMs`, "timeoutMs must be a positive integer.");
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return success(value);
|
|
464
|
+
}
|
|
416
465
|
function validateReviewRole(value, path) {
|
|
417
466
|
if (!isRecord(value)) {
|
|
418
467
|
return failure("INVALID_TYPE", path, "Expected a review role object.");
|