@tokenfactory/acc-runner 0.44.1 → 0.44.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -1
- package/dist/bin-resolve.d.ts +39 -3
- package/dist/bin-resolve.d.ts.map +1 -1
- package/dist/bin-resolve.js +176 -12
- package/dist/bin-resolve.js.map +1 -1
- package/dist/build-fingerprint.d.ts +66 -0
- package/dist/build-fingerprint.d.ts.map +1 -0
- package/dist/build-fingerprint.js +70 -0
- package/dist/build-fingerprint.js.map +1 -0
- package/dist/build-info.generated.d.ts +3 -0
- package/dist/build-info.generated.d.ts.map +1 -0
- package/dist/build-info.generated.js +3 -0
- package/dist/build-info.generated.js.map +1 -0
- package/dist/capacity/resume-controller.d.ts +5 -3
- package/dist/capacity/resume-controller.d.ts.map +1 -1
- package/dist/capacity/resume-controller.js +9 -0
- package/dist/capacity/resume-controller.js.map +1 -1
- package/dist/chat-delegation-guard.d.ts +23 -0
- package/dist/chat-delegation-guard.d.ts.map +1 -0
- package/dist/chat-delegation-guard.js +62 -0
- package/dist/chat-delegation-guard.js.map +1 -0
- package/dist/chat-lane.d.ts +17 -6
- package/dist/chat-lane.d.ts.map +1 -1
- package/dist/chat-lane.js +29 -8
- package/dist/chat-lane.js.map +1 -1
- package/dist/companion-identity.d.ts +37 -0
- package/dist/companion-identity.d.ts.map +1 -1
- package/dist/companion-identity.js +98 -29
- package/dist/companion-identity.js.map +1 -1
- package/dist/companion-run.d.ts +1 -50
- package/dist/companion-run.d.ts.map +1 -1
- package/dist/companion-run.js +48 -105
- package/dist/companion-run.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +14 -3
- package/dist/config.js.map +1 -1
- package/dist/conformance.d.ts +49 -0
- package/dist/conformance.d.ts.map +1 -0
- package/dist/conformance.js +121 -0
- package/dist/conformance.js.map +1 -0
- package/dist/cost-pricing.d.ts +46 -0
- package/dist/cost-pricing.d.ts.map +1 -1
- package/dist/cost-pricing.js +58 -1
- package/dist/cost-pricing.js.map +1 -1
- package/dist/credential-identity.d.ts +70 -0
- package/dist/credential-identity.d.ts.map +1 -0
- package/dist/credential-identity.js +128 -0
- package/dist/credential-identity.js.map +1 -0
- package/dist/doctor.d.ts +33 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +284 -44
- package/dist/doctor.js.map +1 -1
- package/dist/engines/chat-stream.d.ts +71 -1
- package/dist/engines/chat-stream.d.ts.map +1 -1
- package/dist/engines/chat-stream.js +126 -8
- package/dist/engines/chat-stream.js.map +1 -1
- package/dist/engines/claude-code-chat.d.ts +11 -1
- package/dist/engines/claude-code-chat.d.ts.map +1 -1
- package/dist/engines/claude-code-chat.js +69 -5
- package/dist/engines/claude-code-chat.js.map +1 -1
- package/dist/engines/claude-code.d.ts.map +1 -1
- package/dist/engines/claude-code.js +30 -6
- package/dist/engines/claude-code.js.map +1 -1
- package/dist/engines/codex.d.ts.map +1 -1
- package/dist/engines/codex.js +18 -5
- package/dist/engines/codex.js.map +1 -1
- package/dist/engines/registry.d.ts +26 -0
- package/dist/engines/registry.d.ts.map +1 -1
- package/dist/engines/registry.js +37 -0
- package/dist/engines/registry.js.map +1 -1
- package/dist/engines/types.d.ts +5 -0
- package/dist/engines/types.d.ts.map +1 -1
- package/dist/failure-classifier.d.ts +1 -1
- package/dist/failure-classifier.d.ts.map +1 -1
- package/dist/failure-classifier.js +65 -12
- package/dist/failure-classifier.js.map +1 -1
- package/dist/gh.d.ts +15 -0
- package/dist/gh.d.ts.map +1 -1
- package/dist/gh.js +18 -0
- package/dist/gh.js.map +1 -1
- package/dist/git.d.ts +6 -0
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +27 -1
- package/dist/git.js.map +1 -1
- package/dist/keychain.d.ts +17 -0
- package/dist/keychain.d.ts.map +1 -1
- package/dist/keychain.js +30 -0
- package/dist/keychain.js.map +1 -1
- package/dist/loc-advisory.d.ts +70 -0
- package/dist/loc-advisory.d.ts.map +1 -0
- package/dist/loc-advisory.js +196 -0
- package/dist/loc-advisory.js.map +1 -0
- package/dist/locks/claim-lock-policy.d.ts +67 -0
- package/dist/locks/claim-lock-policy.d.ts.map +1 -0
- package/dist/locks/claim-lock-policy.js +132 -0
- package/dist/locks/claim-lock-policy.js.map +1 -0
- package/dist/login.d.ts +59 -2
- package/dist/login.d.ts.map +1 -1
- package/dist/login.js +87 -7
- package/dist/login.js.map +1 -1
- package/dist/mcp-spawn.d.ts +54 -0
- package/dist/mcp-spawn.d.ts.map +1 -1
- package/dist/mcp-spawn.js +88 -4
- package/dist/mcp-spawn.js.map +1 -1
- package/dist/migration-seq-guard.d.ts +28 -1
- package/dist/migration-seq-guard.d.ts.map +1 -1
- package/dist/migration-seq-guard.js +111 -6
- package/dist/migration-seq-guard.js.map +1 -1
- package/dist/output-diagnostics.d.ts +15 -0
- package/dist/output-diagnostics.d.ts.map +1 -0
- package/dist/output-diagnostics.js +95 -0
- package/dist/output-diagnostics.js.map +1 -0
- package/dist/pr-open-guard.d.ts +67 -0
- package/dist/pr-open-guard.d.ts.map +1 -0
- package/dist/pr-open-guard.js +79 -0
- package/dist/pr-open-guard.js.map +1 -0
- package/dist/prepr-gate.d.ts +229 -1
- package/dist/prepr-gate.d.ts.map +1 -1
- package/dist/prepr-gate.js +406 -3
- package/dist/prepr-gate.js.map +1 -1
- package/dist/program.d.ts.map +1 -1
- package/dist/program.js +30 -5
- package/dist/program.js.map +1 -1
- package/dist/provider-auth.d.ts +8 -0
- package/dist/provider-auth.d.ts.map +1 -1
- package/dist/provider-auth.js +10 -0
- package/dist/provider-auth.js.map +1 -1
- package/dist/runtime/quarantine.d.ts +11 -0
- package/dist/runtime/quarantine.d.ts.map +1 -1
- package/dist/runtime/quarantine.js +16 -1
- package/dist/runtime/quarantine.js.map +1 -1
- package/dist/runtime/reviewer.d.ts +90 -2
- package/dist/runtime/reviewer.d.ts.map +1 -1
- package/dist/runtime/reviewer.js +547 -46
- package/dist/runtime/reviewer.js.map +1 -1
- package/dist/runtime/running-build.d.ts +24 -0
- package/dist/runtime/running-build.d.ts.map +1 -0
- package/dist/runtime/running-build.js +62 -0
- package/dist/runtime/running-build.js.map +1 -0
- package/dist/runtime/worktree.d.ts +45 -0
- package/dist/runtime/worktree.d.ts.map +1 -1
- package/dist/runtime/worktree.js +174 -5
- package/dist/runtime/worktree.js.map +1 -1
- package/dist/task-runner.d.ts +126 -5
- package/dist/task-runner.d.ts.map +1 -1
- package/dist/task-runner.js +1219 -127
- package/dist/task-runner.js.map +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/watch-chat/wire.d.ts +80 -0
- package/dist/watch-chat/wire.d.ts.map +1 -1
- package/dist/watch-chat/wire.js +203 -43
- package/dist/watch-chat/wire.js.map +1 -1
- package/dist/watch.d.ts +70 -16
- package/dist/watch.d.ts.map +1 -1
- package/dist/watch.js +501 -89
- package/dist/watch.js.map +1 -1
- package/package.json +2 -2
package/dist/runtime/reviewer.js
CHANGED
|
@@ -15,10 +15,11 @@
|
|
|
15
15
|
* cron's poll-merge loop can advance.
|
|
16
16
|
*/
|
|
17
17
|
import { execa } from "execa";
|
|
18
|
-
import { priceUsdCents, normalizeUsage, modelFallbackChain, } from "../cost-pricing.js";
|
|
18
|
+
import { priceUsdCents, normalizeUsage, modelFallbackChain, toReviewerApiId, toReviewerCliAlias, } from "../cost-pricing.js";
|
|
19
19
|
import { extractResetTime, isReviewerAuthFailure, isEnvBrokenReviewerProbe, } from "../failure-classifier.js";
|
|
20
20
|
import { buildEnv } from "../bin-resolve.js";
|
|
21
|
-
import { getEngine } from "../engines/registry.js";
|
|
21
|
+
import { engineIds, getEngine } from "../engines/registry.js";
|
|
22
|
+
import { resolveCodexSpawnMode } from "../engines/codex.js";
|
|
22
23
|
import { ensureRepoClone } from "./clone-cache.js";
|
|
23
24
|
import { prepareReviewWorktree, ReviewFetchError } from "./worktree.js";
|
|
24
25
|
import { isEmptyReviewDiff } from "../review-diff-guard.js";
|
|
@@ -29,6 +30,28 @@ import { describePromptRenderBleed, findPromptRenderBleed, substitutePlaceholder
|
|
|
29
30
|
import { abstainReasons, describeAbstainForLog, } from "./reviewer-abstain.js";
|
|
30
31
|
import { chargeReviewAttempt, classifyReviewCost, openReviewCostContext, reviewCostLedger, reviewCostOverrunReason, reviewCostRunawayReason, } from "./review-cost-cap.js";
|
|
31
32
|
import { classifyTeardownFailure } from "../review-worktree-cleanup.js";
|
|
33
|
+
import { failureDiagnostics } from "../output-diagnostics.js";
|
|
34
|
+
/** Stable marker keying every reviewer tooling-failure reason (mirrors
|
|
35
|
+
* TOOLING_FAILURE_MARKER in api/_lib/review-retry.ts — the runner is a
|
|
36
|
+
* standalone package and cannot import from api/_lib). // keep in sync */
|
|
37
|
+
const REVIEWER_TOOLING_FAILURE_MARKER = "reviewer tooling failure:";
|
|
38
|
+
const REVIEWER_UNRESOLVABLE_ENGINE_REASON = `${REVIEWER_TOOLING_FAILURE_MARKER} assigned runner could not resolve the ` +
|
|
39
|
+
"task's agent engine. This is fleet configuration infra, not a review " +
|
|
40
|
+
"verdict; review budget untouched.";
|
|
41
|
+
/* The stamp above is for OPERATOR legibility, not for correctness. The server's
|
|
42
|
+
* MARKER classifier (isUnresolvableEngineReviewerOutcome in api/_lib/review-retry.ts)
|
|
43
|
+
* matches ONLY this marker — it deliberately does NOT free-text-match the raw
|
|
44
|
+
* `runtime_threw: unknown agent engine:` text, so a row from an un-upgraded runner
|
|
45
|
+
* is not exempted there. Instead the capability-reset in countReviewerErrors (keyed
|
|
46
|
+
* on LEGACY_UNRESOLVABLE_ENGINE_RAW) amnesties BOTH stamped and legacy rows — but
|
|
47
|
+
* only once the fleet regains the engine (engineServedByFleet). So an
|
|
48
|
+
* already-stranded PR recovers when a codex-capable runner comes online: recovery
|
|
49
|
+
* does not wait on THIS package being republished across the fleet, but it IS
|
|
50
|
+
* conditional on the engine being served again (while unserved the rows still
|
|
51
|
+
* count and strand — AC3). */
|
|
52
|
+
function isUnresolvableEngineError(err) {
|
|
53
|
+
return err?.message?.startsWith("unknown agent engine:") === true;
|
|
54
|
+
}
|
|
32
55
|
// Byte-identical to prompts/reviewer-agent.md. The runner does not have
|
|
33
56
|
// the markdown file at runtime (the package ships without prompts/) so
|
|
34
57
|
// the prompt is embedded. Keep this string in sync with the markdown
|
|
@@ -78,6 +101,18 @@ export const REVIEWER_PROMPT_TEMPLATE = [
|
|
|
78
101
|
" (security/tenant-isolation, build/typecheck, unsafe migration, secret,",
|
|
79
102
|
" forbidden file, out-of-scope edits present in the diff).",
|
|
80
103
|
"- Only approve if EVERY acceptance criterion is clearly met by the diff.",
|
|
104
|
+
"- **A LOC / line-count cap acceptance criterion is ADVISORY, not blocking (operator",
|
|
105
|
+
" decision D-7, 2026-09-05).** An acceptance criterion whose ONLY content is a numeric",
|
|
106
|
+
' line-count / LOC / added-lines BUDGET (e.g. "implementation additions <= 200 LOC") is',
|
|
107
|
+
" a budget, not a behaviour. When the diff exceeds it, emit that overrun as its OWN",
|
|
108
|
+
" reason in the EXACT machine form `loc_cap: measured=<N> limit=<M>` (nothing else in",
|
|
109
|
+
" that reason), and approve when every OTHER acceptance criterion is met. Put every",
|
|
110
|
+
" OTHER finding in its OWN separate reason — a forbidden file in the diff, a named",
|
|
111
|
+
" acceptance criterion whose mechanism the diff does not implement, a defect the diff",
|
|
112
|
+
" introduces, scope creep, a PR body whose numbers contradict the diff — those ALL",
|
|
113
|
+
" still reject. NEVER fold a real finding into the `loc_cap:` reason: a reason that",
|
|
114
|
+
" mixes any other clause with the overrun is NOT the cap tag and still rejects. Only a",
|
|
115
|
+
" reason that is EXACTLY the `loc_cap:` tag is advisory.",
|
|
81
116
|
"- Reject if the diff adds files outside the task's stated scope.",
|
|
82
117
|
"- Reject if the diff makes changes beyond what the task asked for",
|
|
83
118
|
" (refactors, drive-by cleanups, dependency bumps that weren't requested).",
|
|
@@ -160,6 +195,46 @@ export const REVIEWER_PROMPT_TEMPLATE = [
|
|
|
160
195
|
" pointing at a specific part of the diff or the task spec. No prose",
|
|
161
196
|
" paragraphs. No re-stating the task.",
|
|
162
197
|
].join("\n");
|
|
198
|
+
/**
|
|
199
|
+
* D-7 (operator decision, 2026-09-05): the reviewer emits a LOC/line-count cap overrun
|
|
200
|
+
* as a STRUCTURED, machine-tagged finding — a reason that is EXACTLY
|
|
201
|
+
* `loc_cap: measured=<N> limit=<M>` and nothing else — so a cap overrun is a typed
|
|
202
|
+
* object, not English the seam has to interpret. This matcher is a WHITELIST, not a
|
|
203
|
+
* blacklist: it flips only on that exact tag, anchored END TO END (^…$), so a reason
|
|
204
|
+
* that mixes any other clause — a defect, a forbidden file, a scope note, a body-vs-diff
|
|
205
|
+
* contradiction — cannot be the tag and stays a reject. Fail-CLOSED by construction:
|
|
206
|
+
* anything not positively proven the pure cap tag keeps the REJECT. A mixed finding is
|
|
207
|
+
* structurally impossible because the defect half is its OWN separate, untagged reason.
|
|
208
|
+
*/
|
|
209
|
+
const LOC_CAP_TAG_RE = /^\s*loc_cap:\s*measured=(\d+)\s+limit=(\d+)[.\s]*$/i;
|
|
210
|
+
export function isLocCapAdvisoryReason(reason) {
|
|
211
|
+
return LOC_CAP_TAG_RE.test(reason ?? "");
|
|
212
|
+
}
|
|
213
|
+
/** Render the machine tag into an operator-legible advisory line (measured/limit kept
|
|
214
|
+
* visible), or return the reason unchanged when it is not a cap tag. */
|
|
215
|
+
export function renderLocCapAdvisory(reason) {
|
|
216
|
+
const m = LOC_CAP_TAG_RE.exec(reason ?? "");
|
|
217
|
+
return m
|
|
218
|
+
? `LOC-cap advisory (D-7): implementation additions ${m[1]} exceed the ${m[2]}-line ` +
|
|
219
|
+
`budget — recorded, not blocking.`
|
|
220
|
+
: reason;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* D-7: reduce a reviewer verdict under the cap-advisory rule. A `reject` whose EVERY
|
|
224
|
+
* substantive reason is the pure `loc_cap:` tag is not a rejection — it becomes an
|
|
225
|
+
* `approve` that STILL carries the measured overrun (rendered) in `reasons`. Any reason
|
|
226
|
+
* that is not exactly the tag — a defect, forbidden file, unmet AC, body mismatch, scope
|
|
227
|
+
* creep, or even a cap stated in free prose — keeps the reject (fail-closed). A
|
|
228
|
+
* non-reject verdict is returned untouched.
|
|
229
|
+
*/
|
|
230
|
+
export function applyLocCapAdvisory(verdict) {
|
|
231
|
+
if (verdict.decision !== "reject")
|
|
232
|
+
return verdict;
|
|
233
|
+
const substantive = (verdict.reasons ?? []).filter((r) => r && r.trim());
|
|
234
|
+
if (substantive.length === 0 || !substantive.every(isLocCapAdvisoryReason))
|
|
235
|
+
return verdict;
|
|
236
|
+
return { ...verdict, decision: "approve", reasons: verdict.reasons.map(renderLocCapAdvisory) };
|
|
237
|
+
}
|
|
163
238
|
/* ──────── RF-1 (NOT-IMPLEMENTED-CARVE-OUT): completion-signal scoping ────────
|
|
164
239
|
*
|
|
165
240
|
* The reviewer prompt's completion-negative rule used to fire on the WHOLE PR
|
|
@@ -441,9 +516,9 @@ export function truncationInfraNote(omitted, partialFile = null, reordered = fal
|
|
|
441
516
|
const RETRY_DELAYS_MS = [2000, 4000, 8000];
|
|
442
517
|
/* ───────────────── REVFB (G1): in-process capacity retry/backoff ────────────
|
|
443
518
|
*
|
|
444
|
-
* A `reviewer_capacity` outcome (
|
|
519
|
+
* A `reviewer_capacity` outcome (engine subscription 429 / momentary brownout)
|
|
445
520
|
* is often transient. Rather than immediately yielding reviewer_capacity — which
|
|
446
|
-
* pauses
|
|
521
|
+
* pauses review claims via watch.ts until a resume timer fires — we retry the
|
|
447
522
|
* claude spawn IN-PROCESS a bounded number of times with exponential backoff,
|
|
448
523
|
* but ONLY for a brownout with no stated reset time. A hard session cap (which
|
|
449
524
|
* carries a "resets <time>" reset instant) is yielded immediately, since there
|
|
@@ -919,21 +994,126 @@ async function cleanupReviewTree(tree, supabase, assignment) {
|
|
|
919
994
|
await emitReviewWorktreeOrphaned(supabase, assignment, tree.path ?? "", result);
|
|
920
995
|
}
|
|
921
996
|
}
|
|
922
|
-
// M2 (ENGINE-EXTRACT): the coding engine the reviewer drives.
|
|
923
|
-
//
|
|
924
|
-
//
|
|
925
|
-
// logic now owned by src/engines/claude-code.ts. buildEnv() is the reviewer's
|
|
926
|
-
// base env (mirrors v0.74-B): the engine forwards ANTHROPIC_API_KEY over it
|
|
927
|
-
// when set.
|
|
997
|
+
// M2 (ENGINE-EXTRACT): the coding engine the reviewer drives. buildEnv() is the
|
|
998
|
+
// reviewer's base env (mirrors v0.74-B): the engine forwards provider auth over
|
|
999
|
+
// it when set.
|
|
928
1000
|
//
|
|
929
|
-
//
|
|
930
|
-
//
|
|
931
|
-
//
|
|
932
|
-
//
|
|
933
|
-
// unstamped/pre-MM-1 rows are byte-identical to Wave 1.
|
|
1001
|
+
// CROSS-ENGINE-REVIEW-1: the reviewed task's engine is now only the AUTHORING
|
|
1002
|
+
// engine. The reviewer engine is selected from an org policy per review, with a
|
|
1003
|
+
// default that prefers a different provider family but advances to the next
|
|
1004
|
+
// local engine when the preferred provider is capacity-blocked.
|
|
934
1005
|
function defaultSpawnClaude(modelId, engine) {
|
|
935
1006
|
return engine.spawn({ modelId, baseEnv: buildEnv() });
|
|
936
1007
|
}
|
|
1008
|
+
export const DEFAULT_REVIEWER_ENGINE_SELECTION_POLICY = "prefer_different_family";
|
|
1009
|
+
function engineFamily(engineId) {
|
|
1010
|
+
const id = engineId.trim().toLowerCase();
|
|
1011
|
+
if (id.startsWith("claude"))
|
|
1012
|
+
return "anthropic";
|
|
1013
|
+
if (id.startsWith("codex"))
|
|
1014
|
+
return "openai";
|
|
1015
|
+
if (id.startsWith("gemini"))
|
|
1016
|
+
return "google";
|
|
1017
|
+
return id;
|
|
1018
|
+
}
|
|
1019
|
+
export function resolveReviewerEngineSelectionPolicy(policy) {
|
|
1020
|
+
const raw = policy && typeof policy === "object"
|
|
1021
|
+
? policy.reviewer_engine_policy
|
|
1022
|
+
: null;
|
|
1023
|
+
return raw === "prefer_same_family" || raw === "capacity_first" || raw === "prefer_different_family"
|
|
1024
|
+
? raw
|
|
1025
|
+
: DEFAULT_REVIEWER_ENGINE_SELECTION_POLICY;
|
|
1026
|
+
}
|
|
1027
|
+
function uniqueEngineIds(ids) {
|
|
1028
|
+
const out = [];
|
|
1029
|
+
for (const id of ids) {
|
|
1030
|
+
const v = id.trim().toLowerCase();
|
|
1031
|
+
if (v && !out.includes(v))
|
|
1032
|
+
out.push(v);
|
|
1033
|
+
}
|
|
1034
|
+
return out;
|
|
1035
|
+
}
|
|
1036
|
+
export function selectReviewerEngineIds(authorEngine, policy, availableEngineIds = engineIds()) {
|
|
1037
|
+
const resolvedPolicy = resolveReviewerEngineSelectionPolicy(policy);
|
|
1038
|
+
const authorId = (authorEngine ?? "").trim().toLowerCase() || "claude-code";
|
|
1039
|
+
const authorFamily = engineFamily(authorId);
|
|
1040
|
+
const available = uniqueEngineIds(availableEngineIds);
|
|
1041
|
+
// FAIL CLOSED: an empty available set is a FAILURE (the runner advertised no
|
|
1042
|
+
// engine, or the capability probe failed), not a licence to route to the author
|
|
1043
|
+
// engine anyway — that would review on an engine the assigned runner may not be
|
|
1044
|
+
// able to spawn. Yield NO candidate; runReview then surfaces a no-route infra
|
|
1045
|
+
// error instead of dispatching blind.
|
|
1046
|
+
if (available.length === 0)
|
|
1047
|
+
return { engineIds: [], policy: resolvedPolicy };
|
|
1048
|
+
const same = available.filter((id) => engineFamily(id) === authorFamily);
|
|
1049
|
+
const different = available.filter((id) => engineFamily(id) !== authorFamily);
|
|
1050
|
+
const ordered = resolvedPolicy === "prefer_same_family"
|
|
1051
|
+
? [...same, ...different]
|
|
1052
|
+
: resolvedPolicy === "capacity_first"
|
|
1053
|
+
? available
|
|
1054
|
+
: [...different, ...same];
|
|
1055
|
+
return { engineIds: ordered.length > 0 ? ordered : available, policy: resolvedPolicy };
|
|
1056
|
+
}
|
|
1057
|
+
/**
|
|
1058
|
+
* CROSS-ENGINE-REVIEW-1 (AC10): map the org policy's single reviewer `model_id`
|
|
1059
|
+
* onto the engine actually chosen for THIS review. The policy historically
|
|
1060
|
+
* carries a Claude id (default 'claude-sonnet-4-6'). Cross-engine selection now
|
|
1061
|
+
* routes a review to codex even when the PR was authored by Claude — so that
|
|
1062
|
+
* Claude model id is meaningless to the reviewer engine and MUST NOT be pinned:
|
|
1063
|
+
*
|
|
1064
|
+
* - codex → run UNPINNED (model_id → `null`) unless an OpenAI-valid id is
|
|
1065
|
+
* explicitly configured on an api-key channel (a Claude id is never valid
|
|
1066
|
+
* for codex and is always dropped to `null`).
|
|
1067
|
+
* - claude-family → keep the existing catalog fallback chain unchanged.
|
|
1068
|
+
*
|
|
1069
|
+
* Returning `null` tells `engine.spawn` to omit `--model`, so codex never sees
|
|
1070
|
+
* `claude-sonnet-4-6` on argv.
|
|
1071
|
+
*/
|
|
1072
|
+
export function reviewerModelChain(engine, policyModelId, modelChainMax) {
|
|
1073
|
+
const baseModel = policyModelId ?? "claude-sonnet-4-6";
|
|
1074
|
+
if (engine.id === "codex") {
|
|
1075
|
+
// ChatGPT-account Codex rejects every explicit --model, including valid
|
|
1076
|
+
// OpenAI ids; and a Claude model id can never drive codex. Both collapse to
|
|
1077
|
+
// a single unpinned attempt. An OpenAI-valid id on an api-key channel is the
|
|
1078
|
+
// only case that pins.
|
|
1079
|
+
const codexAuthMode = resolveCodexSpawnMode(buildEnv());
|
|
1080
|
+
return codexAuthMode === "api-key" && !baseModel.startsWith("claude-")
|
|
1081
|
+
? [baseModel]
|
|
1082
|
+
: [null];
|
|
1083
|
+
}
|
|
1084
|
+
// #2169 namespace split: build the fallback chain from the CANONICAL API id so
|
|
1085
|
+
// a configured CLI alias ('sonnet') never lands in the recorded chain; the
|
|
1086
|
+
// spawn call CLI-alias-translates each entry with toReviewerCliAlias.
|
|
1087
|
+
return modelFallbackChain(toReviewerApiId(baseModel), modelChainMax);
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
1090
|
+
* CROSS-ENGINE-REVIEW-1 (AC3): resolve the assigned runner's advertised
|
|
1091
|
+
* caps.engines. Prefers the explicitly-injected list (watch.ts threads the
|
|
1092
|
+
* runner's ALREADY-PUBLISHED `acc.runners.caps.engines` — the value the
|
|
1093
|
+
* assignment was made against), then an async `detectEngines` override, and only
|
|
1094
|
+
* as a last resort falls back to the local registry (`engineIds()`). The
|
|
1095
|
+
* production path always injects, so the reviewer only ever selects an engine
|
|
1096
|
+
* THIS runner can spawn, never a registry entry it cannot.
|
|
1097
|
+
*
|
|
1098
|
+
* FAIL CLOSED: if the `detectEngines` probe THROWS we return an EMPTY set, never
|
|
1099
|
+
* the full local registry. A failed capability probe is not licence to route to
|
|
1100
|
+
* an engine the assigned runner never advertised; the empty set makes
|
|
1101
|
+
* `selectReviewerEngineIds` yield no candidate, so the review surfaces a no-route
|
|
1102
|
+
* infra error rather than silently reviewing on an unadvertised engine.
|
|
1103
|
+
*/
|
|
1104
|
+
async function resolveAdvertisedEngineIds(deps) {
|
|
1105
|
+
if (deps.advertisedEngineIds)
|
|
1106
|
+
return deps.advertisedEngineIds;
|
|
1107
|
+
if (deps.detectEngines) {
|
|
1108
|
+
try {
|
|
1109
|
+
return await deps.detectEngines();
|
|
1110
|
+
}
|
|
1111
|
+
catch {
|
|
1112
|
+
return [];
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
return engineIds();
|
|
1116
|
+
}
|
|
937
1117
|
function parseEnvelope(stdout, fallbackModel, engine) {
|
|
938
1118
|
const parsed = engine.parseResult(stdout);
|
|
939
1119
|
if (!parsed) {
|
|
@@ -962,8 +1142,8 @@ function parseEnvelope(stdout, fallbackModel, engine) {
|
|
|
962
1142
|
export async function runReview(assignment, deps) {
|
|
963
1143
|
const fetchMeta = deps.fetchPRMeta ?? defaultFetchPRMeta;
|
|
964
1144
|
const prepareTree = deps.prepareReviewTree ?? defaultPrepareReviewTree;
|
|
965
|
-
//
|
|
966
|
-
// which driveReview
|
|
1145
|
+
// CROSS-ENGINE-REVIEW-1: `spawnFn`'s default needs the selected REVIEWER
|
|
1146
|
+
// engine, which driveReview resolves from policy after it fetches the task.
|
|
967
1147
|
// `deps.spawnClaude` (test override) is unaffected either way.
|
|
968
1148
|
let outcome;
|
|
969
1149
|
try {
|
|
@@ -979,9 +1159,12 @@ export async function runReview(assignment, deps) {
|
|
|
979
1159
|
outcome = null;
|
|
980
1160
|
}
|
|
981
1161
|
else {
|
|
1162
|
+
const runtimeReason = `runtime_threw: ${err.message?.slice(0, 200)}`;
|
|
982
1163
|
outcome = {
|
|
983
1164
|
decision: "reviewer_error",
|
|
984
|
-
reasons:
|
|
1165
|
+
reasons: isUnresolvableEngineError(err)
|
|
1166
|
+
? [REVIEWER_UNRESOLVABLE_ENGINE_REASON, runtimeReason]
|
|
1167
|
+
: [runtimeReason],
|
|
985
1168
|
confidence: 0,
|
|
986
1169
|
session_id: null,
|
|
987
1170
|
cost_usd: 0,
|
|
@@ -1028,21 +1211,231 @@ export async function runReview(assignment, deps) {
|
|
|
1028
1211
|
if (error) {
|
|
1029
1212
|
process.stderr.write(`[acc-runner] submit_review(${assignment.review_id}) failed: ${error.message}\n`);
|
|
1030
1213
|
}
|
|
1214
|
+
// REVIEWER-SPEND-TO-COST-PIPE: emit this completed review's spend into
|
|
1215
|
+
// acc.cost_events AT COMPLETION (best-effort), only when the review actually
|
|
1216
|
+
// completed (submit_review did not error). See recordReviewCost.
|
|
1217
|
+
if (!error) {
|
|
1218
|
+
await recordReviewCost(deps.supabase, assignment.task_id, submitted.cost_usd);
|
|
1219
|
+
}
|
|
1220
|
+
// RUNNER-DISCARDS-STDOUT-ON-FAILURE-1: submit_review is reasons-only (frozen
|
|
1221
|
+
// RPC), so preserve the captured child stdout/stderr tails of a genuine
|
|
1222
|
+
// reviewer_error through the additive log_activity seam — otherwise the real
|
|
1223
|
+
// failure is discarded and only the reason banner survives.
|
|
1224
|
+
if (submitted.decision === "reviewer_error" &&
|
|
1225
|
+
(submitted.stdout_tail || submitted.stderr_tail)) {
|
|
1226
|
+
await emitReviewerErrorDiagnostics(deps.supabase, assignment, submitted);
|
|
1227
|
+
}
|
|
1031
1228
|
return submitted;
|
|
1032
1229
|
}
|
|
1230
|
+
/**
|
|
1231
|
+
* RUNNER-DISCARDS-STDOUT-ON-FAILURE-1 — record the credential-scrubbed,
|
|
1232
|
+
* end-truncated child stdout/stderr tails of a reviewer_error on the additive
|
|
1233
|
+
* `task.reviewer_error` `log_activity` verb (no RPC-signature or protocol-shape
|
|
1234
|
+
* change). Best-effort: a logging failure must never turn a settled review row
|
|
1235
|
+
* into a crash.
|
|
1236
|
+
*/
|
|
1237
|
+
async function emitReviewerErrorDiagnostics(supabase, assignment, outcome) {
|
|
1238
|
+
try {
|
|
1239
|
+
const { error } = await supabase.rpc("log_activity", {
|
|
1240
|
+
p_verb: "task.reviewer_error",
|
|
1241
|
+
p_target_id: assignment.task_id,
|
|
1242
|
+
p_target_type: "task",
|
|
1243
|
+
p_payload: {
|
|
1244
|
+
review_id: assignment.review_id,
|
|
1245
|
+
pr_number: assignment.pr_number,
|
|
1246
|
+
model_used: outcome.model_used ?? null,
|
|
1247
|
+
reasons: outcome.reasons,
|
|
1248
|
+
stdout_tail: outcome.stdout_tail ?? null,
|
|
1249
|
+
stderr_tail: outcome.stderr_tail ?? null,
|
|
1250
|
+
},
|
|
1251
|
+
});
|
|
1252
|
+
if (error) {
|
|
1253
|
+
process.stderr.write(`[acc-runner] task.reviewer_error log_activity(${assignment.task_id}) failed: ${error.message}\n`);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
catch (logErr) {
|
|
1257
|
+
process.stderr.write(`[acc-runner] task.reviewer_error emit failed: ${logErr.message}\n`);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
/** REVIEWER-SPEND-TO-COST-PIPE: the runner's thin mirror of the api-side
|
|
1261
|
+
* emitReviewCostEvent — a call site of the shared acc.record_cost_event RPC
|
|
1262
|
+
* (model='reviewer', zero-cost skipped, best-effort), NOT shared logic (the
|
|
1263
|
+
* runner cannot import api/_lib). Exactly-once HALTed per AC7. Returns the
|
|
1264
|
+
* outcome for the runner-package test (which root CI does NOT run — see PR body). */
|
|
1265
|
+
export async function recordReviewCost(supabase, taskId, costUsd) {
|
|
1266
|
+
const cents = Math.max(Math.round((costUsd ?? 0) * 100), 0);
|
|
1267
|
+
if (cents <= 0)
|
|
1268
|
+
return "skipped_zero";
|
|
1269
|
+
let res;
|
|
1270
|
+
try {
|
|
1271
|
+
res = await supabase.rpc("record_cost_event", {
|
|
1272
|
+
p_task_id: taskId,
|
|
1273
|
+
p_model: "reviewer",
|
|
1274
|
+
p_input_tokens: 0,
|
|
1275
|
+
p_output_tokens: 0,
|
|
1276
|
+
p_cache_read_tokens: 0,
|
|
1277
|
+
p_cache_write_tokens: 0,
|
|
1278
|
+
p_usd_cents: cents,
|
|
1279
|
+
p_runner_id: null,
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
1282
|
+
catch (err) {
|
|
1283
|
+
res = { error: { message: err.message } };
|
|
1284
|
+
}
|
|
1285
|
+
if (res?.error) {
|
|
1286
|
+
process.stderr.write(`[acc-runner] review cost-event(${taskId}) failed: ${res.error.message}\n`);
|
|
1287
|
+
return "error";
|
|
1288
|
+
}
|
|
1289
|
+
return "emitted";
|
|
1290
|
+
}
|
|
1033
1291
|
async function invokeClaude(spawnFn, modelId, prompt) {
|
|
1034
1292
|
const spawnedAt = Date.now();
|
|
1035
1293
|
const child = spawnFn(modelId);
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1294
|
+
const stdoutP = collectStream(child.stdout);
|
|
1295
|
+
const stderrP = collectStream(child.stderr);
|
|
1296
|
+
const exitP = child;
|
|
1297
|
+
const delivery = await deliverReviewerPrompt(child.stdin, prompt);
|
|
1298
|
+
const [stdout, stderr, exit] = await Promise.all([stdoutP, stderrP, exitP]);
|
|
1299
|
+
const durationMs = Date.now() - spawnedAt;
|
|
1300
|
+
const exitCode = exit.exitCode ?? null;
|
|
1301
|
+
const signal = exit.signal ?? null;
|
|
1302
|
+
// Clean delivery — the child's own exit is authoritative.
|
|
1303
|
+
if (!delivery) {
|
|
1304
|
+
return { stdout, stderr, exitCode: exitCode ?? 1, durationMs };
|
|
1305
|
+
}
|
|
1306
|
+
// REVIEWER-EPIPE-MASKS-THE-REAL-CHILD-EXIT — STARTUP DEATH: the child never
|
|
1307
|
+
// accepted a byte (`bytesWritten === 0`) AND exited abnormally (non-zero or by
|
|
1308
|
+
// signal). Only bytes_written distinguishes this from a genuine delivery break:
|
|
1309
|
+
// a write that failed with NOTHING delivered means the child was already dead
|
|
1310
|
+
// before it read input, so its OWN exit code / signal / stderr are the
|
|
1311
|
+
// diagnosable cause and pass through here VERBATIM. Everything downstream that
|
|
1312
|
+
// reads them — engine.classifyFailure, isRetriableError, extractResetTime AND
|
|
1313
|
+
// the stderr_tail recorded by emitReviewerErrorDiagnostics — sees the real child
|
|
1314
|
+
// signal, so the capacity class and the infra-retry ladder are decided by the
|
|
1315
|
+
// child, never by a reason string (AC1/AC4). The failed write is the parent's
|
|
1316
|
+
// SYMPTOM only: recorded SEPARATELY in `deliverySymptom` (appended after the
|
|
1317
|
+
// child cause, never fed back into classification).
|
|
1318
|
+
//
|
|
1319
|
+
// A MID-WRITE break (`bytesWritten > 0`) is NOT a startup death — the child WAS
|
|
1320
|
+
// reading and the pipe broke mid-stream, so the review ran on partial input and
|
|
1321
|
+
// the DELIVERY failure is the reported cause (falls through below). Branching on
|
|
1322
|
+
// exitCode/signal ALONE mislabelled that shape as a startup death — the exact
|
|
1323
|
+
// misattribution this card removes.
|
|
1324
|
+
if (delivery.bytesWritten === 0 && (exitCode !== 0 || signal !== null)) {
|
|
1325
|
+
// AC4 INVARIANT: the prompt was never delivered (bytesWritten === 0), so this
|
|
1326
|
+
// result MUST reach runClaudeAndDecide as a FAILURE whatever the child's exit
|
|
1327
|
+
// code says. A child killed by a signal can still report exitCode 0; passing
|
|
1328
|
+
// that 0 through (`?? 1` did) makes runClaudeAndDecide take the exitCode === 0
|
|
1329
|
+
// path and finalizeReviewerSuccess on an undelivered prompt. `|| 1` forces a
|
|
1330
|
+
// non-zero code (0/null → 1) while preserving a real non-zero exit for the
|
|
1331
|
+
// retry ladder; the child's TRUE exit/signal are named in deliverySymptom.
|
|
1332
|
+
return {
|
|
1333
|
+
stdout,
|
|
1334
|
+
stderr,
|
|
1335
|
+
exitCode: exitCode || 1,
|
|
1336
|
+
durationMs,
|
|
1337
|
+
deliverySymptom: reviewerStartupDeathSymptom(delivery, { exitCode, signal }),
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
// The prompt never fully landed while the child was NOT dead-before-reading —
|
|
1341
|
+
// it exited 0 on no/partial input, or a MID-WRITE break (bytesWritten > 0) gave
|
|
1342
|
+
// it partial input. Its "success" is not a real verdict, so the delivery write
|
|
1343
|
+
// is the reported cause; exitCode is forced non-zero so an exit-0 partial run is
|
|
1344
|
+
// not mistaken for a verdict. The marker is PREPENDED (not appended) so it is the
|
|
1345
|
+
// first-useful stderr line failureDiagnostics reports — surfacing in `reasons`
|
|
1346
|
+
// (budget-exempt) even when the child had its OWN stderr, while that child stderr
|
|
1347
|
+
// is still carried through for stderr_tail + classification. Prepending also keeps
|
|
1348
|
+
// the stderr non-empty, so an exit-0 empty child is never misread as a silent
|
|
1349
|
+
// capacity exit.
|
|
1350
|
+
return {
|
|
1351
|
+
stdout,
|
|
1352
|
+
stderr: [reviewerPromptDeliveryFailure(delivery), stderr].filter(Boolean).join("\n"),
|
|
1353
|
+
exitCode: 1,
|
|
1354
|
+
durationMs,
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
1357
|
+
/**
|
|
1358
|
+
* REVIEWER-EPIPE — a prompt-delivery write failure when the child itself exited
|
|
1359
|
+
* CLEANLY (exit 0). The review ran on no / partial input, so its "success" is not a
|
|
1360
|
+
* real verdict and THIS is the one shape where the delivery write is the reported
|
|
1361
|
+
* cause. Worded off bytes_written so a 0-byte failure never contradicts itself by
|
|
1362
|
+
* claiming a "mid-write" break (the stdin-unavailable and bytes_written=0 cases now
|
|
1363
|
+
* read "before any byte reached the child", not "mid-write ... bytes_written=0").
|
|
1364
|
+
*/
|
|
1365
|
+
function reviewerPromptDeliveryFailure(f) {
|
|
1366
|
+
const phase = f.bytesWritten > 0
|
|
1367
|
+
? `mid-write (${f.bytesWritten} of ${f.promptBytes} bytes delivered)`
|
|
1368
|
+
: "before any byte reached the child";
|
|
1369
|
+
return (`${REVIEWER_TOOLING_FAILURE_MARKER} reviewer prompt delivery failed ${phase} ` +
|
|
1370
|
+
`(mechanism=stdin_pipe, prompt_bytes=${f.promptBytes}, bytes_written=${f.bytesWritten}). ` +
|
|
1371
|
+
`${f.detail.slice(0, 160)}. Review budget untouched (not a verdict).`);
|
|
1372
|
+
}
|
|
1373
|
+
/**
|
|
1374
|
+
* REVIEWER-EPIPE-MASKS-THE-REAL-CHILD-EXIT — the child EXITED ABNORMALLY before it
|
|
1375
|
+
* finished reading the prompt. Its OWN exit code / signal / stderr are the
|
|
1376
|
+
* diagnosable cause and are passed through UNTOUCHED by invokeClaude (so the
|
|
1377
|
+
* classifier, the retry ladder and the verbatim stderr_tail all see the real child
|
|
1378
|
+
* signal). This line records the parent's failed stdin write SEPARATELY and
|
|
1379
|
+
* explicitly as the SYMPTOM that first surfaced the death — never in place of the
|
|
1380
|
+
* child stderr. It is appended to the outcome reasons AFTER the child cause and
|
|
1381
|
+
* carries the tooling-failure marker so the row stays budget-exempt (a startup
|
|
1382
|
+
* death is infra, not a verdict); it is NEVER fed back into classification.
|
|
1383
|
+
*
|
|
1384
|
+
* Scope of the class this names: the subset of "exited 1" reviewer_errors that are
|
|
1385
|
+
* EPIPE-masked startup deaths. Rows that already carry the child's own stderr are
|
|
1386
|
+
* NOT touched here. How many rows this newly names is a POST-DEPLOY measurement,
|
|
1387
|
+
* carried by the follow-up card (D-1) — no count is asserted here.
|
|
1388
|
+
*/
|
|
1389
|
+
function reviewerStartupDeathSymptom(f, exit) {
|
|
1390
|
+
return (`${REVIEWER_TOOLING_FAILURE_MARKER} reviewer child died at startup ` +
|
|
1391
|
+
`(exit=${exit.exitCode ?? "null"}, signal=${exit.signal ?? "none"}) — the cause is the ` +
|
|
1392
|
+
`child's own exit/stderr recorded above (verbatim in stderr_tail); the parent stdin write ` +
|
|
1393
|
+
`is only the symptom (bytes_written=${f.bytesWritten}/${f.promptBytes}: ${f.detail.slice(0, 160)}). ` +
|
|
1394
|
+
`Review budget untouched (not a verdict).`);
|
|
1395
|
+
}
|
|
1396
|
+
async function deliverReviewerPrompt(stdin, prompt) {
|
|
1397
|
+
const promptBytes = Buffer.byteLength(prompt);
|
|
1398
|
+
if (!stdin) {
|
|
1399
|
+
return { detail: "child stdin pipe was unavailable", promptBytes, bytesWritten: 0 };
|
|
1400
|
+
}
|
|
1401
|
+
let bytesWritten = 0;
|
|
1402
|
+
try {
|
|
1403
|
+
await new Promise((resolve, reject) => {
|
|
1404
|
+
const onError = (err) => {
|
|
1405
|
+
stdin.off("error", onError);
|
|
1406
|
+
reject(err);
|
|
1407
|
+
};
|
|
1408
|
+
stdin.once("error", onError);
|
|
1409
|
+
stdin.write(prompt, (err) => {
|
|
1410
|
+
stdin.off("error", onError);
|
|
1411
|
+
if (err)
|
|
1412
|
+
reject(err);
|
|
1413
|
+
else {
|
|
1414
|
+
bytesWritten = promptBytes;
|
|
1415
|
+
resolve();
|
|
1416
|
+
}
|
|
1417
|
+
});
|
|
1418
|
+
});
|
|
1419
|
+
await new Promise((resolve, reject) => {
|
|
1420
|
+
const onError = (err) => {
|
|
1421
|
+
stdin.off("error", onError);
|
|
1422
|
+
reject(err);
|
|
1423
|
+
};
|
|
1424
|
+
stdin.once("error", onError);
|
|
1425
|
+
stdin.end(() => {
|
|
1426
|
+
stdin.off("error", onError);
|
|
1427
|
+
resolve();
|
|
1428
|
+
});
|
|
1429
|
+
});
|
|
1430
|
+
return null;
|
|
1431
|
+
}
|
|
1432
|
+
catch (err) {
|
|
1433
|
+
return {
|
|
1434
|
+
detail: err.message || String(err),
|
|
1435
|
+
promptBytes,
|
|
1436
|
+
bytesWritten,
|
|
1437
|
+
};
|
|
1039
1438
|
}
|
|
1040
|
-
const [stdout, stderr, exit] = await Promise.all([
|
|
1041
|
-
collectStream(child.stdout),
|
|
1042
|
-
collectStream(child.stderr),
|
|
1043
|
-
child,
|
|
1044
|
-
]);
|
|
1045
|
-
return { stdout, stderr, exitCode: exit.exitCode ?? 1, durationMs: Date.now() - spawnedAt };
|
|
1046
1439
|
}
|
|
1047
1440
|
async function driveReview(assignment, deps, fetchMeta, prepareTree) {
|
|
1048
1441
|
const { data, error } = await deps.supabase.rpc("fetch_task_for_runner", {
|
|
@@ -1069,12 +1462,6 @@ async function driveReview(assignment, deps, fetchMeta, prepareTree) {
|
|
|
1069
1462
|
cost_usd: 0,
|
|
1070
1463
|
};
|
|
1071
1464
|
}
|
|
1072
|
-
// NS MM-2 (BATCH-ENGINE-DISPATCH): resolve the reviewed task's engine ONCE,
|
|
1073
|
-
// right after the task itself is known — mirrors task-runner.ts's
|
|
1074
|
-
// per-task resolution. `getEngine(null)` (an unstamped/pre-MM-1 row)
|
|
1075
|
-
// resolves the same claude-code default Wave 1 always used.
|
|
1076
|
-
const engine = getEngine(task.engine ?? null);
|
|
1077
|
-
const spawnFn = deps.spawnClaude ?? ((modelId) => defaultSpawnClaude(modelId, engine));
|
|
1078
1465
|
// Look up the org's reviewer policy. The runner-side reviewer needs
|
|
1079
1466
|
// confidence_threshold + max_cost_usd_per_review to stay consistent
|
|
1080
1467
|
// with the cron-side gate, since the cron now just lifts the decision
|
|
@@ -1082,6 +1469,13 @@ async function driveReview(assignment, deps, fetchMeta, prepareTree) {
|
|
|
1082
1469
|
const policy = deps.loadPolicy
|
|
1083
1470
|
? await deps.loadPolicy("")
|
|
1084
1471
|
: await loadReviewerPolicy(deps.supabase);
|
|
1472
|
+
// CROSS-ENGINE-REVIEW-1 (AC3): constrain reviewer-engine selection to the
|
|
1473
|
+
// engines THIS (the assigned) runner actually advertises in caps.engines —
|
|
1474
|
+
// NOT the full local registry. `resolveAdvertisedEngineIds` prefers an
|
|
1475
|
+
// explicitly-injected list and otherwise re-runs the same capability probe
|
|
1476
|
+
// the runner published at register time.
|
|
1477
|
+
const advertisedEngineIds = await resolveAdvertisedEngineIds(deps);
|
|
1478
|
+
const reviewerEngineSelection = selectReviewerEngineIds(task.engine ?? null, policy, advertisedEngineIds);
|
|
1085
1479
|
const meta = await fetchMeta(repo, assignment.pr_number);
|
|
1086
1480
|
// R0-D-REVIEWER-WORKSPACE-INTEGRITY: materialize the EXACT PR head before
|
|
1087
1481
|
// forming any verdict, or fail closed. On failure we post NO verdict and emit
|
|
@@ -1141,13 +1535,86 @@ async function driveReview(assignment, deps, fetchMeta, prepareTree) {
|
|
|
1141
1535
|
throw err;
|
|
1142
1536
|
}
|
|
1143
1537
|
try {
|
|
1144
|
-
|
|
1538
|
+
let lastCapacity = null;
|
|
1539
|
+
// The engine that ACTUALLY produced the returned outcome. After a capacity
|
|
1540
|
+
// fallback this is NOT engineIds[0] — recording the first candidate would make
|
|
1541
|
+
// every same-vs-cross-family row in the AC4 measurement wrong whenever a
|
|
1542
|
+
// fallback fired (and fallbacks are common). Set to each engine as it is tried,
|
|
1543
|
+
// so it names the winning engine on success and the last-tried on a whole-chain
|
|
1544
|
+
// capacity cap.
|
|
1545
|
+
let reviewerEngineId = null;
|
|
1546
|
+
let outcome = null;
|
|
1547
|
+
const input = {
|
|
1145
1548
|
task_description: task.description ?? "",
|
|
1146
1549
|
acceptance_criteria: task.acceptance ?? [],
|
|
1147
1550
|
pr_title: meta.title,
|
|
1148
1551
|
pr_body: meta.body,
|
|
1149
1552
|
pr_diff: tree.diff,
|
|
1150
|
-
}
|
|
1553
|
+
};
|
|
1554
|
+
for (let i = 0; i < reviewerEngineSelection.engineIds.length; i++) {
|
|
1555
|
+
const engineId = reviewerEngineSelection.engineIds[i];
|
|
1556
|
+
// CAPS-ENGINES-STALE-KEYCHAIN-1 (AC12): the reviewer only selects an
|
|
1557
|
+
// ADVERTISED engine, so a getEngine throw here is a caps-vs-registry SELF-
|
|
1558
|
+
// INCONSISTENCY. Drop it from caps (onUnknownAdvertisedEngine) — AC12's
|
|
1559
|
+
// "dropped from caps immediately" — THEN re-throw, so upstream's #2129
|
|
1560
|
+
// (REVIEWER-INFRA-ERROR-BURNS-BUDGET) still stamps it as a budget-exempt
|
|
1561
|
+
// tooling-failure `reviewer_error` that recovers (ns04-mm2 / riebb). Mirrors
|
|
1562
|
+
// the task-runner guard: AC12 side-effect + upstream's outcome, both kept.
|
|
1563
|
+
let engine;
|
|
1564
|
+
try {
|
|
1565
|
+
engine = getEngine(engineId);
|
|
1566
|
+
}
|
|
1567
|
+
catch (err) {
|
|
1568
|
+
await deps.onUnknownAdvertisedEngine?.(engineId, err);
|
|
1569
|
+
throw err;
|
|
1570
|
+
}
|
|
1571
|
+
const spawnFn = deps.spawnClaude
|
|
1572
|
+
? ((modelId) => deps.spawnClaude(modelId))
|
|
1573
|
+
: ((modelId) => defaultSpawnClaude(modelId, engine));
|
|
1574
|
+
const out = await produceVerdict(input, policy, deps, spawnFn, engine);
|
|
1575
|
+
reviewerEngineId = engineId; // the engine this outcome actually came from
|
|
1576
|
+
if (out.decision !== "reviewer_capacity") {
|
|
1577
|
+
outcome = lastCapacity ? { ...out, capacity_degraded: true } : out;
|
|
1578
|
+
break;
|
|
1579
|
+
}
|
|
1580
|
+
lastCapacity = out;
|
|
1581
|
+
}
|
|
1582
|
+
if (outcome === null) {
|
|
1583
|
+
outcome = lastCapacity ?? {
|
|
1584
|
+
decision: "reviewer_error",
|
|
1585
|
+
reasons: ["no reviewer engine resolved from policy/runner caps.engines"],
|
|
1586
|
+
confidence: 0,
|
|
1587
|
+
session_id: null,
|
|
1588
|
+
cost_usd: 0,
|
|
1589
|
+
};
|
|
1590
|
+
}
|
|
1591
|
+
// AC4 + AC8: record WHICH engine reviewed (submit_review is frozen reasons-only;
|
|
1592
|
+
// task.review_submitted carries no engine), so same-vs-cross-family outcomes are
|
|
1593
|
+
// computable. Emitted AFTER the decision comes back, keyed on the engine that
|
|
1594
|
+
// ACTUALLY produced it (`reviewerEngineId`) — the routing OUTCOME, never the
|
|
1595
|
+
// first candidate at selection time. Additive log_activity seam, fire-and-
|
|
1596
|
+
// forget. docs/acc/CROSS_ENGINE_REVIEW_MEASUREMENT.md.
|
|
1597
|
+
const authorEngineId = (task.engine ?? "claude-code").trim().toLowerCase();
|
|
1598
|
+
void deps.supabase
|
|
1599
|
+
.rpc("log_activity", {
|
|
1600
|
+
p_verb: "review.engine_selected",
|
|
1601
|
+
p_target_id: assignment.task_id,
|
|
1602
|
+
p_target_type: "task",
|
|
1603
|
+
p_payload: {
|
|
1604
|
+
pr_number: assignment.pr_number,
|
|
1605
|
+
review_id: assignment.review_id,
|
|
1606
|
+
policy: reviewerEngineSelection.policy,
|
|
1607
|
+
author_engine: authorEngineId,
|
|
1608
|
+
author_family: engineFamily(authorEngineId),
|
|
1609
|
+
reviewer_engine: reviewerEngineId,
|
|
1610
|
+
reviewer_family: engineFamily(reviewerEngineId ?? ""),
|
|
1611
|
+
cross_family: engineFamily(reviewerEngineId ?? "") !== engineFamily(authorEngineId),
|
|
1612
|
+
candidate_engines: reviewerEngineSelection.engineIds,
|
|
1613
|
+
advertised_engines: advertisedEngineIds,
|
|
1614
|
+
},
|
|
1615
|
+
})
|
|
1616
|
+
.then?.(undefined, () => undefined);
|
|
1617
|
+
return outcome;
|
|
1151
1618
|
}
|
|
1152
1619
|
finally {
|
|
1153
1620
|
// Remove the dedicated review worktree so it can never become a stale
|
|
@@ -1434,9 +1901,18 @@ function finalizeReviewerSuccess(stdout, modelId, degraded, costCtx, engine) {
|
|
|
1434
1901
|
cost_breakdown: ledger,
|
|
1435
1902
|
};
|
|
1436
1903
|
}
|
|
1437
|
-
|
|
1904
|
+
// D-7 (2026-09-05): a reject whose ONLY reasons are pure LOC-cap advisories is
|
|
1905
|
+
// not a rejection — flip to approve, keeping the measured overrun in `reasons`.
|
|
1906
|
+
// The prompt already instructs the model to do this; the guard makes the operator
|
|
1907
|
+
// decision deterministic. Conservative: a single non-cap reason keeps the reject,
|
|
1908
|
+
// so the reviewer's bias-to-reject is untouched for every other finding.
|
|
1909
|
+
const adjudicated = applyLocCapAdvisory({
|
|
1438
1910
|
decision: decision.decision,
|
|
1439
1911
|
reasons: decision.reasons,
|
|
1912
|
+
});
|
|
1913
|
+
return {
|
|
1914
|
+
decision: adjudicated.decision,
|
|
1915
|
+
reasons: adjudicated.reasons,
|
|
1440
1916
|
confidence: decision.confidence,
|
|
1441
1917
|
session_id: envelope.session_id,
|
|
1442
1918
|
cost_usd: envelope.cost_usd,
|
|
@@ -1462,10 +1938,15 @@ function finalizeReviewerSuccess(stdout, modelId, degraded, costCtx, engine) {
|
|
|
1462
1938
|
*/
|
|
1463
1939
|
async function runClaudeAndDecide(prompt, policy, deps, spawnFn, engine, costCtx) {
|
|
1464
1940
|
const sleepFn = deps.sleep ?? defaultSleep;
|
|
1465
|
-
const baseModel = policy?.model_id ?? "claude-sonnet-4-6";
|
|
1466
1941
|
const maxCapacityRetries = resolveCapacityRetries(process.env[CAPACITY_RETRIES_ENV]);
|
|
1467
|
-
|
|
1468
|
-
|
|
1942
|
+
// CROSS-ENGINE-REVIEW-1 (AC10): map policy.model_id onto the SELECTED reviewer
|
|
1943
|
+
// engine. For codex this drops a Claude id (e.g. 'claude-sonnet-4-6') to an
|
|
1944
|
+
// unpinned run so the reviewer engine never receives a foreign model id;
|
|
1945
|
+
// Claude keeps its catalog fallback chain (built from the CANONICAL API id via
|
|
1946
|
+
// toReviewerApiId — the #2169 namespace split — and CLI-alias-translated only
|
|
1947
|
+
// at the spawn call inside the loop). See `reviewerModelChain`.
|
|
1948
|
+
const chain = reviewerModelChain(engine, policy?.model_id, deps.modelChainMax ?? DEFAULT_MODEL_CHAIN_MAX);
|
|
1949
|
+
const primaryModel = chain[0] ?? `${engine.id}-default`;
|
|
1469
1950
|
// RVU-2: optional env-broken CLI-auth PREFLIGHT canary. A broken login /
|
|
1470
1951
|
// rejected key is caught BEFORE spawning the reviewer against a dead
|
|
1471
1952
|
// credential, so it never loops as a spurious reviewer_error.
|
|
@@ -1484,10 +1965,15 @@ async function runClaudeAndDecide(prompt, policy, deps, spawnFn, engine, costCtx
|
|
|
1484
1965
|
let lastCapacityDetail = "reviewer capacity exhausted across all fallback tiers";
|
|
1485
1966
|
let lastResumeMs = null;
|
|
1486
1967
|
for (let i = 0; i < chain.length; i++) {
|
|
1487
|
-
|
|
1968
|
+
// `modelId` is the CANONICAL id recorded on every outcome / cost row below.
|
|
1969
|
+
const modelId = chain[i] ?? `${engine.id}-default`;
|
|
1970
|
+
// Alias→CLI translation is one-way, for the spawn call ONLY — never recorded.
|
|
1971
|
+
// Codex passes its own id (or null, its unpinned default) straight through;
|
|
1972
|
+
// Claude spawns the CLI alias derived from the canonical id.
|
|
1973
|
+
const spawnModelId = chain[i] === null || engine.id === "codex" ? chain[i] : toReviewerCliAlias(modelId);
|
|
1488
1974
|
const isLast = i === chain.length - 1;
|
|
1489
|
-
const invokeResult = await invokeReviewerModel(spawnFn,
|
|
1490
|
-
const { stdout, exitCode, stderr: claudeStderr, durationMs } = invokeResult;
|
|
1975
|
+
const invokeResult = await invokeReviewerModel(spawnFn, spawnModelId, prompt, sleepFn, maxCapacityRetries, engine);
|
|
1976
|
+
const { stdout, exitCode, stderr: claudeStderr, durationMs, deliverySymptom } = invokeResult;
|
|
1491
1977
|
if (exitCode === 0) {
|
|
1492
1978
|
return finalizeReviewerSuccess(stdout, modelId, modelId !== primaryModel, costCtx, engine);
|
|
1493
1979
|
}
|
|
@@ -1527,6 +2013,7 @@ async function runClaudeAndDecide(prompt, policy, deps, spawnFn, engine, costCtx
|
|
|
1527
2013
|
session_id: null,
|
|
1528
2014
|
cost_usd: 0,
|
|
1529
2015
|
resume_at: lastResumeMs !== null ? new Date(lastResumeMs).toISOString() : null,
|
|
2016
|
+
engine: engine.id,
|
|
1530
2017
|
model_used: modelId,
|
|
1531
2018
|
capacity_degraded: true,
|
|
1532
2019
|
cost_cap: costCtx.cap,
|
|
@@ -1535,16 +2022,30 @@ async function runClaudeAndDecide(prompt, policy, deps, spawnFn, engine, costCtx
|
|
|
1535
2022
|
}
|
|
1536
2023
|
// Genuine error (task_error / usage_limit) — legacy reviewer_error, no
|
|
1537
2024
|
// re-route (a lower tier would just repeat the failure/cost).
|
|
2025
|
+
const diagnostics = failureDiagnostics({
|
|
2026
|
+
exitCode,
|
|
2027
|
+
stdout,
|
|
2028
|
+
stderr: claudeStderr,
|
|
2029
|
+
detail: classified.detail,
|
|
2030
|
+
label: engine.id,
|
|
2031
|
+
});
|
|
2032
|
+
// REVIEWER-EPIPE-MASKS-THE-REAL-CHILD-EXIT — the child cause is already named by
|
|
2033
|
+
// `diagnostics` (its first-useful stderr line) and its FULL stderr is preserved
|
|
2034
|
+
// verbatim in stderr_tail. When a startup-death write symptom was recorded, append
|
|
2035
|
+
// it AFTER the child cause: it names the masked exit/signal, labels the parent
|
|
2036
|
+
// write a symptom, and carries the tooling marker so the row is budget-exempt.
|
|
2037
|
+
const reasons = deliverySymptom
|
|
2038
|
+
? [...diagnostics.reasons, deliverySymptom]
|
|
2039
|
+
: diagnostics.reasons;
|
|
1538
2040
|
return {
|
|
1539
2041
|
decision: "reviewer_error",
|
|
1540
|
-
reasons
|
|
1541
|
-
`claude exited ${exitCode}`,
|
|
1542
|
-
...(claudeStderr ? [`stderr: ${claudeStderr.slice(0, 200)}`] : []),
|
|
1543
|
-
],
|
|
2042
|
+
reasons,
|
|
1544
2043
|
confidence: 0,
|
|
1545
2044
|
session_id: null,
|
|
1546
2045
|
cost_usd: 0,
|
|
1547
2046
|
model_used: modelId,
|
|
2047
|
+
stdout_tail: diagnostics.stdoutTail,
|
|
2048
|
+
stderr_tail: diagnostics.stderrTail,
|
|
1548
2049
|
};
|
|
1549
2050
|
}
|
|
1550
2051
|
// Unreachable in practice (chain always has ≥1 entry): a blank policy model
|