@tokenfactory/acc-runner 0.29.4 → 0.33.0
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/cli.js +3 -2
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +9 -2
- package/dist/config.js.map +1 -1
- package/dist/doctor.d.ts +19 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +169 -8
- package/dist/doctor.js.map +1 -1
- package/dist/engines/claude-code.d.ts +9 -0
- package/dist/engines/claude-code.d.ts.map +1 -0
- package/dist/engines/claude-code.js +95 -0
- package/dist/engines/claude-code.js.map +1 -0
- package/dist/engines/codex.d.ts +27 -0
- package/dist/engines/codex.d.ts.map +1 -0
- package/dist/engines/codex.js +178 -0
- package/dist/engines/codex.js.map +1 -0
- package/dist/engines/registry.d.ts +18 -0
- package/dist/engines/registry.d.ts.map +1 -0
- package/dist/engines/registry.js +26 -0
- package/dist/engines/registry.js.map +1 -0
- package/dist/engines/types.d.ts +73 -0
- package/dist/engines/types.d.ts.map +1 -0
- package/dist/engines/types.js +2 -0
- package/dist/engines/types.js.map +1 -0
- package/dist/failure-classifier.d.ts +32 -1
- package/dist/failure-classifier.d.ts.map +1 -1
- package/dist/failure-classifier.js +193 -1
- package/dist/failure-classifier.js.map +1 -1
- package/dist/git.d.ts +10 -0
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +6 -0
- package/dist/git.js.map +1 -1
- package/dist/login.d.ts +17 -0
- package/dist/login.d.ts.map +1 -1
- package/dist/login.js +22 -1
- package/dist/login.js.map +1 -1
- package/dist/pr-body.d.ts +53 -0
- package/dist/pr-body.d.ts.map +1 -0
- package/dist/pr-body.js +154 -0
- package/dist/pr-body.js.map +1 -0
- package/dist/prepr-gate.d.ts +132 -2
- package/dist/prepr-gate.d.ts.map +1 -1
- package/dist/prepr-gate.js +260 -9
- package/dist/prepr-gate.js.map +1 -1
- package/dist/provider-auth.d.ts +120 -0
- package/dist/provider-auth.d.ts.map +1 -0
- package/dist/provider-auth.js +184 -0
- package/dist/provider-auth.js.map +1 -0
- package/dist/review-diff-guard.d.ts +12 -0
- package/dist/review-diff-guard.d.ts.map +1 -0
- package/dist/review-diff-guard.js +52 -0
- package/dist/review-diff-guard.js.map +1 -0
- package/dist/review-worktree-cleanup.d.ts +73 -0
- package/dist/review-worktree-cleanup.d.ts.map +1 -0
- package/dist/review-worktree-cleanup.js +103 -0
- package/dist/review-worktree-cleanup.js.map +1 -0
- package/dist/runtime/reviewer.d.ts.map +1 -1
- package/dist/runtime/reviewer.js +72 -29
- package/dist/runtime/reviewer.js.map +1 -1
- package/dist/runtime/worktree.d.ts.map +1 -1
- package/dist/runtime/worktree.js +8 -3
- package/dist/runtime/worktree.js.map +1 -1
- package/dist/task-runner.d.ts +1 -8
- package/dist/task-runner.d.ts.map +1 -1
- package/dist/task-runner.js +65 -66
- package/dist/task-runner.js.map +1 -1
- package/dist/watch.d.ts.map +1 -1
- package/dist/watch.js +30 -3
- package/dist/watch.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralised resolution of how the spawned
|
|
3
|
+
* `claude` CLI authenticates with Anthropic.
|
|
4
|
+
*
|
|
5
|
+
* Claude Code authenticates *strictly* via the `ANTHROPIC_API_KEY`
|
|
6
|
+
* environment variable (or an apiKeyHelper) — it has no `--api-key` flag;
|
|
7
|
+
* passing one fails with `error: unknown option '--api-key'`. So "passing
|
|
8
|
+
* the API key to claude" means making sure the variable is present in the
|
|
9
|
+
* spawned subprocess's environment.
|
|
10
|
+
*
|
|
11
|
+
* When ANTHROPIC_API_KEY is set the CLI bills against the API account, which
|
|
12
|
+
* has no per-session usage cap. When it is absent the CLI falls back to the
|
|
13
|
+
* operator's interactive OAuth session, which is subject to the 429 session
|
|
14
|
+
* caps this change exists to avoid.
|
|
15
|
+
*
|
|
16
|
+
* M4 (ENGINE-CODEX): this module was renamed anthropic-auth.ts →
|
|
17
|
+
* provider-auth.ts and extended to cover the OpenAI Codex CLI as well, which
|
|
18
|
+
* has TWO auth channels (see §0.1.5 of the multi-model plan): an `api-key`
|
|
19
|
+
* mode driven by `OPENAI_API_KEY` (keychain/env) and a `subscription` mode
|
|
20
|
+
* driven by a stored ChatGPT login session. The Anthropic helpers are
|
|
21
|
+
* unchanged (byte-identical) so existing call sites keep their behaviour.
|
|
22
|
+
*/
|
|
23
|
+
import os from "node:os";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import { RUNNER_KEYCHAIN_SERVICE } from "./types.js";
|
|
26
|
+
/** Trimmed ANTHROPIC_API_KEY from `env`, or "" when unset/blank. */
|
|
27
|
+
export function anthropicApiKey(env = process.env) {
|
|
28
|
+
return (env.ANTHROPIC_API_KEY ?? "").trim();
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Opt-in claude-config isolation. Even with ANTHROPIC_API_KEY
|
|
32
|
+
* set, the Claude CLI prefers a stored claude.ai OAuth login when one exists —
|
|
33
|
+
* the observed cause of 429 session caps on a key-configured runner. Pointing
|
|
34
|
+
* the spawned claude at an isolated CLAUDE_CONFIG_DIR (no stored login) leaves
|
|
35
|
+
* the env key as the only credential, forcing API-key auth. Opt-in via
|
|
36
|
+
* ACC_RUNNER_ISOLATE_CLAUDE_CONFIG because the behaviour needs live-runner
|
|
37
|
+
* validation before becoming default.
|
|
38
|
+
*/
|
|
39
|
+
export function claudeConfigIsolationEnabled(env = process.env) {
|
|
40
|
+
const v = (env.ACC_RUNNER_ISOLATE_CLAUDE_CONFIG ?? "").trim().toLowerCase();
|
|
41
|
+
return v === "1" || v === "true" || v === "yes" || v === "on";
|
|
42
|
+
}
|
|
43
|
+
/** Runner-owned claude config dir used when isolation is enabled. */
|
|
44
|
+
export function isolatedClaudeConfigDir(env = process.env) {
|
|
45
|
+
const override = (env.ACC_RUNNER_CLAUDE_CONFIG_DIR ?? "").trim();
|
|
46
|
+
return override || path.join(os.homedir(), ".acc-runner", "claude-config");
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* `"api-key"` when ANTHROPIC_API_KEY is set (non-blank), else
|
|
50
|
+
* `"interactive-session"`.
|
|
51
|
+
*/
|
|
52
|
+
export function resolveAuthMode(env = process.env) {
|
|
53
|
+
return anthropicApiKey(env) ? "api-key" : "interactive-session";
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Returns `base` with ANTHROPIC_API_KEY explicitly forwarded (trimmed) when
|
|
57
|
+
* set, so every claude spawn site authenticates identically regardless of how
|
|
58
|
+
* `base` was assembled. A passthrough of `base` when the key is absent — the
|
|
59
|
+
* CLI then uses the operator's interactive OAuth session.
|
|
60
|
+
*/
|
|
61
|
+
export function withAnthropicAuth(base = process.env) {
|
|
62
|
+
const key = anthropicApiKey(base);
|
|
63
|
+
if (!key)
|
|
64
|
+
return base;
|
|
65
|
+
const next = { ...base, ANTHROPIC_API_KEY: key };
|
|
66
|
+
// When isolation is opted in and the operator hasn't already
|
|
67
|
+
// pinned a config dir, point claude at a runner-owned dir so a stored
|
|
68
|
+
// claude.ai login can't shadow the API key. Never clobber an explicit value.
|
|
69
|
+
if (claudeConfigIsolationEnabled(base) &&
|
|
70
|
+
!(base.CLAUDE_CONFIG_DIR ?? "").trim()) {
|
|
71
|
+
next.CLAUDE_CONFIG_DIR = isolatedClaudeConfigDir(base);
|
|
72
|
+
}
|
|
73
|
+
return next;
|
|
74
|
+
}
|
|
75
|
+
/** One-line, human-readable description of an auth mode for banners/checks. */
|
|
76
|
+
export function describeAuthMode(mode) {
|
|
77
|
+
return mode === "api-key"
|
|
78
|
+
? "api-key (ANTHROPIC_API_KEY set — no session cap)"
|
|
79
|
+
: "interactive-session (no ANTHROPIC_API_KEY — subject to 429 session caps)";
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Precedence hint for the banner / doctor. Returns null when
|
|
83
|
+
* there's nothing to add (interactive-session mode is already self-explanatory
|
|
84
|
+
* via describeAuthMode). In api-key mode it tells the operator either that
|
|
85
|
+
* isolation is forcing the key, or — if not isolated — how to stop a claude.ai
|
|
86
|
+
* login from shadowing the key and causing 429s.
|
|
87
|
+
*/
|
|
88
|
+
export function authPrecedenceHint(env = process.env) {
|
|
89
|
+
if (resolveAuthMode(env) !== "api-key")
|
|
90
|
+
return null;
|
|
91
|
+
if (claudeConfigIsolationEnabled(env)) {
|
|
92
|
+
return `claude config isolated at ${isolatedClaudeConfigDir(env)} — API key forced`;
|
|
93
|
+
}
|
|
94
|
+
return ("if you still hit 429 session caps, a claude.ai login is shadowing the API key — " +
|
|
95
|
+
"run `claude /logout` or set ACC_RUNNER_ISOLATE_CLAUDE_CONFIG=1");
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The per-provider keychain account under the frozen `acc-runner` service.
|
|
99
|
+
* FROZEN-CONTRACT EXTENSION (Track-A note): this ADDS a namespaced account
|
|
100
|
+
* (`provider:openai`) alongside the existing `default` session entry — the
|
|
101
|
+
* session schema in keychain.ts is untouched, so a Track-A review only needs
|
|
102
|
+
* to bless the new account name, not a shape change. The value is the raw
|
|
103
|
+
* OPENAI_API_KEY string (never a JSON blob), so it round-trips through keytar
|
|
104
|
+
* without colliding with the StoredSession JSON at `default`.
|
|
105
|
+
*/
|
|
106
|
+
export const OPENAI_KEYCHAIN_ACCOUNT = "provider:openai";
|
|
107
|
+
/** Trimmed OPENAI_API_KEY from `env`, or "" when unset/blank. */
|
|
108
|
+
export function openaiApiKey(env = process.env) {
|
|
109
|
+
return (env.OPENAI_API_KEY ?? "").trim();
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Read the OPENAI_API_KEY from the OS keychain (per-provider account). Returns
|
|
113
|
+
* "" when absent, keytar is unavailable (headless Linux without libsecret), or
|
|
114
|
+
* the entry is empty. Async because keychain access is — the sync spawn path
|
|
115
|
+
* (`withOpenAiAuth`) only ever reads the env-present key, exactly like the
|
|
116
|
+
* Anthropic path; the keychain is consulted by startup/doctor which can await.
|
|
117
|
+
*/
|
|
118
|
+
export async function openaiApiKeyFromKeychain() {
|
|
119
|
+
try {
|
|
120
|
+
const mod = (await import("keytar"));
|
|
121
|
+
const k = mod.default ?? mod;
|
|
122
|
+
const raw = await k.getPassword(RUNNER_KEYCHAIN_SERVICE, OPENAI_KEYCHAIN_ACCOUNT);
|
|
123
|
+
return (raw ?? "").trim();
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return "";
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Resolve the `auth_preference` string into a typed preference. Accepts the
|
|
131
|
+
* env-string forms and the DB spelling; anything unknown/blank falls back to
|
|
132
|
+
* the `subscription_first` default.
|
|
133
|
+
*/
|
|
134
|
+
export function resolveCodexAuthPreference(raw) {
|
|
135
|
+
const v = (raw ?? "").trim().toLowerCase();
|
|
136
|
+
if (v === "api_key_first" || v === "api-key-first" || v === "api_key") {
|
|
137
|
+
return "api_key_first";
|
|
138
|
+
}
|
|
139
|
+
return "subscription_first";
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The auth mode a codex spawn should START in, given the preference and whether
|
|
143
|
+
* an api key is present. `subscription_first` uses subscription; `api_key_first`
|
|
144
|
+
* uses the key when one is available (else subscription). This is the FIRST
|
|
145
|
+
* attempt only — the spill to api-key on a `subscription_exhausted` cap-hit is
|
|
146
|
+
* driven by the (future, M5) task-runner orchestration, which re-invokes with
|
|
147
|
+
* mode "api-key" explicitly.
|
|
148
|
+
*/
|
|
149
|
+
export function initialCodexAuthMode(preference, hasApiKey) {
|
|
150
|
+
if (preference === "api_key_first")
|
|
151
|
+
return hasApiKey ? "api-key" : "subscription";
|
|
152
|
+
return "subscription";
|
|
153
|
+
}
|
|
154
|
+
/** One-line, human-readable description of a codex auth mode for banners/checks. */
|
|
155
|
+
export function describeCodexAuthMode(mode) {
|
|
156
|
+
return mode === "api-key"
|
|
157
|
+
? "api-key (OPENAI_API_KEY set — billed to the platform key, no session cap)"
|
|
158
|
+
: "subscription (ChatGPT login session — subject to plan session/usage caps)";
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Augment `base` for a codex spawn in the given auth mode:
|
|
162
|
+
* - "api-key": OPENAI_API_KEY is forwarded (trimmed) so codex bills the
|
|
163
|
+
* key. A passthrough when the key is absent — codex then
|
|
164
|
+
* errors on missing auth, surfaced as `auth_expired`.
|
|
165
|
+
* - "subscription": OPENAI_API_KEY is REMOVED from the environment so codex
|
|
166
|
+
* cannot silently bill the key and instead uses the stored
|
|
167
|
+
* ChatGPT login (the mirror of the claude config-isolation
|
|
168
|
+
* concern: a stray key must not shadow the subscription we
|
|
169
|
+
* deliberately chose to try first).
|
|
170
|
+
* Never mutates `base`; returns a shallow copy.
|
|
171
|
+
*/
|
|
172
|
+
export function withOpenAiAuth(mode, base = process.env) {
|
|
173
|
+
const next = { ...base };
|
|
174
|
+
if (mode === "api-key") {
|
|
175
|
+
const key = openaiApiKey(base);
|
|
176
|
+
if (key)
|
|
177
|
+
next.OPENAI_API_KEY = key;
|
|
178
|
+
return next;
|
|
179
|
+
}
|
|
180
|
+
// subscription: strip the key so codex uses the ChatGPT login session.
|
|
181
|
+
delete next.OPENAI_API_KEY;
|
|
182
|
+
return next;
|
|
183
|
+
}
|
|
184
|
+
//# sourceMappingURL=provider-auth.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"provider-auth.js","sourceRoot":"","sources":["../src/provider-auth.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True when a review diff is ZERO-LENGTH — empty or pure whitespace. A failed
|
|
3
|
+
* fetch / a head that never advanced past base yields an empty `git diff`, the
|
|
4
|
+
* literal infra signal. Deliberately narrow: any real text is a genuine change
|
|
5
|
+
* left for the reviewer. Mirrors api/_lib/review-request.ts.
|
|
6
|
+
*/
|
|
7
|
+
export declare function isEmptyReviewDiff(diff: string | null | undefined): boolean;
|
|
8
|
+
/** Normalize a diff for stable content-hashing. Mirrors review-request.ts. */
|
|
9
|
+
export declare function normalizeDiffForHash(diff: string | null | undefined): string;
|
|
10
|
+
/** hex sha256 of the normalized diff. Mirrors review-request.ts. */
|
|
11
|
+
export declare function hashReviewDiff(diff: string | null | undefined): string;
|
|
12
|
+
//# sourceMappingURL=review-diff-guard.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"review-diff-guard.d.ts","sourceRoot":"","sources":["../src/review-diff-guard.ts"],"names":[],"mappings":"AAiBA;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAE1E;AAED,8EAA8E;AAC9E,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,CAiB5E;AAED,oEAAoE;AACpE,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,CAEtE"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* V96 (R3) — runner-side zero-diff guard.
|
|
3
|
+
*
|
|
4
|
+
* The runner is the HOT review path: runtime/reviewer.ts materializes the exact
|
|
5
|
+
* PR head into a worktree and computes the diff from it. A zero-length diff there
|
|
6
|
+
* is an INFRA fault (a fetch that produced an incomplete tree, a head that never
|
|
7
|
+
* advanced past base) — never a reviewable PR. `isEmptyReviewDiff` lets the
|
|
8
|
+
* runtime take the fail-closed infra path (re-fetch once, then emit
|
|
9
|
+
* `task.review_fetch_failed` and post NO verdict) instead of spawning the
|
|
10
|
+
* reviewer against nothing and burning a review cycle.
|
|
11
|
+
*
|
|
12
|
+
* This mirrors api/_lib/review-request.ts byte-for-byte in behaviour; the runner
|
|
13
|
+
* is a standalone package and cannot import from api/_lib (the same convention as
|
|
14
|
+
* the duplicated PRICING / MODEL_ALIASES maps). Keep the two in sync.
|
|
15
|
+
*/
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
/**
|
|
18
|
+
* True when a review diff is ZERO-LENGTH — empty or pure whitespace. A failed
|
|
19
|
+
* fetch / a head that never advanced past base yields an empty `git diff`, the
|
|
20
|
+
* literal infra signal. Deliberately narrow: any real text is a genuine change
|
|
21
|
+
* left for the reviewer. Mirrors api/_lib/review-request.ts.
|
|
22
|
+
*/
|
|
23
|
+
export function isEmptyReviewDiff(diff) {
|
|
24
|
+
return (diff ?? "").trim().length === 0;
|
|
25
|
+
}
|
|
26
|
+
/** Normalize a diff for stable content-hashing. Mirrors review-request.ts. */
|
|
27
|
+
export function normalizeDiffForHash(diff) {
|
|
28
|
+
const text = (diff ?? "").replace(/\r\n/g, "\n");
|
|
29
|
+
const out = [];
|
|
30
|
+
for (const line of text.split("\n")) {
|
|
31
|
+
const trimmedEnd = line.replace(/[ \t]+$/, "");
|
|
32
|
+
if (/^index [0-9a-f]+\.\.[0-9a-f]+( [0-7]{6})?$/i.test(trimmedEnd))
|
|
33
|
+
continue;
|
|
34
|
+
const hunk = /^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@(.*)$/.exec(trimmedEnd);
|
|
35
|
+
if (hunk) {
|
|
36
|
+
const ctx = hunk[1].trim();
|
|
37
|
+
out.push(ctx ? `@@ ${ctx}` : "@@");
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
out.push(trimmedEnd);
|
|
41
|
+
}
|
|
42
|
+
while (out.length > 0 && out[0] === "")
|
|
43
|
+
out.shift();
|
|
44
|
+
while (out.length > 0 && out[out.length - 1] === "")
|
|
45
|
+
out.pop();
|
|
46
|
+
return out.join("\n");
|
|
47
|
+
}
|
|
48
|
+
/** hex sha256 of the normalized diff. Mirrors review-request.ts. */
|
|
49
|
+
export function hashReviewDiff(diff) {
|
|
50
|
+
return createHash("sha256").update(normalizeDiffForHash(diff), "utf8").digest("hex");
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=review-diff-guard.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"review-diff-guard.js","sourceRoot":"","sources":["../src/review-diff-guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEzC;;;;;GAKG;AACH,MAAM,UAAU,iBAAiB,CAAC,IAA+B;IAC/D,OAAO,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,MAAM,KAAK,CAAC,CAAC;AAC1C,CAAC;AAED,8EAA8E;AAC9E,MAAM,UAAU,oBAAoB,CAAC,IAA+B;IAClE,MAAM,IAAI,GAAG,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;IACjD,MAAM,GAAG,GAAa,EAAE,CAAC;IACzB,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;QACpC,MAAM,UAAU,GAAG,IAAI,CAAC,OAAO,CAAC,SAAS,EAAE,EAAE,CAAC,CAAC;QAC/C,IAAI,6CAA6C,CAAC,IAAI,CAAC,UAAU,CAAC;YAAE,SAAS;QAC7E,MAAM,IAAI,GAAG,0CAA0C,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;QACzE,IAAI,IAAI,EAAE,CAAC;YACT,MAAM,GAAG,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;YAC3B,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,GAAG,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;YACnC,SAAS;QACX,CAAC;QACD,GAAG,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;IACvB,CAAC;IACD,OAAO,GAAG,CAAC,MAAM,GAAG,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,KAAK,EAAE;QAAE,GAAG,CAAC,KAAK,EAAE,CAAC;IACpD,OAAO,GAAG,CAAC,MAAM,GAAG,CAAC,IAAI,GAAG,CAAC,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC,KAAK,EAAE;QAAE,GAAG,CAAC,GAAG,EAAE,CAAC;IAC/D,OAAO,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACxB,CAAC;AAED,oEAAoE;AACpE,MAAM,UAAU,cAAc,CAAC,IAA+B;IAC5D,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,oBAAoB,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AACvF,CAAC"}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* V96 (ADDED SCOPE, live incident PR #1063) — tolerant review-worktree teardown.
|
|
3
|
+
*
|
|
4
|
+
* Review runs on fallback runners crashed with
|
|
5
|
+
* runtime_threw ENOTEMPTY: directory not empty, rmdir .../review-<uuid>/design
|
|
6
|
+
* because the review-worktree teardown died removing an UNTRACKED directory
|
|
7
|
+
* (`design/`, the `Agent Auto/` design export) that `git worktree remove` won't
|
|
8
|
+
* delete and a plain `rmdir` can't (it's non-empty). That crash surfaced as a
|
|
9
|
+
* `reviewer_error` row, which DECREMENTS the per-PR review retry cap — an infra
|
|
10
|
+
* fault stealing review budget.
|
|
11
|
+
*
|
|
12
|
+
* Two fixes live here, both pure/injectable so they unit-test without a real FS:
|
|
13
|
+
*
|
|
14
|
+
* 1. `tolerantRemoveDir` — recursive + force removal that also RETRIES the
|
|
15
|
+
* transient races (`ENOTEMPTY`/`EBUSY`/`EPERM`/`EMFILE`) `fs.rm` can throw
|
|
16
|
+
* mid-recursion, and NEVER throws (a residual dir is logged, not fatal). This
|
|
17
|
+
* is the "recursive, tolerant cleanup" the incident calls for.
|
|
18
|
+
* 2. `isTeardownError` / `classifyTeardownFailure` — recognize a worktree-
|
|
19
|
+
* teardown failure so the caller can classify it as INFRA (fail-closed,
|
|
20
|
+
* re-broadcast) instead of a `reviewer_error` verdict that burns a retry.
|
|
21
|
+
*/
|
|
22
|
+
export interface TolerantRemoveDeps {
|
|
23
|
+
/**
|
|
24
|
+
* Remove a directory tree. Default: `fs.rm(dir, { recursive, force })`. The
|
|
25
|
+
* recursive+force flags handle untracked directories (`design/`, `Agent Auto/`)
|
|
26
|
+
* that `git worktree remove` leaves behind — the root cause of the ENOTEMPTY
|
|
27
|
+
* crash. Injectable so the unit suite can drive success / transient-then-success
|
|
28
|
+
* / persistent-failure without touching disk.
|
|
29
|
+
*/
|
|
30
|
+
rm?: (dir: string, opts: {
|
|
31
|
+
recursive: boolean;
|
|
32
|
+
force: boolean;
|
|
33
|
+
}) => Promise<void>;
|
|
34
|
+
/** Sleep between retries. Default: real setTimeout. Injectable (tests pass a no-op). */
|
|
35
|
+
sleep?: (ms: number) => Promise<void>;
|
|
36
|
+
/** Max retry attempts after the first (default 3). */
|
|
37
|
+
maxRetries?: number;
|
|
38
|
+
/** Base backoff between retries in ms (default 50). */
|
|
39
|
+
retryDelayMs?: number;
|
|
40
|
+
}
|
|
41
|
+
export interface TolerantRemoveResult {
|
|
42
|
+
removed: boolean;
|
|
43
|
+
attempts: number;
|
|
44
|
+
/** First-line of the last error when `removed === false` (log-safe). */
|
|
45
|
+
lastError?: string;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Recursively, forcefully, and TOLERANTLY remove a directory tree. Retries the
|
|
49
|
+
* transient FS races a recursive rm can hit (`ENOTEMPTY` when a child is written
|
|
50
|
+
* concurrently, `EBUSY`/`EPERM` on a locked handle) with a short backoff, and
|
|
51
|
+
* returns a result instead of throwing — a review teardown must never crash the
|
|
52
|
+
* reviewer. `force:true` already swallows ENOENT, so a missing dir is a clean
|
|
53
|
+
* `removed: true`.
|
|
54
|
+
*/
|
|
55
|
+
export declare function tolerantRemoveDir(dir: string, deps?: TolerantRemoveDeps): Promise<TolerantRemoveResult>;
|
|
56
|
+
/**
|
|
57
|
+
* True when an error is a worktree-teardown failure — a recursive-removal FS race
|
|
58
|
+
* (`ENOTEMPTY` etc.), a `git worktree remove` failure, or a bare `rmdir`/`unlink`
|
|
59
|
+
* failure. The distinguishing property: it originates in CLEANUP, not in forming a
|
|
60
|
+
* verdict. Matched on both the fs error code and the message text so a wrapped /
|
|
61
|
+
* re-thrown error (the incident's `runtime_threw: ...ENOTEMPTY... rmdir ...`) is
|
|
62
|
+
* still recognized.
|
|
63
|
+
*/
|
|
64
|
+
export declare function isTeardownError(err: unknown): boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Classify a review failure that MIGHT be a teardown crash. Returns `"infra"`
|
|
67
|
+
* when the error is a teardown failure (the caller must treat it as INFRA:
|
|
68
|
+
* fail-closed, re-broadcast, NEVER a `reviewer_error` verdict, NEVER decrement
|
|
69
|
+
* the retry cap) and `"review"` otherwise (a genuine reviewer failure that the
|
|
70
|
+
* existing error path owns).
|
|
71
|
+
*/
|
|
72
|
+
export declare function classifyTeardownFailure(err: unknown): "infra" | "review";
|
|
73
|
+
//# sourceMappingURL=review-worktree-cleanup.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"review-worktree-cleanup.d.ts","sourceRoot":"","sources":["../src/review-worktree-cleanup.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAYH,MAAM,WAAW,kBAAkB;IACjC;;;;;;OAMG;IACH,EAAE,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE;QAAE,SAAS,EAAE,OAAO,CAAC;QAAC,KAAK,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAClF,wFAAwF;IACxF,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACtC,sDAAsD;IACtD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,uDAAuD;IACvD,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,oBAAoB;IACnC,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,wEAAwE;IACxE,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAUD;;;;;;;GAOG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,MAAM,EACX,IAAI,GAAE,kBAAuB,GAC5B,OAAO,CAAC,oBAAoB,CAAC,CA0B/B;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAcrD;AAED;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,GAAG,QAAQ,CAExE"}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* V96 (ADDED SCOPE, live incident PR #1063) — tolerant review-worktree teardown.
|
|
3
|
+
*
|
|
4
|
+
* Review runs on fallback runners crashed with
|
|
5
|
+
* runtime_threw ENOTEMPTY: directory not empty, rmdir .../review-<uuid>/design
|
|
6
|
+
* because the review-worktree teardown died removing an UNTRACKED directory
|
|
7
|
+
* (`design/`, the `Agent Auto/` design export) that `git worktree remove` won't
|
|
8
|
+
* delete and a plain `rmdir` can't (it's non-empty). That crash surfaced as a
|
|
9
|
+
* `reviewer_error` row, which DECREMENTS the per-PR review retry cap — an infra
|
|
10
|
+
* fault stealing review budget.
|
|
11
|
+
*
|
|
12
|
+
* Two fixes live here, both pure/injectable so they unit-test without a real FS:
|
|
13
|
+
*
|
|
14
|
+
* 1. `tolerantRemoveDir` — recursive + force removal that also RETRIES the
|
|
15
|
+
* transient races (`ENOTEMPTY`/`EBUSY`/`EPERM`/`EMFILE`) `fs.rm` can throw
|
|
16
|
+
* mid-recursion, and NEVER throws (a residual dir is logged, not fatal). This
|
|
17
|
+
* is the "recursive, tolerant cleanup" the incident calls for.
|
|
18
|
+
* 2. `isTeardownError` / `classifyTeardownFailure` — recognize a worktree-
|
|
19
|
+
* teardown failure so the caller can classify it as INFRA (fail-closed,
|
|
20
|
+
* re-broadcast) instead of a `reviewer_error` verdict that burns a retry.
|
|
21
|
+
*/
|
|
22
|
+
/** Node fs error codes that a recursive dir removal can hit transiently. */
|
|
23
|
+
const RETRYABLE_FS_CODES = new Set([
|
|
24
|
+
"ENOTEMPTY",
|
|
25
|
+
"EBUSY",
|
|
26
|
+
"EPERM",
|
|
27
|
+
"EMFILE",
|
|
28
|
+
"ENFILE",
|
|
29
|
+
"EACCES",
|
|
30
|
+
]);
|
|
31
|
+
function fsErrCode(err) {
|
|
32
|
+
return String(err?.code ?? "");
|
|
33
|
+
}
|
|
34
|
+
function defaultSleep(ms) {
|
|
35
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Recursively, forcefully, and TOLERANTLY remove a directory tree. Retries the
|
|
39
|
+
* transient FS races a recursive rm can hit (`ENOTEMPTY` when a child is written
|
|
40
|
+
* concurrently, `EBUSY`/`EPERM` on a locked handle) with a short backoff, and
|
|
41
|
+
* returns a result instead of throwing — a review teardown must never crash the
|
|
42
|
+
* reviewer. `force:true` already swallows ENOENT, so a missing dir is a clean
|
|
43
|
+
* `removed: true`.
|
|
44
|
+
*/
|
|
45
|
+
export async function tolerantRemoveDir(dir, deps = {}) {
|
|
46
|
+
const maxRetries = deps.maxRetries ?? 3;
|
|
47
|
+
const retryDelayMs = deps.retryDelayMs ?? 50;
|
|
48
|
+
const sleep = deps.sleep ?? defaultSleep;
|
|
49
|
+
const rm = deps.rm ??
|
|
50
|
+
(async (d, opts) => {
|
|
51
|
+
const fs = await import("node:fs/promises");
|
|
52
|
+
await fs.rm(d, opts);
|
|
53
|
+
});
|
|
54
|
+
let lastError;
|
|
55
|
+
for (let attempt = 1; attempt <= maxRetries + 1; attempt++) {
|
|
56
|
+
try {
|
|
57
|
+
await rm(dir, { recursive: true, force: true });
|
|
58
|
+
return { removed: true, attempts: attempt };
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
lastError = err?.message?.split("\n")[0] ?? String(err);
|
|
62
|
+
const code = fsErrCode(err);
|
|
63
|
+
if (!RETRYABLE_FS_CODES.has(code) || attempt > maxRetries) {
|
|
64
|
+
return { removed: false, attempts: attempt, lastError };
|
|
65
|
+
}
|
|
66
|
+
await sleep(retryDelayMs * attempt);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { removed: false, attempts: maxRetries + 1, lastError };
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* True when an error is a worktree-teardown failure — a recursive-removal FS race
|
|
73
|
+
* (`ENOTEMPTY` etc.), a `git worktree remove` failure, or a bare `rmdir`/`unlink`
|
|
74
|
+
* failure. The distinguishing property: it originates in CLEANUP, not in forming a
|
|
75
|
+
* verdict. Matched on both the fs error code and the message text so a wrapped /
|
|
76
|
+
* re-thrown error (the incident's `runtime_threw: ...ENOTEMPTY... rmdir ...`) is
|
|
77
|
+
* still recognized.
|
|
78
|
+
*/
|
|
79
|
+
export function isTeardownError(err) {
|
|
80
|
+
const code = fsErrCode(err);
|
|
81
|
+
if (RETRYABLE_FS_CODES.has(code) || code === "ENOENT") {
|
|
82
|
+
// ENOENT alone isn't a teardown failure, but with rmdir/worktree text it is.
|
|
83
|
+
}
|
|
84
|
+
if (RETRYABLE_FS_CODES.has(code))
|
|
85
|
+
return true;
|
|
86
|
+
const msg = (err?.message ?? String(err)).toLowerCase();
|
|
87
|
+
return (/\brmdir\b/.test(msg) ||
|
|
88
|
+
/directory not empty/.test(msg) ||
|
|
89
|
+
/enotempty/.test(msg) ||
|
|
90
|
+
/worktree (?:remove|prune)/.test(msg) ||
|
|
91
|
+
/review-[0-9a-f-]+/.test(msg));
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Classify a review failure that MIGHT be a teardown crash. Returns `"infra"`
|
|
95
|
+
* when the error is a teardown failure (the caller must treat it as INFRA:
|
|
96
|
+
* fail-closed, re-broadcast, NEVER a `reviewer_error` verdict, NEVER decrement
|
|
97
|
+
* the retry cap) and `"review"` otherwise (a genuine reviewer failure that the
|
|
98
|
+
* existing error path owns).
|
|
99
|
+
*/
|
|
100
|
+
export function classifyTeardownFailure(err) {
|
|
101
|
+
return isTeardownError(err) ? "infra" : "review";
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=review-worktree-cleanup.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"review-worktree-cleanup.js","sourceRoot":"","sources":["../src/review-worktree-cleanup.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"reviewer.d.ts","sourceRoot":"","sources":["../../src/runtime/reviewer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,EAAS,KAAK,iBAAiB,EAAE,MAAM,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"reviewer.d.ts","sourceRoot":"","sources":["../../src/runtime/reviewer.ts"],"names":[],"mappings":"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"}
|
package/dist/runtime/reviewer.js
CHANGED
|
@@ -15,12 +15,14 @@
|
|
|
15
15
|
* cron's poll-merge loop can advance.
|
|
16
16
|
*/
|
|
17
17
|
import { execa } from "execa";
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import { withAnthropicAuth } from "../anthropic-auth.js";
|
|
18
|
+
import { priceUsdCents, normalizeUsage, } from "../cost-pricing.js";
|
|
19
|
+
import { extractResetTime } from "../failure-classifier.js";
|
|
21
20
|
import { buildEnv } from "../bin-resolve.js";
|
|
21
|
+
import { getEngine } from "../engines/registry.js";
|
|
22
22
|
import { ensureRepoClone } from "./clone-cache.js";
|
|
23
23
|
import { prepareReviewWorktree, ReviewFetchError } from "./worktree.js";
|
|
24
|
+
import { isEmptyReviewDiff } from "../review-diff-guard.js";
|
|
25
|
+
import { classifyTeardownFailure } from "../review-worktree-cleanup.js";
|
|
24
26
|
// Byte-identical to prompts/reviewer-agent.md. The runner does not have
|
|
25
27
|
// the markdown file at runtime (the package ships without prompts/) so
|
|
26
28
|
// the prompt is embedded. Keep this string in sync with the markdown
|
|
@@ -327,25 +329,17 @@ async function emitReviewFetchFailed(supabase, assignment, err) {
|
|
|
327
329
|
process.stderr.write(`[acc-runner] task.review_fetch_failed emit failed: ${logErr.message}\n`);
|
|
328
330
|
}
|
|
329
331
|
}
|
|
332
|
+
// M2 (ENGINE-EXTRACT): the coding engine the reviewer drives — the default
|
|
333
|
+
// `claude-code` engine. spawn/argv/result-parse/failure-class behavior is
|
|
334
|
+
// byte-identical to the pre-refactor inline logic now owned by
|
|
335
|
+
// src/engines/claude-code.ts. buildEnv() is the reviewer's base env (mirrors
|
|
336
|
+
// v0.74-B): the engine forwards ANTHROPIC_API_KEY over it when set.
|
|
337
|
+
const engine = getEngine();
|
|
330
338
|
function defaultSpawnClaude(modelId) {
|
|
331
|
-
|
|
332
|
-
const trimmed = (modelId ?? "").trim();
|
|
333
|
-
if (trimmed)
|
|
334
|
-
args.push("--model", trimmed);
|
|
335
|
-
// v0.74-B: forward ANTHROPIC_API_KEY (trimmed) when set so the reviewer
|
|
336
|
-
// claude authenticates via the API account (no per-session cap) rather than
|
|
337
|
-
// the interactive OAuth session. claude has no --api-key flag; the env var
|
|
338
|
-
// is the only auth channel. See src/anthropic-auth.ts.
|
|
339
|
-
return execa("claude", args, {
|
|
340
|
-
stdin: "pipe",
|
|
341
|
-
stdout: "pipe",
|
|
342
|
-
stderr: "pipe",
|
|
343
|
-
reject: false,
|
|
344
|
-
env: withAnthropicAuth(buildEnv()),
|
|
345
|
-
});
|
|
339
|
+
return engine.spawn({ modelId, baseEnv: buildEnv() });
|
|
346
340
|
}
|
|
347
341
|
function parseEnvelope(stdout, fallbackModel) {
|
|
348
|
-
const parsed =
|
|
342
|
+
const parsed = engine.parseResult(stdout);
|
|
349
343
|
if (!parsed) {
|
|
350
344
|
return { result: stdout.trim(), session_id: null, cost_usd: 0, model: fallbackModel };
|
|
351
345
|
}
|
|
@@ -354,7 +348,7 @@ function parseEnvelope(stdout, fallbackModel) {
|
|
|
354
348
|
const cents = priceUsdCents(model, usage);
|
|
355
349
|
return {
|
|
356
350
|
result: parsed.result ?? "",
|
|
357
|
-
// any-allowed:
|
|
351
|
+
// any-allowed: engine.parseResult keeps additional fields on the
|
|
358
352
|
// record; session_id isn't on the typed shape but appears in
|
|
359
353
|
// every observed `--output-format=json` envelope from Claude CLI.
|
|
360
354
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -378,13 +372,23 @@ export async function runReview(assignment, deps) {
|
|
|
378
372
|
outcome = await driveReview(assignment, deps, fetchMeta, prepareTree, spawnFn);
|
|
379
373
|
}
|
|
380
374
|
catch (err) {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
375
|
+
// V96 (ADDED SCOPE): a worktree teardown crash that escaped driveReview
|
|
376
|
+
// (ENOTEMPTY / rmdir on an untracked dir — PR #1063) is INFRA, never a
|
|
377
|
+
// review verdict. Treat it like a fetch failure: post NO verdict so R0's
|
|
378
|
+
// re-broadcast reassigns it, instead of a reviewer_error that burns a retry.
|
|
379
|
+
if (classifyTeardownFailure(err) === "infra") {
|
|
380
|
+
process.stderr.write(`[acc-runner] review teardown crash (infra, no verdict): ${err.message?.slice(0, 200)}\n`);
|
|
381
|
+
outcome = null;
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
outcome = {
|
|
385
|
+
decision: "reviewer_error",
|
|
386
|
+
reasons: [`runtime_threw: ${err.message?.slice(0, 200)}`],
|
|
387
|
+
confidence: 0,
|
|
388
|
+
session_id: null,
|
|
389
|
+
cost_usd: 0,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
388
392
|
}
|
|
389
393
|
// R0-D: fail-closed. driveReview returns null when it could not materialize
|
|
390
394
|
// the exact PR head — `task.review_fetch_failed` was already emitted there.
|
|
@@ -462,6 +466,12 @@ async function driveReview(assignment, deps, fetchMeta, prepareTree, spawnFn) {
|
|
|
462
466
|
// R0-D-REVIEWER-WORKSPACE-INTEGRITY: materialize the EXACT PR head before
|
|
463
467
|
// forming any verdict, or fail closed. On failure we post NO verdict and emit
|
|
464
468
|
// `task.review_fetch_failed`; R0's re-broadcast reassigns to a healthy runner.
|
|
469
|
+
//
|
|
470
|
+
// V96 (R3) ZERO-DIFF GUARD: an empty diff off the materialized head is an INFRA
|
|
471
|
+
// fault (an incomplete fetch, a head that never advanced). Re-fetch ONCE (a
|
|
472
|
+
// fresh tree) before giving up; if it is STILL empty, fail closed exactly like
|
|
473
|
+
// a fetch failure — post NO verdict, emit `task.review_fetch_failed` — so the
|
|
474
|
+
// reviewer is never spawned against nothing and no review cycle is burned.
|
|
465
475
|
let tree;
|
|
466
476
|
try {
|
|
467
477
|
tree = await prepareTree({
|
|
@@ -469,12 +479,45 @@ async function driveReview(assignment, deps, fetchMeta, prepareTree, spawnFn) {
|
|
|
469
479
|
reviewId: assignment.review_id,
|
|
470
480
|
prNumber: assignment.pr_number,
|
|
471
481
|
});
|
|
482
|
+
if (isEmptyReviewDiff(tree.diff)) {
|
|
483
|
+
await tree.cleanup().catch(() => undefined);
|
|
484
|
+
tree = await prepareTree({
|
|
485
|
+
repo,
|
|
486
|
+
reviewId: assignment.review_id,
|
|
487
|
+
prNumber: assignment.pr_number,
|
|
488
|
+
});
|
|
489
|
+
if (isEmptyReviewDiff(tree.diff)) {
|
|
490
|
+
await tree.cleanup().catch(() => undefined);
|
|
491
|
+
const emptyErr = new ReviewFetchError({
|
|
492
|
+
event: "task.review_fetch_failed",
|
|
493
|
+
prNumber: assignment.pr_number,
|
|
494
|
+
expectedHeadSha: tree.headSha,
|
|
495
|
+
reason: "review request carried a zero-length diff after one re-fetch (infra fault) — " +
|
|
496
|
+
"not sent to the reviewer, no review cycle consumed",
|
|
497
|
+
});
|
|
498
|
+
await emitReviewFetchFailed(deps.supabase, assignment, emptyErr);
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
472
502
|
}
|
|
473
503
|
catch (err) {
|
|
474
504
|
if (err instanceof ReviewFetchError) {
|
|
475
505
|
await emitReviewFetchFailed(deps.supabase, assignment, err);
|
|
476
506
|
return null;
|
|
477
507
|
}
|
|
508
|
+
// V96 (ADDED SCOPE): a worktree TEARDOWN failure (ENOTEMPTY / rmdir on an
|
|
509
|
+
// untracked dir, PR #1063) is INFRA, never a review verdict. Fail closed
|
|
510
|
+
// like a fetch failure — post no verdict, re-broadcast — instead of letting
|
|
511
|
+
// it become a reviewer_error that decrements the retry cap.
|
|
512
|
+
if (classifyTeardownFailure(err) === "infra") {
|
|
513
|
+
await emitReviewFetchFailed(deps.supabase, assignment, new ReviewFetchError({
|
|
514
|
+
event: "task.review_fetch_failed",
|
|
515
|
+
prNumber: assignment.pr_number,
|
|
516
|
+
expectedHeadSha: "",
|
|
517
|
+
reason: `review worktree teardown failed (infra): ${err.message?.split("\n")[0] ?? String(err)}`,
|
|
518
|
+
}));
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
478
521
|
throw err;
|
|
479
522
|
}
|
|
480
523
|
try {
|
|
@@ -515,7 +558,7 @@ async function runClaudeAndDecide(prompt, policy, deps, spawnFn) {
|
|
|
515
558
|
// point retrying before then). A brownout with NO reset time is retried
|
|
516
559
|
// in-process with exponential backoff, bounded by classifyCapacityRetry, so
|
|
517
560
|
// a momentary blip recovers without pausing the whole runner.
|
|
518
|
-
const classified =
|
|
561
|
+
const classified = engine.classifyFailure(invokeResult.exitCode, invokeResult.stderr, invokeResult.stdout, invokeResult.durationMs);
|
|
519
562
|
const isCapacity = classified.class === "capacity_exhausted" ||
|
|
520
563
|
classified.class === "claude_unavailable";
|
|
521
564
|
if (isCapacity) {
|
|
@@ -555,7 +598,7 @@ async function runClaudeAndDecide(prompt, policy, deps, spawnFn) {
|
|
|
555
598
|
// output, no 429/auth/env signal) is also treated as capacity rather than
|
|
556
599
|
// reviewer_error — the host is fine but claude is momentarily unreachable,
|
|
557
600
|
// so the review should not consume the per-PR retry cap.
|
|
558
|
-
const classified =
|
|
601
|
+
const classified = engine.classifyFailure(exitCode, claudeStderr, stdout, durationMs);
|
|
559
602
|
if (classified.class === "capacity_exhausted" ||
|
|
560
603
|
classified.class === "claude_unavailable") {
|
|
561
604
|
const resumeMs = extractResetTime(`${claudeStderr}\n${stdout}`);
|