@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.
Files changed (71) hide show
  1. package/dist/cli.js +3 -2
  2. package/dist/cli.js.map +1 -1
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +9 -2
  5. package/dist/config.js.map +1 -1
  6. package/dist/doctor.d.ts +19 -0
  7. package/dist/doctor.d.ts.map +1 -1
  8. package/dist/doctor.js +169 -8
  9. package/dist/doctor.js.map +1 -1
  10. package/dist/engines/claude-code.d.ts +9 -0
  11. package/dist/engines/claude-code.d.ts.map +1 -0
  12. package/dist/engines/claude-code.js +95 -0
  13. package/dist/engines/claude-code.js.map +1 -0
  14. package/dist/engines/codex.d.ts +27 -0
  15. package/dist/engines/codex.d.ts.map +1 -0
  16. package/dist/engines/codex.js +178 -0
  17. package/dist/engines/codex.js.map +1 -0
  18. package/dist/engines/registry.d.ts +18 -0
  19. package/dist/engines/registry.d.ts.map +1 -0
  20. package/dist/engines/registry.js +26 -0
  21. package/dist/engines/registry.js.map +1 -0
  22. package/dist/engines/types.d.ts +73 -0
  23. package/dist/engines/types.d.ts.map +1 -0
  24. package/dist/engines/types.js +2 -0
  25. package/dist/engines/types.js.map +1 -0
  26. package/dist/failure-classifier.d.ts +32 -1
  27. package/dist/failure-classifier.d.ts.map +1 -1
  28. package/dist/failure-classifier.js +193 -1
  29. package/dist/failure-classifier.js.map +1 -1
  30. package/dist/git.d.ts +10 -0
  31. package/dist/git.d.ts.map +1 -1
  32. package/dist/git.js +6 -0
  33. package/dist/git.js.map +1 -1
  34. package/dist/login.d.ts +17 -0
  35. package/dist/login.d.ts.map +1 -1
  36. package/dist/login.js +22 -1
  37. package/dist/login.js.map +1 -1
  38. package/dist/pr-body.d.ts +53 -0
  39. package/dist/pr-body.d.ts.map +1 -0
  40. package/dist/pr-body.js +154 -0
  41. package/dist/pr-body.js.map +1 -0
  42. package/dist/prepr-gate.d.ts +132 -2
  43. package/dist/prepr-gate.d.ts.map +1 -1
  44. package/dist/prepr-gate.js +260 -9
  45. package/dist/prepr-gate.js.map +1 -1
  46. package/dist/provider-auth.d.ts +120 -0
  47. package/dist/provider-auth.d.ts.map +1 -0
  48. package/dist/provider-auth.js +184 -0
  49. package/dist/provider-auth.js.map +1 -0
  50. package/dist/review-diff-guard.d.ts +12 -0
  51. package/dist/review-diff-guard.d.ts.map +1 -0
  52. package/dist/review-diff-guard.js +52 -0
  53. package/dist/review-diff-guard.js.map +1 -0
  54. package/dist/review-worktree-cleanup.d.ts +73 -0
  55. package/dist/review-worktree-cleanup.d.ts.map +1 -0
  56. package/dist/review-worktree-cleanup.js +103 -0
  57. package/dist/review-worktree-cleanup.js.map +1 -0
  58. package/dist/runtime/reviewer.d.ts.map +1 -1
  59. package/dist/runtime/reviewer.js +72 -29
  60. package/dist/runtime/reviewer.js.map +1 -1
  61. package/dist/runtime/worktree.d.ts.map +1 -1
  62. package/dist/runtime/worktree.js +8 -3
  63. package/dist/runtime/worktree.js.map +1 -1
  64. package/dist/task-runner.d.ts +1 -8
  65. package/dist/task-runner.d.ts.map +1 -1
  66. package/dist/task-runner.js +65 -66
  67. package/dist/task-runner.js.map +1 -1
  68. package/dist/watch.d.ts.map +1 -1
  69. package/dist/watch.js +30 -3
  70. package/dist/watch.js.map +1 -1
  71. 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":"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"}
1
+ {"version":3,"file":"reviewer.d.ts","sourceRoot":"","sources":["../../src/runtime/reviewer.ts"],"names":[],"mappings":"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"}
@@ -15,12 +15,14 @@
15
15
  * cron's poll-merge loop can advance.
16
16
  */
17
17
  import { execa } from "execa";
18
- import { parseClaudeJson, priceUsdCents, normalizeUsage, } from "../cost-pricing.js";
19
- import { classifyClaudeExit, extractResetTime } from "../failure-classifier.js";
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
- const args = ["--print", "--dangerously-skip-permissions", "--output-format=json"];
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 = parseClaudeJson(stdout);
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: parseClaudeJson keeps additional fields on the
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
- outcome = {
382
- decision: "reviewer_error",
383
- reasons: [`runtime_threw: ${err.message?.slice(0, 200)}`],
384
- confidence: 0,
385
- session_id: null,
386
- cost_usd: 0,
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 = classifyClaudeExit(invokeResult.exitCode, invokeResult.stderr, invokeResult.stdout, invokeResult.durationMs);
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 = classifyClaudeExit(exitCode, claudeStderr, stdout, durationMs);
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}`);