@kylecheng3146/agent-ops 0.3.5 → 0.3.7
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/runtime/src/hooks/completion-gate.js +21 -1
- package/dist/runtime/src/install/doctor.js +4 -0
- package/dist/runtime/src/parallel/finish.js +7 -4
- package/dist/runtime/src/parallel/service.js +4 -0
- package/dist/runtime/src/review/execute.js +6 -1
- package/dist/runtime/src/review/host-sandbox.js +36 -1
- package/dist/runtime/src/review/probe.js +13 -4
- package/dist/runtime/src/review/render.js +5 -0
- package/docs/en/guides/configuration.md +18 -5
- package/docs/zh-TW/guides/configuration.md +14 -2
- package/package.json +1 -1
|
@@ -6,7 +6,7 @@ import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../secur
|
|
|
6
6
|
import { checkTaskCompletionEvidence, findIncompleteSubtask } from "../task/completion.js";
|
|
7
7
|
import { collectBaseChangePaths, collectChangeSurface } from "../verify/change-surface.js";
|
|
8
8
|
import { calculateSourceFingerprint } from "../verify/source-fingerprint.js";
|
|
9
|
-
import { readWorktreeRecord } from "../parallel/service.js";
|
|
9
|
+
import { NOTES_REF, noteSessionLine, readWorktreeRecord } from "../parallel/service.js";
|
|
10
10
|
const FINGERPRINT = /^[a-f0-9]{64}$/u;
|
|
11
11
|
const SESSION = /^[^\0\r\n]{1,256}$/u;
|
|
12
12
|
function gateDirectory(root) {
|
|
@@ -248,12 +248,32 @@ export class CompletionGateService {
|
|
|
248
248
|
event.event === "command-batch" ? event.commands : [];
|
|
249
249
|
return commands.some(({ command, args }) => [command, ...args].includes("allow-stop"));
|
|
250
250
|
}
|
|
251
|
+
/**
|
|
252
|
+
* Whether `worktree finish` merged this session's reviewed work: a note
|
|
253
|
+
* naming the session on a commit HEAD contains, and nothing uncommitted on
|
|
254
|
+
* top. finish moves the task into that note, so the checkout has none left.
|
|
255
|
+
*/
|
|
256
|
+
async #finishedByWorktree(sessionId) {
|
|
257
|
+
const runner = this.#options.gitRunner;
|
|
258
|
+
if ((await collectChangeSurface(runner)).paths.length > 0)
|
|
259
|
+
return false;
|
|
260
|
+
// ponytail: newest 500 commits only; older merges need a one-time allow-stop
|
|
261
|
+
const log = await runner.run(["log", `--notes=${NOTES_REF}`, "--format=%N%x00", "-n", "500", "HEAD"]);
|
|
262
|
+
if (log.exitCode !== 0)
|
|
263
|
+
return false;
|
|
264
|
+
const line = noteSessionLine(sessionId);
|
|
265
|
+
return new TextDecoder().decode(log.stdout).split("\0").some((note) => note.split("\n").includes(line));
|
|
266
|
+
}
|
|
251
267
|
async #validateTask(sessionId) {
|
|
252
268
|
let stored;
|
|
253
269
|
try {
|
|
254
270
|
stored = await this.#options.taskService.status({ sessionId });
|
|
255
271
|
}
|
|
256
272
|
catch (error) {
|
|
273
|
+
if (error instanceof AgentOpsError && error.code === "TASK_SESSION_UNATTACHED" &&
|
|
274
|
+
await this.#finishedByWorktree(sessionId)) {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
257
277
|
return error instanceof AgentOpsError && error.code === "TASK_SESSION_UNATTACHED"
|
|
258
278
|
? gateResult("block", "FAIL", "COMPLETION_GATE_TASK_REQUIRED", `Attach this conversation to a formal task: agent-ops task create --title <title> --criterion <json> --criterion <json> (two to five criteria), then agent-ops task attach --task <task-id> --session ${shellQuote(sessionId)}.`)
|
|
259
279
|
: gateResult("block", "UNKNOWN", "COMPLETION_GATE_TASK_UNAVAILABLE", "Repair task state with agent-ops doctor before stopping.");
|
|
@@ -475,6 +475,10 @@ async function checkReviewTargets(config, probe, checkAuth, selectedTargets = []
|
|
|
475
475
|
failures.push(check("review-targets", "FAIL", `${target} did not answer in time.`, undefined, "Re-run: agent-ops doctor --check-auth"));
|
|
476
476
|
continue;
|
|
477
477
|
}
|
|
478
|
+
if (result === "network-unreachable") {
|
|
479
|
+
failures.push(check("review-targets", "FAIL", `${target} could not reach the network, so its login state is unknown.`, undefined, "Run agent-ops outside the sandbox, or grant it escalated execution, then re-run: agent-ops doctor --check-auth"));
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
478
482
|
if (result === "capability-unavailable") {
|
|
479
483
|
failures.push(check("review-targets", "FAIL", `${target} could not complete its read-only capability probe.`, undefined, "Run agent-ops review on a host with network and loopback permission, then retry."));
|
|
480
484
|
continue;
|
|
@@ -5,8 +5,7 @@ import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
|
|
|
5
5
|
import { currentGateFingerprint } from "../hooks/completion-gate.js";
|
|
6
6
|
import { collectChangeSurface } from "../verify/change-surface.js";
|
|
7
7
|
import { aggregateVerificationStatus, executeConfiguredCommand } from "../verify/command-executor.js";
|
|
8
|
-
import { assertWorktreeName, parseWorktreeListPorcelain, readWorktreeRecord, removeCheckout, resolveCheckouts, worktreePath } from "./service.js";
|
|
9
|
-
export const NOTES_REF = "agent-ops";
|
|
8
|
+
import { assertWorktreeName, NOTES_REF, noteSessionLine, parseWorktreeListPorcelain, readWorktreeRecord, removeCheckout, resolveCheckouts, worktreePath } from "./service.js";
|
|
10
9
|
const LOCK_NAME = "agent-ops-finish.lock";
|
|
11
10
|
const LOCK_POLL_MS = 2_000;
|
|
12
11
|
const LOCK_WAIT_MS = 30 * 60 * 1000;
|
|
@@ -180,6 +179,7 @@ function taskTree(tasks) {
|
|
|
180
179
|
function noteText(record, tasks) {
|
|
181
180
|
return [
|
|
182
181
|
`worktree: ${record.name} (${record.branch})`,
|
|
182
|
+
noteSessionLine(record.sessionId),
|
|
183
183
|
...tasks.flatMap(({ task, depth }) => {
|
|
184
184
|
const indent = " ".repeat(depth);
|
|
185
185
|
return [
|
|
@@ -314,8 +314,11 @@ export async function finishWorktree(deps, options) {
|
|
|
314
314
|
if (gateEnabled) {
|
|
315
315
|
await attempt("gate", async () => {
|
|
316
316
|
const mainGate = await deps.gate(mainRoot, mainConfig);
|
|
317
|
-
await
|
|
318
|
-
await mainGate.rebase(before,
|
|
317
|
+
const after = await currentGateFingerprint(mainRoot, mainRunner);
|
|
318
|
+
await mainGate.rebase(before, after);
|
|
319
|
+
// rebase only moves baselines equal to `before`; a main that moved since
|
|
320
|
+
// this session began would leave it reading the merge as its own change.
|
|
321
|
+
await mainGate.seed(record.sessionId, after);
|
|
319
322
|
});
|
|
320
323
|
}
|
|
321
324
|
await attempt("trust", async () => await deps.trust.revoke(record.path, worktreeConfig));
|
|
@@ -15,6 +15,10 @@ import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../secur
|
|
|
15
15
|
export const WORKTREE_DIRECTORY = ".worktrees";
|
|
16
16
|
export const WORKTREE_BRANCH_PREFIX = "agent-ops/";
|
|
17
17
|
export const WORKTREE_RECORD_PATH = ".agent-ops/tasks/worktree.json";
|
|
18
|
+
/** The notes ref `worktree finish` records a merge in. */
|
|
19
|
+
export const NOTES_REF = "agent-ops";
|
|
20
|
+
/** The note line binding a merge to the session that finished it. */
|
|
21
|
+
export const noteSessionLine = (sessionId) => `session: ${sessionId}`;
|
|
18
22
|
const EXCLUDE_LINE = `/${WORKTREE_DIRECTORY}/`;
|
|
19
23
|
const NAME = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/u;
|
|
20
24
|
const SESSION = /^[^\0\r\n]{1,256}$/u;
|
|
@@ -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 } from "./host-sandbox.js";
|
|
11
|
+
import { detectHostRestriction, 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";
|
|
@@ -143,6 +143,8 @@ function preflightUnavailableReason(result) {
|
|
|
143
143
|
return "timeout";
|
|
144
144
|
case "unauthenticated":
|
|
145
145
|
return "login-required";
|
|
146
|
+
case "network-unreachable":
|
|
147
|
+
return "network-unreachable";
|
|
146
148
|
case "ineligible":
|
|
147
149
|
return "capability-unavailable";
|
|
148
150
|
case "capability-unavailable":
|
|
@@ -171,6 +173,9 @@ function rejectedCallReason(output) {
|
|
|
171
173
|
if (/\b(?:quota|rate limit|usage limit|too many requests)\b/iu.test(output)) {
|
|
172
174
|
return "quota-exhausted";
|
|
173
175
|
}
|
|
176
|
+
if (isNetworkFailure(output)) {
|
|
177
|
+
return "network-unreachable";
|
|
178
|
+
}
|
|
174
179
|
if (/\b(?:not logged in|login required|log in to|authentication required|unauthenticated|unauthorized)\b/iu.test(output) ||
|
|
175
180
|
/\b(?:invalid|expired)\s+(?:api key|token|credential)/iu.test(output) ||
|
|
176
181
|
/\b401\b/u.test(output)) {
|
|
@@ -1,7 +1,42 @@
|
|
|
1
|
-
import { createServer } from "node:net";
|
|
1
|
+
import { connect, createServer } from "node:net";
|
|
2
2
|
/** Targets that need a loopback listener of their own to answer at all. */
|
|
3
3
|
export const BIND_DEPENDENT_TARGETS = ["agy"];
|
|
4
4
|
const BIND_PROBE_TIMEOUT_MS = 2_000;
|
|
5
|
+
/**
|
|
6
|
+
* A reviewer that cannot reach its API host says so in its own words. Reading
|
|
7
|
+
* that as "not logged in" sent users to re-login an authenticated CLI when the
|
|
8
|
+
* host sandbox was what withheld the network.
|
|
9
|
+
*/
|
|
10
|
+
const NETWORK_FAILURE = /\b(?:ENOTFOUND|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH)\b|getaddrinfo|fetch failed|network is unreachable|could not resolve host|no such host|dial tcp/iu;
|
|
11
|
+
export function isNetworkFailure(output) {
|
|
12
|
+
return NETWORK_FAILURE.test(output);
|
|
13
|
+
}
|
|
14
|
+
/** The host each reviewer talks to, so a reachability check tests its real path. */
|
|
15
|
+
export const TARGET_API_HOST = {
|
|
16
|
+
agy: "cloudcode-pa.googleapis.com",
|
|
17
|
+
codex: "api.openai.com",
|
|
18
|
+
claude: "api.anthropic.com"
|
|
19
|
+
};
|
|
20
|
+
export const REACH_TIMEOUT_MS = 2_000;
|
|
21
|
+
/**
|
|
22
|
+
* DNS plus a TCP connect to `host:443`, nothing sent. Diagnostic only: it
|
|
23
|
+
* refines a failed probe's reason and never gates a review, so a proxy-only
|
|
24
|
+
* network that fails it still gets the review attempted.
|
|
25
|
+
*/
|
|
26
|
+
export async function probeHostReachable(host, port = 443) {
|
|
27
|
+
return await new Promise((resolve) => {
|
|
28
|
+
const socket = connect({ host, port });
|
|
29
|
+
const finish = (value) => {
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
socket.destroy();
|
|
32
|
+
resolve(value);
|
|
33
|
+
};
|
|
34
|
+
const timer = setTimeout(() => finish(false), REACH_TIMEOUT_MS);
|
|
35
|
+
timer.unref();
|
|
36
|
+
socket.once("connect", () => finish(true));
|
|
37
|
+
socket.once("error", () => finish(false));
|
|
38
|
+
});
|
|
39
|
+
}
|
|
5
40
|
/**
|
|
6
41
|
* Opens and immediately closes a loopback listener on an ephemeral port.
|
|
7
42
|
* Cheap enough to run before every review, and it exercises exactly the
|
|
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { extractFinalMessage } from "./extract.js";
|
|
6
6
|
import { isolatedReviewEnvironment } from "./execute.js";
|
|
7
|
+
import { isNetworkFailure, probeHostReachable, TARGET_API_HOST } from "./host-sandbox.js";
|
|
7
8
|
import { buildProbeInvocation } from "./invocation.js";
|
|
8
9
|
const PROBE_PROMPT = "Reply with the single word OK and nothing else.";
|
|
9
10
|
/**
|
|
@@ -99,10 +100,18 @@ export async function probeReviewTarget(target, options) {
|
|
|
99
100
|
if (!deep) {
|
|
100
101
|
return spawned.status === "PASS" ? "ok" : "unauthenticated";
|
|
101
102
|
}
|
|
102
|
-
|
|
103
|
-
extractFinalMessage(target, spawned.stdout) !== undefined
|
|
104
|
-
|
|
105
|
-
|
|
103
|
+
if (spawned.status === "PASS" &&
|
|
104
|
+
extractFinalMessage(target, spawned.stdout) !== undefined) {
|
|
105
|
+
return "ok";
|
|
106
|
+
}
|
|
107
|
+
// An unreachable API host outranks whatever the CLI printed: a sandbox that
|
|
108
|
+
// withholds the network makes an authenticated CLI say "not logged in".
|
|
109
|
+
if (isNetworkFailure(`${spawned.stderr}\n${spawned.stdout}`)) {
|
|
110
|
+
return "network-unreachable";
|
|
111
|
+
}
|
|
112
|
+
return (await (options.reachable ?? probeHostReachable)(TARGET_API_HOST[target]))
|
|
113
|
+
? "unauthenticated"
|
|
114
|
+
: "network-unreachable";
|
|
106
115
|
}
|
|
107
116
|
finally {
|
|
108
117
|
if (deep) {
|
|
@@ -83,6 +83,11 @@ export function renderReviewResult(result) {
|
|
|
83
83
|
result.attempts?.some((attempt) => attempt.reason === "login-required")) {
|
|
84
84
|
lines.push("Run: agent-ops doctor --check-auth to verify target authentication.");
|
|
85
85
|
}
|
|
86
|
+
if (result.reason === "network-unreachable" ||
|
|
87
|
+
result.attempts?.some((attempt) => attempt.reason === "network-unreachable")) {
|
|
88
|
+
lines.push("The reviewer could not reach the network. This is not a login " +
|
|
89
|
+
"problem: run agent-ops outside the sandbox, or grant it escalated execution.");
|
|
90
|
+
}
|
|
86
91
|
// Evidence is pinned to the source it was produced from, so any edit to a
|
|
87
92
|
// changed file after the verifier ran — a doc rewritten by a later step
|
|
88
93
|
// counts — voids it. Without the next command the caller reads
|
|
@@ -130,10 +130,17 @@ never rewritten.
|
|
|
130
130
|
targets are permitted, `--yes` decides whether to spend money now.
|
|
131
131
|
|
|
132
132
|
Reviewer failures are classified conservatively from their output: recognizable
|
|
133
|
-
|
|
134
|
-
`
|
|
135
|
-
|
|
136
|
-
|
|
133
|
+
network errors (`ENOTFOUND`, `fetch failed`, `getaddrinfo` and similar) become
|
|
134
|
+
`network-unreachable`, authentication messages become `login-required`, quota
|
|
135
|
+
messages become `quota-exhausted`, and other non-zero exits become
|
|
136
|
+
`capability-unavailable`. A sandbox that withholds the network therefore is not
|
|
137
|
+
reported as a logged-out CLI: `network-unreachable` tells you to run outside the
|
|
138
|
+
sandbox instead of running `<target> login`. When a `--check-auth` probe or the
|
|
139
|
+
review preflight finds a target unauthenticated, a 2-second TCP connection to
|
|
140
|
+
that target's API host on port 443 (`cloudcode-pa.googleapis.com` for agy,
|
|
141
|
+
`api.openai.com` for codex, `api.anthropic.com` for claude) decides between the
|
|
142
|
+
two; it only refines the reported reason and never blocks a review. The review suggests
|
|
143
|
+
`doctor --check-auth` only when an authentication failure was detected. Confirm target authentication with:
|
|
137
144
|
|
|
138
145
|
```bash
|
|
139
146
|
agent-ops doctor # presence only: no tokens, no network
|
|
@@ -213,7 +220,13 @@ evidence. Give each editing conversation its own worktree instead:
|
|
|
213
220
|
another worktree is refused, naming that path. If the target moved, it rebases; a clean rebase whose own patch
|
|
214
221
|
is unchanged is re-verified before merging, and a conflict is reported with
|
|
215
222
|
the `refs/notes/agent-ops` intent of the work that landed first. The note
|
|
216
|
-
records every task of the worktree, subtasks indented under their parent
|
|
223
|
+
records every task of the worktree, subtasks indented under their parent,
|
|
224
|
+
and a `session: <id>` line naming the session that finished it. Finish
|
|
225
|
+
also resets that session's gate baseline to the merged source, so the
|
|
226
|
+
session can stop although the task now lives only in the note. As a
|
|
227
|
+
fallback, the gate lets an unattached session stop when the checkout is
|
|
228
|
+
clean and a note with its `session:` line sits within the newest 500
|
|
229
|
+
commits of `HEAD`; older merges need a one-time `allow-stop`.
|
|
217
230
|
- `worktree list`, `resume <name> --session <id>` and `remove <name>` manage
|
|
218
231
|
what is left; `remove --force` discards work and asks the user first.
|
|
219
232
|
`doctor` reports worktrees idle for more than seven days.
|
|
@@ -111,8 +111,15 @@ task ID 必須明確指定,且永遠使用該 task 的原始 criteria;不能
|
|
|
111
111
|
每次執行 review 仍需 `--yes`:init 的勾選決定「允許哪些目標」,
|
|
112
112
|
`--yes` 決定「現在是否要花錢」。
|
|
113
113
|
|
|
114
|
-
Reviewer
|
|
114
|
+
Reviewer 失敗會依輸出採保守分類:網路錯誤(`ENOTFOUND`、`fetch failed`、
|
|
115
|
+
`getaddrinfo` 等)為 `network-unreachable`,可辨識的認證訊息為 `login-required`,
|
|
115
116
|
quota 訊息為 `quota-exhausted`,其他非零退出則為 `capability-unavailable`。
|
|
117
|
+
因此沙盒擋住網路時不會被誤報成 CLI 未登入:`network-unreachable` 會提示改在
|
|
118
|
+
沙盒外執行,而不是執行 `<target> login`。當 `--check-auth` 探測或 review
|
|
119
|
+
preflight 判定目標未認證時,會再對該目標的 API 主機(agy 為
|
|
120
|
+
`cloudcode-pa.googleapis.com`、codex 為 `api.openai.com`、claude 為
|
|
121
|
+
`api.anthropic.com`)的 443 埠做 2 秒 TCP 連線測試,據此在兩種原因間擇一;
|
|
122
|
+
它只修正回報的原因,不會阻擋 review。
|
|
116
123
|
只有偵測到認證失敗時,review 才會建議執行 `doctor --check-auth`。
|
|
117
124
|
可用以下指令確認目標的認證狀態:
|
|
118
125
|
|
|
@@ -183,7 +190,12 @@ fingerprint,因此兩個對話修改同一個 checkout 會互相作廢對方
|
|
|
183
190
|
target 的 ref,checkout、檔案與未 commit 的變更都不會被動到。Target 若被其他
|
|
184
191
|
worktree checkout 則拒絕,並指出該路徑。Target 若已前進會先 rebase;沒有衝突且自身 patch 不變的 rebase 會先
|
|
185
192
|
重新驗證再合併;衝突則連同先合併那份工作在 `refs/notes/agent-ops` 的意圖一起
|
|
186
|
-
回報。Note 會記錄 worktree 的每個 task,subtask 縮排在其 parent
|
|
193
|
+
回報。Note 會記錄 worktree 的每個 task,subtask 縮排在其 parent 之下,並附上
|
|
194
|
+
一行 `session: <id>`,標明完成這次 finish 的 session。Finish 也會把該 session
|
|
195
|
+
的 gate baseline 重設為合併後的原始碼,所以即使 task 現在只存在於 note 裡,
|
|
196
|
+
session 仍可停止。作為後備,gate 在 checkout 乾淨、且 `HEAD` 最近 500 個 commit
|
|
197
|
+
內有帶該 `session:` 行的 note 時,會讓未附著 task 的 session 停止;更舊的合併需要
|
|
198
|
+
一次性的 `allow-stop`。
|
|
187
199
|
- `worktree list`、`resume <name> --session <id>` 與 `remove <name>` 管理剩下的
|
|
188
200
|
worktree;`remove --force` 會丟棄工作,執行前會先詢問使用者。`doctor` 會回報
|
|
189
201
|
閒置超過七天的 worktree。
|