@kylecheng3146/agent-ops 0.3.0 → 0.3.2
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 +19 -1
- package/dist/packages/cli/src/bin.js +34 -77
- package/dist/packages/cli/src/cli.js +3 -0
- package/dist/packages/cli/src/commands/init.js +27 -3
- package/dist/packages/cli/src/commands/parallel.js +5 -4
- 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/commands/update.js +47 -3
- package/dist/packages/cli/src/hook-entry.js +2 -1
- package/dist/packages/cli/src/hook-process.js +4 -1
- package/dist/packages/cli/src/parallel-deps.js +89 -0
- package/dist/packages/cli/src/wizard.js +21 -2
- package/dist/runtime/src/adapters/claude/config.js +2 -1
- package/dist/runtime/src/hooks/completion-gate.js +1 -1
- package/dist/runtime/src/install/harness.js +1 -1
- package/dist/runtime/src/install/parallel-setup.js +63 -0
- package/dist/runtime/src/install/plan.js +8 -7
- package/dist/runtime/src/install/update.js +4 -1
- package/dist/runtime/src/parallel/finish.js +47 -7
- package/dist/runtime/src/parallel/guard.js +20 -3
- package/dist/runtime/src/parallel/service.js +18 -0
- package/dist/runtime/src/review/attestation.js +125 -50
- package/dist/runtime/src/review/execute.js +32 -5
- 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 +22 -1
- package/docs/zh-TW/guides/configuration.md +16 -1
- package/package.json +1 -1
|
@@ -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`,
|
|
@@ -38,7 +38,7 @@ export async function checkTaskCompletionEvidence(stored, options) {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
|
-
const attestation = await findReviewAttestation(root, sourceFingerprint);
|
|
41
|
+
const attestation = await findReviewAttestation(root, sourceFingerprint, stored.task.id);
|
|
42
42
|
if (attestation === null || attestation.taskId !== stored.task.id) {
|
|
43
43
|
return { status: "FAIL", code: "REVIEW_REQUIRED", remedy: `Run agent-ops review --task ${stored.task.id} --yes for the whole task and current source.` };
|
|
44
44
|
}
|
|
@@ -130,9 +130,17 @@ export class TaskService {
|
|
|
130
130
|
state.tasks.push(record);
|
|
131
131
|
if (input.sessionId !== undefined) {
|
|
132
132
|
const currentIndex = state.sessions.findIndex(({ sessionId }) => sessionId === input.sessionId);
|
|
133
|
+
// A subtask keeps the session on the top of its tree: the completion
|
|
134
|
+
// gate checks the attached task and everything under it, so attaching
|
|
135
|
+
// the subtask would let a Stop through with its parent unfinished.
|
|
136
|
+
let rootId = record.task.id;
|
|
137
|
+
for (let parentId = record.task.parentTaskId; parentId !== undefined;) {
|
|
138
|
+
rootId = parentId;
|
|
139
|
+
parentId = state.tasks.find(({ task }) => task.id === parentId)?.task.parentTaskId;
|
|
140
|
+
}
|
|
133
141
|
const attachment = {
|
|
134
142
|
sessionId: input.sessionId,
|
|
135
|
-
taskId:
|
|
143
|
+
taskId: rootId,
|
|
136
144
|
attachedAt: now
|
|
137
145
|
};
|
|
138
146
|
if (currentIndex === -1) {
|
|
@@ -304,7 +312,12 @@ export class TaskService {
|
|
|
304
312
|
* task accepts evidence: a completed record must stay exactly as it was
|
|
305
313
|
* verified.
|
|
306
314
|
*/
|
|
307
|
-
async recordEvidence(taskId, evidenceInput
|
|
315
|
+
async recordEvidence(taskId, evidenceInput,
|
|
316
|
+
/**
|
|
317
|
+
* A PASS review's base: set records it, null clears it (the review covered
|
|
318
|
+
* uncommitted work), undefined leaves the record's value alone.
|
|
319
|
+
*/
|
|
320
|
+
reviewBase) {
|
|
308
321
|
const now = assertTimestamp(this.#now());
|
|
309
322
|
return await this.#store.mutate((state) => {
|
|
310
323
|
const current = findTask(state, taskId);
|
|
@@ -328,10 +341,13 @@ export class TaskService {
|
|
|
328
341
|
...new Set([...(evidence[criterionId] ?? []), ...references])
|
|
329
342
|
];
|
|
330
343
|
}
|
|
344
|
+
const { reviewBase: previousBase, ...rest } = current;
|
|
345
|
+
const base = reviewBase === undefined ? previousBase : reviewBase ?? undefined;
|
|
331
346
|
const updated = {
|
|
332
|
-
...
|
|
347
|
+
...rest,
|
|
333
348
|
evidence,
|
|
334
|
-
updatedAt: now
|
|
349
|
+
updatedAt: now,
|
|
350
|
+
...(base === undefined ? {} : { reviewBase: base })
|
|
335
351
|
};
|
|
336
352
|
replaceTask(state, updated);
|
|
337
353
|
return cloneRecord(updated);
|
|
@@ -66,7 +66,7 @@ function parseTaskRecord(value) {
|
|
|
66
66
|
];
|
|
67
67
|
// Every record written before an optional field existed must still parse, so
|
|
68
68
|
// the accepted shapes are every combination of them.
|
|
69
|
-
const optionalKeys = ["completionBase", "failureFingerprint", "policyConfigHash"];
|
|
69
|
+
const optionalKeys = ["completionBase", "failureFingerprint", "policyConfigHash", "reviewBase"];
|
|
70
70
|
const allowedKeys = new Set(Array.from({ length: 1 << optionalKeys.length }, (_unused, mask) => [
|
|
71
71
|
...baseKeys,
|
|
72
72
|
...optionalKeys.filter((_key, position) => (mask & (1 << position)) !== 0)
|
|
@@ -146,6 +146,10 @@ function parseTaskRecord(value) {
|
|
|
146
146
|
(typeof completionBase !== "string" || !/^[a-f0-9]{40,64}$/u.test(completionBase))) {
|
|
147
147
|
return invalidState("Task state contains an invalid completion base.");
|
|
148
148
|
}
|
|
149
|
+
if (value.reviewBase !== undefined &&
|
|
150
|
+
(typeof value.reviewBase !== "string" || !/^[a-f0-9]{40,64}$/u.test(value.reviewBase))) {
|
|
151
|
+
return invalidState("Task state contains an invalid review base.");
|
|
152
|
+
}
|
|
149
153
|
return {
|
|
150
154
|
task: task.value,
|
|
151
155
|
status,
|
|
@@ -156,7 +160,8 @@ function parseTaskRecord(value) {
|
|
|
156
160
|
archivedAt: value.archivedAt,
|
|
157
161
|
failureFingerprint,
|
|
158
162
|
policyConfigHash,
|
|
159
|
-
completionBase
|
|
163
|
+
completionBase,
|
|
164
|
+
...(value.reviewBase === undefined ? {} : { reviewBase: value.reviewBase })
|
|
160
165
|
};
|
|
161
166
|
}
|
|
162
167
|
function parseSession(value) {
|
|
@@ -152,6 +152,17 @@ evidence. Give each editing conversation its own worktree instead:
|
|
|
152
152
|
On Claude Code a direct Edit or Write of the main checkout outside
|
|
153
153
|
`.worktrees/` is denied (run `agent-ops update` once so the PreToolUse hook
|
|
154
154
|
matches the file tools). Absent or `"off"` keeps today's single checkout.
|
|
155
|
+
- `agent-ops task create` run from the main checkout with a known session
|
|
156
|
+
creates that session's worktree first (or reuses it) and records the task
|
|
157
|
+
there, so the work starts in the worktree. Its output names the path to
|
|
158
|
+
enter and the `--base` to verify against.
|
|
159
|
+
- As a fallback on Claude Code, the first denied edit creates that worktree:
|
|
160
|
+
`.worktrees/session-<first eight characters of the session id>`, setup
|
|
161
|
+
included, bound to the session. The denial names the path to enter with
|
|
162
|
+
EnterWorktree; a later denial for the same session reuses it. If creation
|
|
163
|
+
fails, the denial says why and falls back to the manual `worktree add`.
|
|
164
|
+
Run `agent-ops update` once so the PreToolUse hook gets the 600-second
|
|
165
|
+
timeout setup needs.
|
|
155
166
|
- `add` creates `.worktrees/<name>` on branch `agent-ops/<name>` from the main
|
|
156
167
|
checkout's HEAD and excludes `/.worktrees/` through `.git/info/exclude`,
|
|
157
168
|
never `.gitignore`. It copies the ignored files agent-ops installed and any
|
|
@@ -166,10 +177,20 @@ evidence. Give each editing conversation its own worktree instead:
|
|
|
166
177
|
- The session's completion gate follows it: Claude Code enters the worktree
|
|
167
178
|
with EnterWorktree, and a host that cannot move a session (agy) is judged by
|
|
168
179
|
the worktree's gate through a redirect the main checkout records.
|
|
180
|
+
- In a worktree, verify and review each task but do not run `task complete`
|
|
181
|
+
(Claude Code refuses it inside an isolated worktree session). `agent-ops
|
|
182
|
+
worktree finish <name>` completes every non-archived task in the worktree,
|
|
183
|
+
subtasks before their parents, each against the base its PASS review
|
|
184
|
+
recorded (the worktree's base when none was recorded), before any rebase.
|
|
185
|
+
If one cannot complete, nothing merges and the error names the task.
|
|
186
|
+
Creating a subtask keeps the session attached to the top of its tree, and
|
|
187
|
+
each task keeps its own review record, so a parent and a subtask reviewed
|
|
188
|
+
on the same source do not overwrite each other.
|
|
169
189
|
- `agent-ops worktree finish <name>` merges by fast-forward only, one finish
|
|
170
190
|
at a time. If the target moved, it rebases; a clean rebase whose own patch
|
|
171
191
|
is unchanged is re-verified before merging, and a conflict is reported with
|
|
172
|
-
the `refs/notes/agent-ops` intent of the work that landed first.
|
|
192
|
+
the `refs/notes/agent-ops` intent of the work that landed first. The note
|
|
193
|
+
records every task of the worktree, subtasks indented under their parent.
|
|
173
194
|
- `worktree list`, `resume <name> --session <id>` and `remove <name>` manage
|
|
174
195
|
what is left; `remove --force` discards work and asks the user first.
|
|
175
196
|
`doctor` reports worktrees idle for more than seven days.
|
|
@@ -136,6 +136,14 @@ fingerprint,因此兩個對話修改同一個 checkout 會互相作廢對方
|
|
|
136
136
|
Write 修改主 checkout 中 `.worktrees/` 以外的檔案會被拒絕(需先執行一次
|
|
137
137
|
`agent-ops update`,讓 PreToolUse hook 比對檔案工具)。未設定或 `"off"` 則維持
|
|
138
138
|
目前的單一 checkout。
|
|
139
|
+
- 在主 checkout、已知 session 的情況下執行 `agent-ops task create`,會先建立(或重用)
|
|
140
|
+
該 session 的 worktree,並把 task 記錄在那裡,讓工作一開始就在 worktree 內。輸出會列出
|
|
141
|
+
要進入的路徑與 verify 用的 `--base`。
|
|
142
|
+
- 作為 Claude Code 上的後備,第一次被拒絕的編輯會自動建立該 worktree:
|
|
143
|
+
`.worktrees/session-<session id 前八個字元>`,包含 setup,並綁定該 session。
|
|
144
|
+
拒絕訊息會列出要以 EnterWorktree 進入的路徑;同一 session 之後被拒絕時會重用它。
|
|
145
|
+
建立失敗時,拒絕訊息會說明原因並退回手動 `worktree add`。需先執行一次
|
|
146
|
+
`agent-ops update`,讓 PreToolUse hook 取得 setup 所需的 600 秒逾時。
|
|
139
147
|
- `add` 從主 checkout 的 HEAD 建立 `.worktrees/<name>` 與 branch
|
|
140
148
|
`agent-ops/<name>`,並透過 `.git/info/exclude`(而非 `.gitignore`)排除
|
|
141
149
|
`/.worktrees/`。它會複製 agent-ops 安裝的 ignored 檔案,以及根目錄
|
|
@@ -148,10 +156,17 @@ fingerprint,因此兩個對話修改同一個 checkout 會互相作廢對方
|
|
|
148
156
|
- Session 的 completion gate 會跟著它:Claude Code 以 EnterWorktree 進入
|
|
149
157
|
worktree;無法移動 session 的 host(agy)則透過主 checkout 記錄的 redirect,
|
|
150
158
|
由 worktree 的 gate 判定。
|
|
159
|
+
- 在 worktree 裡對每個 task 執行 verify 與 review,但不要執行 `task complete`
|
|
160
|
+
(Claude Code 在隔離的 worktree session 中會拒絕它)。`agent-ops worktree finish
|
|
161
|
+
<name>` 會在任何 rebase 之前 complete worktree 內每個未封存的 task,先 subtask
|
|
162
|
+
後 parent,各自使用其 PASS review 記錄的 base(沒有記錄時使用 worktree 的 base)。
|
|
163
|
+
任一 task 無法完成時不會合併,錯誤訊息會指出是哪個 task。建立 subtask 時 session
|
|
164
|
+
維持附著在 task 樹最上層,且每個 task 各自保留 review 紀錄,所以在同一份原始碼上
|
|
165
|
+
review 的 parent 與 subtask 不會互相覆蓋。
|
|
151
166
|
- `agent-ops worktree finish <name>` 只以 fast-forward 合併,一次只執行一個
|
|
152
167
|
finish。Target 若已前進會先 rebase;沒有衝突且自身 patch 不變的 rebase 會先
|
|
153
168
|
重新驗證再合併;衝突則連同先合併那份工作在 `refs/notes/agent-ops` 的意圖一起
|
|
154
|
-
回報。
|
|
169
|
+
回報。Note 會記錄 worktree 的每個 task,subtask 縮排在其 parent 之下。
|
|
155
170
|
- `worktree list`、`resume <name> --session <id>` 與 `remove <name>` 管理剩下的
|
|
156
171
|
worktree;`remove --force` 會丟棄工作,執行前會先詢問使用者。`doctor` 會回報
|
|
157
172
|
閒置超過七天的 worktree。
|