@tokenfactory/acc-runner 0.44.0 → 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 +43 -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 +91 -0
- package/dist/cost-pricing.d.ts.map +1 -1
- package/dist/cost-pricing.js +115 -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 +37 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +300 -41
- 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/instance.d.ts +30 -0
- package/dist/instance.d.ts.map +1 -0
- package/dist/instance.js +51 -0
- package/dist/instance.js.map +1 -0
- package/dist/keychain.d.ts +20 -7
- package/dist/keychain.d.ts.map +1 -1
- package/dist/keychain.js +40 -4
- 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 +63 -2
- package/dist/login.d.ts.map +1 -1
- package/dist/login.js +93 -8
- 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/memory-context.d.ts +106 -0
- package/dist/memory-context.d.ts.map +1 -0
- package/dist/memory-context.js +220 -0
- package/dist/memory-context.js.map +1 -0
- package/dist/memory-curation.d.ts +172 -0
- package/dist/memory-curation.d.ts.map +1 -0
- package/dist/memory-curation.js +277 -0
- package/dist/memory-curation.js.map +1 -0
- 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 +43 -5
- package/dist/program.js.map +1 -1
- package/dist/provider-auth.d.ts +27 -0
- package/dist/provider-auth.d.ts.map +1 -1
- package/dist/provider-auth.js +33 -0
- package/dist/provider-auth.js.map +1 -1
- package/dist/runtime/cache-dir.d.ts.map +1 -1
- package/dist/runtime/cache-dir.js +8 -3
- package/dist/runtime/cache-dir.js.map +1 -1
- package/dist/runtime/decision-provenance.d.ts +63 -0
- package/dist/runtime/decision-provenance.d.ts.map +1 -0
- package/dist/runtime/decision-provenance.js +107 -0
- package/dist/runtime/decision-provenance.js.map +1 -0
- package/dist/runtime/prompt-substitute.d.ts +100 -0
- package/dist/runtime/prompt-substitute.d.ts.map +1 -0
- package/dist/runtime/prompt-substitute.js +124 -0
- package/dist/runtime/prompt-substitute.js.map +1 -0
- 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/review-cost-cap.d.ts +167 -0
- package/dist/runtime/review-cost-cap.d.ts.map +1 -0
- package/dist/runtime/review-cost-cap.js +182 -0
- package/dist/runtime/review-cost-cap.js.map +1 -0
- package/dist/runtime/reviewer-abstain.d.ts +101 -0
- package/dist/runtime/reviewer-abstain.d.ts.map +1 -0
- package/dist/runtime/reviewer-abstain.js +138 -0
- package/dist/runtime/reviewer-abstain.js.map +1 -0
- package/dist/runtime/reviewer.d.ts +165 -2
- package/dist/runtime/reviewer.d.ts.map +1 -1
- package/dist/runtime/reviewer.js +752 -111
- 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/singleton.d.ts.map +1 -1
- package/dist/runtime/singleton.js +9 -1
- package/dist/runtime/singleton.js.map +1 -1
- 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/serving-auth/index.d.ts +79 -0
- package/dist/serving-auth/index.d.ts.map +1 -0
- package/dist/serving-auth/index.js +169 -0
- package/dist/serving-auth/index.js.map +1 -0
- package/dist/task-runner.d.ts +134 -5
- package/dist/task-runner.d.ts.map +1 -1
- package/dist/task-runner.js +1255 -129
- 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 +548 -108
- package/dist/watch.js.map +1 -1
- package/package.json +4 -3
package/dist/runtime/reviewer.js
CHANGED
|
@@ -15,16 +15,43 @@
|
|
|
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";
|
|
25
26
|
import { diffFilePaths, splitDiffByFile } from "./review-diff.js";
|
|
26
27
|
import { planReviewChunks, partitionAbsenceClaims, MAX_REVIEW_CHUNKS, REVIEWER_ABSENCE_ESCALATION_REASON, DISCARDED_CLAIM_PREFIX, } from "./review-chunking.js";
|
|
28
|
+
import { decisionProvenanceReason, reconcileReviewDecision, PROSE_KEYWORD_OVERRIDE_REASON, } from "./decision-provenance.js";
|
|
29
|
+
import { describePromptRenderBleed, findPromptRenderBleed, substitutePlaceholders, } from "./prompt-substitute.js";
|
|
30
|
+
import { abstainReasons, describeAbstainForLog, } from "./reviewer-abstain.js";
|
|
31
|
+
import { chargeReviewAttempt, classifyReviewCost, openReviewCostContext, reviewCostLedger, reviewCostOverrunReason, reviewCostRunawayReason, } from "./review-cost-cap.js";
|
|
27
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
|
+
}
|
|
28
55
|
// Byte-identical to prompts/reviewer-agent.md. The runner does not have
|
|
29
56
|
// the markdown file at runtime (the package ships without prompts/) so
|
|
30
57
|
// the prompt is embedded. Keep this string in sync with the markdown
|
|
@@ -74,6 +101,18 @@ export const REVIEWER_PROMPT_TEMPLATE = [
|
|
|
74
101
|
" (security/tenant-isolation, build/typecheck, unsafe migration, secret,",
|
|
75
102
|
" forbidden file, out-of-scope edits present in the diff).",
|
|
76
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.",
|
|
77
116
|
"- Reject if the diff adds files outside the task's stated scope.",
|
|
78
117
|
"- Reject if the diff makes changes beyond what the task asked for",
|
|
79
118
|
" (refactors, drive-by cleanups, dependency bumps that weren't requested).",
|
|
@@ -156,6 +195,46 @@ export const REVIEWER_PROMPT_TEMPLATE = [
|
|
|
156
195
|
" pointing at a specific part of the diff or the task spec. No prose",
|
|
157
196
|
" paragraphs. No re-stating the task.",
|
|
158
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
|
+
}
|
|
159
238
|
/* ──────── RF-1 (NOT-IMPLEMENTED-CARVE-OUT): completion-signal scoping ────────
|
|
160
239
|
*
|
|
161
240
|
* The reviewer prompt's completion-negative rule used to fire on the WHOLE PR
|
|
@@ -437,9 +516,9 @@ export function truncationInfraNote(omitted, partialFile = null, reordered = fal
|
|
|
437
516
|
const RETRY_DELAYS_MS = [2000, 4000, 8000];
|
|
438
517
|
/* ───────────────── REVFB (G1): in-process capacity retry/backoff ────────────
|
|
439
518
|
*
|
|
440
|
-
* A `reviewer_capacity` outcome (
|
|
519
|
+
* A `reviewer_capacity` outcome (engine subscription 429 / momentary brownout)
|
|
441
520
|
* is often transient. Rather than immediately yielding reviewer_capacity — which
|
|
442
|
-
* pauses
|
|
521
|
+
* pauses review claims via watch.ts until a resume timer fires — we retry the
|
|
443
522
|
* claude spawn IN-PROCESS a bounded number of times with exponential backoff,
|
|
444
523
|
* but ONLY for a brownout with no stated reset time. A hard session cap (which
|
|
445
524
|
* carries a "resets <time>" reset instant) is yielded immediately, since there
|
|
@@ -516,7 +595,7 @@ export function isRetriableError(stderr) {
|
|
|
516
595
|
function defaultSleep(ms) {
|
|
517
596
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
518
597
|
}
|
|
519
|
-
export function
|
|
598
|
+
export function renderReviewerPromptChecked(args) {
|
|
520
599
|
// RF-3: ONE truncation policy — float the AC-named proof files to the front,
|
|
521
600
|
// cut at a whole-file boundary, and name what was withheld. Under the cap
|
|
522
601
|
// nothing reorders and no notice renders, so the prompt is byte-identical to
|
|
@@ -538,14 +617,41 @@ export function renderReviewerPrompt(args) {
|
|
|
538
617
|
const acceptance = (args.acceptance_criteria ?? [])
|
|
539
618
|
.map((a, i) => `${i + 1}. ${a}`)
|
|
540
619
|
.join("\n");
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
620
|
+
// S15-FU/REVIEW-1 (AC1): substitute VERBATIM. `String.replace` with a string
|
|
621
|
+
// replacement expands `$$`/`$&`/`` $` ``/`$'`/`$n`, so a diff containing `$'`
|
|
622
|
+
// — e.g. the clean literal `... where seq ~ '^[0-9]+$' order by 1` — used to
|
|
623
|
+
// splice the template text FOLLOWING `{{pr_diff}}` ("Now answer in this exact
|
|
624
|
+
// JSON shape…") into the diff, and the reviewer then reported that prose as a
|
|
625
|
+
// corruption defect in the file (#1933). Never re-introduce a string
|
|
626
|
+
// replacement here; see ./prompt-substitute.ts.
|
|
627
|
+
const pairs = reviewerPlaceholderPairs(args, diff, acceptance);
|
|
628
|
+
const rendered = substitutePlaceholders(REVIEWER_PROMPT_TEMPLATE, pairs);
|
|
547
629
|
const note = truncationInfraNote(omitted, partialFile, prioritized.reordered);
|
|
548
|
-
|
|
630
|
+
const prompt = note.length > 0 ? `${rendered}\n\n${note}\n` : rendered;
|
|
631
|
+
return { prompt, bleed: findPromptRenderBleed(REVIEWER_PROMPT_TEMPLATE, prompt, pairs) };
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* S15-FU/REVIEW-1 (AC1): the ORDERED placeholder/value pairs a reviewer prompt
|
|
635
|
+
* substitutes, so the render-integrity check (and its test) can assert that every
|
|
636
|
+
* one of these values survived into the prompt VERBATIM.
|
|
637
|
+
* // keep in sync with reviewerPlaceholderPairs in api/_lib/reviewer-agent.ts
|
|
638
|
+
*/
|
|
639
|
+
export function reviewerPlaceholderPairs(args, diff, acceptance) {
|
|
640
|
+
return [
|
|
641
|
+
["{{task_description}}", args.task_description ?? ""],
|
|
642
|
+
["{{acceptance_criteria}}", acceptance],
|
|
643
|
+
["{{pr_title}}", args.pr_title ?? ""],
|
|
644
|
+
["{{pr_body}}", args.pr_body ?? ""],
|
|
645
|
+
["{{pr_diff}}", diff],
|
|
646
|
+
];
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* String façade over `renderReviewerPromptChecked` — the signature every existing
|
|
650
|
+
* caller and the RF-3 parity tests use. Integrity is enforced at the review seam,
|
|
651
|
+
* which needs the `bleed` list to build its abstain.
|
|
652
|
+
*/
|
|
653
|
+
export function renderReviewerPrompt(args) {
|
|
654
|
+
return renderReviewerPromptChecked(args).prompt;
|
|
549
655
|
}
|
|
550
656
|
/* ─────────── REVIEWER-DIFF-CAP: chunked review of an over-cap diff ───────────
|
|
551
657
|
*
|
|
@@ -740,7 +846,18 @@ export function extractReviewerDecision(text) {
|
|
|
740
846
|
"parsed from prose reviewer output (no JSON found)",
|
|
741
847
|
`decision_keyword: ${decision}`,
|
|
742
848
|
];
|
|
743
|
-
|
|
849
|
+
// REVIEW-DECISION-PROVENANCE: this verdict was INFERRED from a keyword, not
|
|
850
|
+
// asserted. PR #1913 persisted a reject this way over an all-"Met." analysis
|
|
851
|
+
// with no trace of the inference; runReview stamps the marker at submit time.
|
|
852
|
+
return {
|
|
853
|
+
decision,
|
|
854
|
+
reasons,
|
|
855
|
+
confidence,
|
|
856
|
+
decision_provenance: {
|
|
857
|
+
decision_source: "prose_keyword",
|
|
858
|
+
override_reason: PROSE_KEYWORD_OVERRIDE_REASON,
|
|
859
|
+
},
|
|
860
|
+
};
|
|
744
861
|
}
|
|
745
862
|
async function defaultFetchPRMeta(repo, prNumber) {
|
|
746
863
|
const { stdout } = await execa("gh", ["pr", "view", String(prNumber), "-R", repo, "--json", "title,body"], { env: buildEnv() });
|
|
@@ -877,21 +994,126 @@ async function cleanupReviewTree(tree, supabase, assignment) {
|
|
|
877
994
|
await emitReviewWorktreeOrphaned(supabase, assignment, tree.path ?? "", result);
|
|
878
995
|
}
|
|
879
996
|
}
|
|
880
|
-
// M2 (ENGINE-EXTRACT): the coding engine the reviewer drives.
|
|
881
|
-
//
|
|
882
|
-
//
|
|
883
|
-
// logic now owned by src/engines/claude-code.ts. buildEnv() is the reviewer's
|
|
884
|
-
// base env (mirrors v0.74-B): the engine forwards ANTHROPIC_API_KEY over it
|
|
885
|
-
// 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.
|
|
886
1000
|
//
|
|
887
|
-
//
|
|
888
|
-
//
|
|
889
|
-
//
|
|
890
|
-
//
|
|
891
|
-
// 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.
|
|
892
1005
|
function defaultSpawnClaude(modelId, engine) {
|
|
893
1006
|
return engine.spawn({ modelId, baseEnv: buildEnv() });
|
|
894
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
|
+
}
|
|
895
1117
|
function parseEnvelope(stdout, fallbackModel, engine) {
|
|
896
1118
|
const parsed = engine.parseResult(stdout);
|
|
897
1119
|
if (!parsed) {
|
|
@@ -920,8 +1142,8 @@ function parseEnvelope(stdout, fallbackModel, engine) {
|
|
|
920
1142
|
export async function runReview(assignment, deps) {
|
|
921
1143
|
const fetchMeta = deps.fetchPRMeta ?? defaultFetchPRMeta;
|
|
922
1144
|
const prepareTree = deps.prepareReviewTree ?? defaultPrepareReviewTree;
|
|
923
|
-
//
|
|
924
|
-
// 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.
|
|
925
1147
|
// `deps.spawnClaude` (test override) is unaffected either way.
|
|
926
1148
|
let outcome;
|
|
927
1149
|
try {
|
|
@@ -937,9 +1159,12 @@ export async function runReview(assignment, deps) {
|
|
|
937
1159
|
outcome = null;
|
|
938
1160
|
}
|
|
939
1161
|
else {
|
|
1162
|
+
const runtimeReason = `runtime_threw: ${err.message?.slice(0, 200)}`;
|
|
940
1163
|
outcome = {
|
|
941
1164
|
decision: "reviewer_error",
|
|
942
|
-
reasons:
|
|
1165
|
+
reasons: isUnresolvableEngineError(err)
|
|
1166
|
+
? [REVIEWER_UNRESOLVABLE_ENGINE_REASON, runtimeReason]
|
|
1167
|
+
: [runtimeReason],
|
|
943
1168
|
confidence: 0,
|
|
944
1169
|
session_id: null,
|
|
945
1170
|
cost_usd: 0,
|
|
@@ -959,32 +1184,258 @@ export async function runReview(assignment, deps) {
|
|
|
959
1184
|
cost_usd: 0,
|
|
960
1185
|
};
|
|
961
1186
|
}
|
|
1187
|
+
// REVIEW-DECISION-PROVENANCE — the persistence seam. Two stamps, both purely
|
|
1188
|
+
// additive to `reasons` and neither touching the verdict:
|
|
1189
|
+
// 1. a SYNTHESIZED decision (prose fallback) renders its marker, so a
|
|
1190
|
+
// keyword-inferred verdict is never stored as if the analysis argued it;
|
|
1191
|
+
// 2. reconcile catches a reject whose own reasons record a clean full pass
|
|
1192
|
+
// and no override rationale (the PR #1913 shape) and flags it.
|
|
1193
|
+
// Applied here, immediately before the RPC, so EVERY path that reaches
|
|
1194
|
+
// submit_review — chunked, single-pass, capacity fallback — is covered.
|
|
1195
|
+
const provenanceNote = outcome.decision_provenance
|
|
1196
|
+
? [decisionProvenanceReason(outcome.decision_provenance)]
|
|
1197
|
+
: [];
|
|
1198
|
+
const reconciled = reconcileReviewDecision({
|
|
1199
|
+
decision: outcome.decision,
|
|
1200
|
+
reasons: [...outcome.reasons, ...provenanceNote],
|
|
1201
|
+
});
|
|
1202
|
+
const submitted = { ...outcome, reasons: reconciled.reasons };
|
|
962
1203
|
const { error } = await deps.supabase.rpc("submit_review", {
|
|
963
1204
|
p_review_id: assignment.review_id,
|
|
964
|
-
p_decision:
|
|
965
|
-
p_reasons:
|
|
966
|
-
p_confidence:
|
|
967
|
-
p_session_id:
|
|
968
|
-
p_cost_usd:
|
|
1205
|
+
p_decision: submitted.decision,
|
|
1206
|
+
p_reasons: submitted.reasons,
|
|
1207
|
+
p_confidence: submitted.confidence,
|
|
1208
|
+
p_session_id: submitted.session_id,
|
|
1209
|
+
p_cost_usd: submitted.cost_usd,
|
|
969
1210
|
});
|
|
970
1211
|
if (error) {
|
|
971
1212
|
process.stderr.write(`[acc-runner] submit_review(${assignment.review_id}) failed: ${error.message}\n`);
|
|
972
1213
|
}
|
|
973
|
-
|
|
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
|
+
}
|
|
1228
|
+
return submitted;
|
|
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";
|
|
974
1290
|
}
|
|
975
1291
|
async function invokeClaude(spawnFn, modelId, prompt) {
|
|
976
1292
|
const spawnedAt = Date.now();
|
|
977
1293
|
const child = spawnFn(modelId);
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
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
|
+
};
|
|
981
1438
|
}
|
|
982
|
-
const [stdout, stderr, exit] = await Promise.all([
|
|
983
|
-
collectStream(child.stdout),
|
|
984
|
-
collectStream(child.stderr),
|
|
985
|
-
child,
|
|
986
|
-
]);
|
|
987
|
-
return { stdout, stderr, exitCode: exit.exitCode ?? 1, durationMs: Date.now() - spawnedAt };
|
|
988
1439
|
}
|
|
989
1440
|
async function driveReview(assignment, deps, fetchMeta, prepareTree) {
|
|
990
1441
|
const { data, error } = await deps.supabase.rpc("fetch_task_for_runner", {
|
|
@@ -1011,12 +1462,6 @@ async function driveReview(assignment, deps, fetchMeta, prepareTree) {
|
|
|
1011
1462
|
cost_usd: 0,
|
|
1012
1463
|
};
|
|
1013
1464
|
}
|
|
1014
|
-
// NS MM-2 (BATCH-ENGINE-DISPATCH): resolve the reviewed task's engine ONCE,
|
|
1015
|
-
// right after the task itself is known — mirrors task-runner.ts's
|
|
1016
|
-
// per-task resolution. `getEngine(null)` (an unstamped/pre-MM-1 row)
|
|
1017
|
-
// resolves the same claude-code default Wave 1 always used.
|
|
1018
|
-
const engine = getEngine(task.engine ?? null);
|
|
1019
|
-
const spawnFn = deps.spawnClaude ?? ((modelId) => defaultSpawnClaude(modelId, engine));
|
|
1020
1465
|
// Look up the org's reviewer policy. The runner-side reviewer needs
|
|
1021
1466
|
// confidence_threshold + max_cost_usd_per_review to stay consistent
|
|
1022
1467
|
// with the cron-side gate, since the cron now just lifts the decision
|
|
@@ -1024,6 +1469,13 @@ async function driveReview(assignment, deps, fetchMeta, prepareTree) {
|
|
|
1024
1469
|
const policy = deps.loadPolicy
|
|
1025
1470
|
? await deps.loadPolicy("")
|
|
1026
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);
|
|
1027
1479
|
const meta = await fetchMeta(repo, assignment.pr_number);
|
|
1028
1480
|
// R0-D-REVIEWER-WORKSPACE-INTEGRITY: materialize the EXACT PR head before
|
|
1029
1481
|
// forming any verdict, or fail closed. On failure we post NO verdict and emit
|
|
@@ -1083,13 +1535,86 @@ async function driveReview(assignment, deps, fetchMeta, prepareTree) {
|
|
|
1083
1535
|
throw err;
|
|
1084
1536
|
}
|
|
1085
1537
|
try {
|
|
1086
|
-
|
|
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 = {
|
|
1087
1548
|
task_description: task.description ?? "",
|
|
1088
1549
|
acceptance_criteria: task.acceptance ?? [],
|
|
1089
1550
|
pr_title: meta.title,
|
|
1090
1551
|
pr_body: meta.body,
|
|
1091
1552
|
pr_diff: tree.diff,
|
|
1092
|
-
}
|
|
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;
|
|
1093
1618
|
}
|
|
1094
1619
|
finally {
|
|
1095
1620
|
// Remove the dedicated review worktree so it can never become a stale
|
|
@@ -1111,9 +1636,34 @@ async function driveReview(assignment, deps, fetchMeta, prepareTree) {
|
|
|
1111
1636
|
* settles as an INFRA outcome the escalation path owns — never a reject.
|
|
1112
1637
|
*/
|
|
1113
1638
|
async function produceVerdict(input, policy, deps, spawnFn, engine) {
|
|
1639
|
+
// REV-COST: ONE cost context per review — the SIZE-SCALED cap plus the
|
|
1640
|
+
// attempt ledger every pass charges into. Derived from the ASSEMBLED diff this
|
|
1641
|
+
// function already holds, so no caller has to change. At or under
|
|
1642
|
+
// REVIEW_CAP_REFERENCE_DIFF_CHARS the cap is EXACTLY the org's flat
|
|
1643
|
+
// `max_cost_usd_per_review` (an ordinary PR is judged against today's number);
|
|
1644
|
+
// above it the cap ramps, bounded by REVIEW_CAP_MAX_MULTIPLE.
|
|
1645
|
+
const costCtx = openReviewCostContext(policy?.max_cost_usd_per_review ?? 0, input.pr_diff.length);
|
|
1114
1646
|
const chunks = planReviewChunks(input.pr_diff, MAX_DIFF_CHARS);
|
|
1115
1647
|
if (chunks.length <= 1) {
|
|
1116
|
-
|
|
1648
|
+
// S15-FU/REVIEW-1 (AC1 + AC2): render, then PROVE the prompt carries the PR
|
|
1649
|
+
// content verbatim. If any value did not survive substitution, the reviewer
|
|
1650
|
+
// would be judging text the repo does not contain (the #1933 corruption
|
|
1651
|
+
// class) — so ABSTAIN before spawning anything. Never a reject, never a cost.
|
|
1652
|
+
const render = renderReviewerPromptChecked(input);
|
|
1653
|
+
if (render.bleed.length > 0) {
|
|
1654
|
+
const detail = describePromptRenderBleed(render.bleed);
|
|
1655
|
+
process.stderr.write(describeAbstainForLog("prompt_render", detail));
|
|
1656
|
+
return {
|
|
1657
|
+
decision: "reviewer_error",
|
|
1658
|
+
reasons: abstainReasons("prompt_render", [
|
|
1659
|
+
`rendered reviewer prompt did not carry PR content verbatim: ${detail}`,
|
|
1660
|
+
]),
|
|
1661
|
+
confidence: 0,
|
|
1662
|
+
session_id: null,
|
|
1663
|
+
cost_usd: 0,
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
return stampReviewCost(await runClaudeAndDecide(render.prompt, policy, deps, spawnFn, engine, costCtx), costCtx);
|
|
1117
1667
|
}
|
|
1118
1668
|
if (chunks.length > MAX_REVIEW_CHUNKS) {
|
|
1119
1669
|
return {
|
|
@@ -1130,7 +1680,7 @@ async function produceVerdict(input, policy, deps, spawnFn, engine) {
|
|
|
1130
1680
|
cost_usd: 0,
|
|
1131
1681
|
};
|
|
1132
1682
|
}
|
|
1133
|
-
return runChunkedReview(input, chunks, policy, deps, spawnFn, engine);
|
|
1683
|
+
return runChunkedReview(input, chunks, policy, deps, spawnFn, engine, costCtx);
|
|
1134
1684
|
}
|
|
1135
1685
|
/**
|
|
1136
1686
|
* REVIEWER-DIFF-CAP — read an over-cap diff in `chunks.length` part passes, then
|
|
@@ -1143,13 +1693,13 @@ async function produceVerdict(input, policy, deps, spawnFn, engine) {
|
|
|
1143
1693
|
* env-broken — short-circuits the whole review with the spend accumulated so far,
|
|
1144
1694
|
* so an infra fault never degrades into a partial-evidence verdict.
|
|
1145
1695
|
*/
|
|
1146
|
-
async function runChunkedReview(input, chunks, policy, deps, spawnFn, engine) {
|
|
1147
|
-
//
|
|
1148
|
-
//
|
|
1149
|
-
|
|
1150
|
-
|
|
1696
|
+
async function runChunkedReview(input, chunks, policy, deps, spawnFn, engine, costCtx) {
|
|
1697
|
+
// REV-COST: the cap is judged ONCE against the WHOLE chunked spend, because
|
|
1698
|
+
// the shared ledger in `costCtx` accumulates across every part pass. That
|
|
1699
|
+
// replaces the old cap-0 `passPolicy` clone: a single part is still never
|
|
1700
|
+
// judged against the whole-review budget, and the whole review is no longer
|
|
1701
|
+
// judged against a FLAT cap.
|
|
1151
1702
|
const passes = [];
|
|
1152
|
-
let cost = 0;
|
|
1153
1703
|
let degraded = false;
|
|
1154
1704
|
let modelUsed;
|
|
1155
1705
|
for (const chunk of chunks) {
|
|
@@ -1157,53 +1707,80 @@ async function runChunkedReview(input, chunks, policy, deps, spawnFn, engine) {
|
|
|
1157
1707
|
// a probe for every chunk. Only the first pass carries it.
|
|
1158
1708
|
const passDeps = chunk.index === 1 ? deps : { ...deps, authCanary: undefined };
|
|
1159
1709
|
const prompt = `${renderReviewerPrompt({ ...input, pr_diff: chunk.text })}\n\n${chunkScopeNotice(chunk)}\n`;
|
|
1160
|
-
|
|
1161
|
-
|
|
1710
|
+
// The RUNAWAY brake lives in finalizeReviewerSuccess, right where the spend
|
|
1711
|
+
// is charged, so a part pass that pushes the WHOLE-review total past the
|
|
1712
|
+
// tolerance short-circuits here as a non-verdict outcome and the remaining
|
|
1713
|
+
// passes never run. An overrun INSIDE the band deliberately does not stop the
|
|
1714
|
+
// review: it finishes and the paid verdict is kept (see the tail).
|
|
1715
|
+
const out = await runClaudeAndDecide(prompt, policy, passDeps, spawnFn, engine, costCtx);
|
|
1716
|
+
const partLedger = reviewCostLedger(costCtx);
|
|
1162
1717
|
modelUsed = out.model_used ?? modelUsed;
|
|
1163
1718
|
degraded = degraded || out.capacity_degraded === true;
|
|
1164
1719
|
if (out.decision !== "approve" && out.decision !== "reject") {
|
|
1165
|
-
return {
|
|
1720
|
+
return {
|
|
1721
|
+
...out,
|
|
1722
|
+
cost_usd: partLedger.total_usd,
|
|
1723
|
+
cost_cap: costCtx.cap,
|
|
1724
|
+
cost_breakdown: partLedger,
|
|
1725
|
+
capacity_degraded: degraded ? true : out.capacity_degraded,
|
|
1726
|
+
};
|
|
1166
1727
|
}
|
|
1167
|
-
if (cap > 0 && cost > cap)
|
|
1168
|
-
return overChunkedCostCap(cost, cap, modelUsed);
|
|
1169
1728
|
passes.push({ decision: out.decision, reasons: out.reasons, confidence: out.confidence });
|
|
1170
1729
|
}
|
|
1171
1730
|
const allPaths = diffFilePaths(input.pr_diff);
|
|
1172
1731
|
const evidence = renderSynthesisEvidence(chunks, passes, allPaths);
|
|
1173
1732
|
const synthPrompt = `${renderReviewerPrompt({ ...input, pr_diff: evidence })}\n\n${synthesisScopeNotice(chunks.length)}\n`;
|
|
1174
|
-
const final = await runClaudeAndDecide(synthPrompt,
|
|
1175
|
-
|
|
1733
|
+
const final = await runClaudeAndDecide(synthPrompt, policy, { ...deps, authCanary: undefined }, spawnFn, engine, costCtx);
|
|
1734
|
+
const ledger = reviewCostLedger(costCtx);
|
|
1176
1735
|
modelUsed = final.model_used ?? modelUsed;
|
|
1177
1736
|
degraded = degraded || final.capacity_degraded === true;
|
|
1178
1737
|
if (final.decision !== "approve" && final.decision !== "reject") {
|
|
1179
|
-
return {
|
|
1738
|
+
return {
|
|
1739
|
+
...final,
|
|
1740
|
+
cost_usd: ledger.total_usd,
|
|
1741
|
+
cost_cap: costCtx.cap,
|
|
1742
|
+
cost_breakdown: ledger,
|
|
1743
|
+
capacity_degraded: degraded ? true : final.capacity_degraded,
|
|
1744
|
+
};
|
|
1180
1745
|
}
|
|
1181
|
-
if (cap > 0 && cost > cap)
|
|
1182
|
-
return overChunkedCostCap(cost, cap, modelUsed);
|
|
1183
1746
|
// Guard FIRST (so a coverage claim can never survive as a verdict), then stamp
|
|
1184
1747
|
// the coverage telemetry — which must never itself be partitioned as a claim.
|
|
1185
1748
|
const guarded = guardCoverageClaims({
|
|
1186
1749
|
...final,
|
|
1187
|
-
cost_usd:
|
|
1750
|
+
cost_usd: ledger.total_usd,
|
|
1188
1751
|
model_used: modelUsed,
|
|
1189
1752
|
capacity_degraded: degraded ? true : undefined,
|
|
1190
1753
|
});
|
|
1754
|
+
// REV-COST: the overrun note (when the paid spend landed in the tolerance
|
|
1755
|
+
// band) sits between the findings and the coverage telemetry — a real reason,
|
|
1756
|
+
// so it must precede chunkedCoverageReason to keep THAT last.
|
|
1757
|
+
const stamped = stampReviewCost(guarded, costCtx);
|
|
1191
1758
|
return {
|
|
1192
|
-
...
|
|
1193
|
-
reasons: [...
|
|
1759
|
+
...stamped,
|
|
1760
|
+
reasons: [...stamped.reasons, chunkedCoverageReason(chunks.length, allPaths.length, input.pr_diff.length)],
|
|
1194
1761
|
};
|
|
1195
1762
|
}
|
|
1196
|
-
/**
|
|
1197
|
-
|
|
1763
|
+
/**
|
|
1764
|
+
* REV-COST — stamp the cost ledger onto a FINISHED review outcome and, when
|
|
1765
|
+
* the chargeable spend landed in the OVERRUN band (over the effective cap, under
|
|
1766
|
+
* the runaway threshold), keep the verdict and add the advisory note.
|
|
1767
|
+
*
|
|
1768
|
+
* Called exactly once per review, after the verdict is assembled — never on a
|
|
1769
|
+
* chunked PART pass, so a budget note can never leak into the synthesis evidence
|
|
1770
|
+
* and be re-read as a finding.
|
|
1771
|
+
*/
|
|
1772
|
+
function stampReviewCost(out, costCtx) {
|
|
1773
|
+
const ledger = reviewCostLedger(costCtx);
|
|
1774
|
+
const isVerdict = out.decision === "approve" || out.decision === "reject";
|
|
1775
|
+
const overrun = isVerdict && classifyReviewCost(ledger.chargeable_usd, costCtx.cap) === "overrun";
|
|
1198
1776
|
return {
|
|
1199
|
-
|
|
1200
|
-
reasons:
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
model_used: modelUsed,
|
|
1777
|
+
...out,
|
|
1778
|
+
reasons: overrun
|
|
1779
|
+
? [...out.reasons, reviewCostOverrunReason(costCtx.cap, ledger)]
|
|
1780
|
+
: out.reasons,
|
|
1781
|
+
cost_cap: costCtx.cap,
|
|
1782
|
+
cost_breakdown: ledger,
|
|
1783
|
+
...(overrun ? { cost_overrun: true } : {}),
|
|
1207
1784
|
};
|
|
1208
1785
|
}
|
|
1209
1786
|
/**
|
|
@@ -1261,57 +1838,90 @@ async function invokeReviewerModel(spawnFn, modelId, prompt, sleepFn, maxCapacit
|
|
|
1261
1838
|
/** RVU-2: a budget-exempt env-broken (auth) reviewer_error. Decision stays
|
|
1262
1839
|
* `reviewer_error` (frozen); the marker reason makes the shared cron treat it
|
|
1263
1840
|
* as budget-exempt and alert once. */
|
|
1264
|
-
function envBrokenOutcome(modelId, detail) {
|
|
1841
|
+
function envBrokenOutcome(modelId, detail, ledger) {
|
|
1265
1842
|
return {
|
|
1266
1843
|
decision: "reviewer_error",
|
|
1267
1844
|
reasons: [REVIEWER_ENV_BROKEN_REASON, `detail: ${(detail ?? "").slice(0, 160)}`],
|
|
1268
1845
|
confidence: 0,
|
|
1846
|
+
// REV-COST: an auth fault costs nothing and is charged to INFRA, never to
|
|
1847
|
+
// the per-review budget — the review still needs its productive attempt.
|
|
1848
|
+
cost_usd: ledger?.total_usd ?? 0,
|
|
1269
1849
|
session_id: null,
|
|
1270
|
-
cost_usd: 0,
|
|
1271
1850
|
model_used: modelId,
|
|
1272
1851
|
env_broken: true,
|
|
1852
|
+
...(ledger ? { cost_breakdown: ledger } : {}),
|
|
1273
1853
|
};
|
|
1274
1854
|
}
|
|
1275
1855
|
/** RVU-2: assemble the outcome from a clean (exit 0) reviewer envelope. Cost-cap
|
|
1276
|
-
* parity + parse are unchanged; `degraded` marks a re-routed (lower-tier) run.
|
|
1277
|
-
|
|
1856
|
+
* parity + parse are unchanged; `degraded` marks a re-routed (lower-tier) run.
|
|
1857
|
+
* REV-COST: the cap is now the size-scaled decision carried in `costCtx`, and
|
|
1858
|
+
* only the RUNAWAY band refuses the verdict here. */
|
|
1859
|
+
function finalizeReviewerSuccess(stdout, modelId, degraded, costCtx, engine) {
|
|
1278
1860
|
const envelope = parseEnvelope(stdout, modelId, engine);
|
|
1279
|
-
//
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1861
|
+
// REV-COST: the spawn returned a readable envelope, so this is the review's
|
|
1862
|
+
// PRODUCTIVE attempt — the only class charged to the per-review cap. Parity
|
|
1863
|
+
// with api/_lib/reviewer-agent.ts, which charges at exactly this point.
|
|
1864
|
+
const ledger = chargeReviewAttempt(costCtx, modelId, "productive", envelope.cost_usd);
|
|
1865
|
+
// REV-COST RUNAWAY BACKSTOP. Past `cap × REVIEW_COST_OVERRUN_TOLERANCE`
|
|
1866
|
+
// something is pathological, so the verdict is still refused. Inside the band
|
|
1867
|
+
// the spend is judged AFTER the verdict is assembled (stampReviewCost): the
|
|
1868
|
+
// money is already gone, and discarding a paid verdict only stranded the PR
|
|
1869
|
+
// (#1937 — $18 spent, $8 flat cap, NO verdict) and re-charged the same review
|
|
1870
|
+
// on the next tick. A cap of 0 disables the whole check, as before.
|
|
1871
|
+
if (classifyReviewCost(ledger.chargeable_usd, costCtx.cap) === "runaway") {
|
|
1283
1872
|
return {
|
|
1284
1873
|
decision: "cost_cap_exceeded",
|
|
1285
|
-
reasons: [
|
|
1286
|
-
`reviewer cost $${envelope.cost_usd.toFixed(4)} exceeded cap $${policy.max_cost_usd_per_review.toFixed(2)}`,
|
|
1287
|
-
],
|
|
1874
|
+
reasons: [reviewCostRunawayReason(costCtx.cap, ledger)],
|
|
1288
1875
|
confidence: 0,
|
|
1289
1876
|
session_id: envelope.session_id,
|
|
1290
|
-
cost_usd:
|
|
1877
|
+
cost_usd: ledger.total_usd,
|
|
1291
1878
|
model_used: modelId,
|
|
1879
|
+
cost_cap: costCtx.cap,
|
|
1880
|
+
cost_breakdown: ledger,
|
|
1292
1881
|
};
|
|
1293
1882
|
}
|
|
1294
1883
|
const decision = extractReviewerDecision(envelope.result);
|
|
1295
1884
|
if (!decision) {
|
|
1885
|
+
// S15-FU/REVIEW-1 (AC2): a PARSE FAULT is an explicit ABSTAIN. The unparsed
|
|
1886
|
+
// output head is carried as QUARANTINED context (never a finding) so an
|
|
1887
|
+
// operator can diagnose the format drift without any of that text being
|
|
1888
|
+
// scored as a defect.
|
|
1889
|
+
process.stderr.write(describeAbstainForLog("verdict_parse"));
|
|
1296
1890
|
return {
|
|
1297
1891
|
decision: "reviewer_error",
|
|
1298
|
-
reasons: [
|
|
1892
|
+
reasons: abstainReasons("verdict_parse", [
|
|
1299
1893
|
"could not parse reviewer JSON from claude output",
|
|
1300
1894
|
`output_head: ${envelope.result.slice(0, 200)}`,
|
|
1301
|
-
],
|
|
1895
|
+
]),
|
|
1302
1896
|
confidence: 0,
|
|
1303
1897
|
session_id: envelope.session_id,
|
|
1304
|
-
cost_usd:
|
|
1898
|
+
cost_usd: ledger.total_usd,
|
|
1305
1899
|
model_used: modelId,
|
|
1900
|
+
cost_cap: costCtx.cap,
|
|
1901
|
+
cost_breakdown: ledger,
|
|
1306
1902
|
};
|
|
1307
1903
|
}
|
|
1308
|
-
|
|
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({
|
|
1309
1910
|
decision: decision.decision,
|
|
1310
1911
|
reasons: decision.reasons,
|
|
1912
|
+
});
|
|
1913
|
+
return {
|
|
1914
|
+
decision: adjudicated.decision,
|
|
1915
|
+
reasons: adjudicated.reasons,
|
|
1311
1916
|
confidence: decision.confidence,
|
|
1312
1917
|
session_id: envelope.session_id,
|
|
1313
1918
|
cost_usd: envelope.cost_usd,
|
|
1314
1919
|
model_used: modelId,
|
|
1920
|
+
// REVIEW-DECISION-PROVENANCE: carried as a field (never a reason) so it
|
|
1921
|
+
// survives the coverage/absence guards without being read as a finding.
|
|
1922
|
+
...(decision.decision_provenance
|
|
1923
|
+
? { decision_provenance: decision.decision_provenance }
|
|
1924
|
+
: {}),
|
|
1315
1925
|
// Signal capacity pressure to watch.ts only when the review actually
|
|
1316
1926
|
// re-routed to a lower tier (leave undefined on the primary-model path).
|
|
1317
1927
|
capacity_degraded: degraded ? true : undefined,
|
|
@@ -1326,12 +1936,17 @@ function finalizeReviewerSuccess(stdout, modelId, degraded, policy, engine) {
|
|
|
1326
1936
|
* explicit broken CLI-auth signal surfaces as budget-exempt env_broken rather
|
|
1327
1937
|
* than looping. Cost-cap / parse behaviour is unchanged.
|
|
1328
1938
|
*/
|
|
1329
|
-
async function runClaudeAndDecide(prompt, policy, deps, spawnFn, engine) {
|
|
1939
|
+
async function runClaudeAndDecide(prompt, policy, deps, spawnFn, engine, costCtx) {
|
|
1330
1940
|
const sleepFn = deps.sleep ?? defaultSleep;
|
|
1331
|
-
const baseModel = policy?.model_id ?? "claude-sonnet-4-6";
|
|
1332
1941
|
const maxCapacityRetries = resolveCapacityRetries(process.env[CAPACITY_RETRIES_ENV]);
|
|
1333
|
-
|
|
1334
|
-
|
|
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`;
|
|
1335
1950
|
// RVU-2: optional env-broken CLI-auth PREFLIGHT canary. A broken login /
|
|
1336
1951
|
// rejected key is caught BEFORE spawning the reviewer against a dead
|
|
1337
1952
|
// credential, so it never loops as a spurious reviewer_error.
|
|
@@ -1344,18 +1959,23 @@ async function runClaudeAndDecide(prompt, policy, deps, spawnFn, engine) {
|
|
|
1344
1959
|
probes = [];
|
|
1345
1960
|
}
|
|
1346
1961
|
if (isEnvBrokenReviewerProbe(probes)) {
|
|
1347
|
-
return envBrokenOutcome(primaryModel, "preflight auth canary: repeated silent CLI exit (broken login / rejected key)");
|
|
1962
|
+
return envBrokenOutcome(primaryModel, "preflight auth canary: repeated silent CLI exit (broken login / rejected key)", chargeReviewAttempt(costCtx, primaryModel, "infra_auth", 0));
|
|
1348
1963
|
}
|
|
1349
1964
|
}
|
|
1350
1965
|
let lastCapacityDetail = "reviewer capacity exhausted across all fallback tiers";
|
|
1351
1966
|
let lastResumeMs = null;
|
|
1352
1967
|
for (let i = 0; i < chain.length; i++) {
|
|
1353
|
-
|
|
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);
|
|
1354
1974
|
const isLast = i === chain.length - 1;
|
|
1355
|
-
const invokeResult = await invokeReviewerModel(spawnFn,
|
|
1356
|
-
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;
|
|
1357
1977
|
if (exitCode === 0) {
|
|
1358
|
-
return finalizeReviewerSuccess(stdout, modelId, modelId !== primaryModel,
|
|
1978
|
+
return finalizeReviewerSuccess(stdout, modelId, modelId !== primaryModel, costCtx, engine);
|
|
1359
1979
|
}
|
|
1360
1980
|
const classified = engine.classifyFailure(exitCode, claudeStderr, stdout, durationMs);
|
|
1361
1981
|
const combined = `${claudeStderr}\n${stdout}`;
|
|
@@ -1367,7 +1987,7 @@ async function runClaudeAndDecide(prompt, policy, deps, spawnFn, engine) {
|
|
|
1367
1987
|
if (classified.class === "auth_expired" ||
|
|
1368
1988
|
classified.class === "env_broken" ||
|
|
1369
1989
|
isReviewerAuthFailure(combined)) {
|
|
1370
|
-
return envBrokenOutcome(modelId, classified.detail);
|
|
1990
|
+
return envBrokenOutcome(modelId, classified.detail, chargeReviewAttempt(costCtx, modelId, "infra_auth", 0));
|
|
1371
1991
|
}
|
|
1372
1992
|
const isCapacity = classified.class === "capacity_exhausted" ||
|
|
1373
1993
|
classified.class === "claude_unavailable";
|
|
@@ -1376,6 +1996,10 @@ async function runClaudeAndDecide(prompt, policy, deps, spawnFn, engine) {
|
|
|
1376
1996
|
const resumeMs = extractResetTime(combined);
|
|
1377
1997
|
if (resumeMs !== null)
|
|
1378
1998
|
lastResumeMs = resumeMs;
|
|
1999
|
+
// REV-COST: a 429/overload brownout is INFRA. Recording it makes "N
|
|
2000
|
+
// attempts on one review" visible WITHOUT any of those attempts consuming
|
|
2001
|
+
// the per-review budget the productive attempt needs.
|
|
2002
|
+
const capacityLedger = chargeReviewAttempt(costCtx, modelId, "infra_capacity", 0);
|
|
1379
2003
|
// Re-route to the next lower catalog tier instead of stalling the lane.
|
|
1380
2004
|
if (!isLast)
|
|
1381
2005
|
continue;
|
|
@@ -1389,22 +2013,39 @@ async function runClaudeAndDecide(prompt, policy, deps, spawnFn, engine) {
|
|
|
1389
2013
|
session_id: null,
|
|
1390
2014
|
cost_usd: 0,
|
|
1391
2015
|
resume_at: lastResumeMs !== null ? new Date(lastResumeMs).toISOString() : null,
|
|
2016
|
+
engine: engine.id,
|
|
1392
2017
|
model_used: modelId,
|
|
1393
2018
|
capacity_degraded: true,
|
|
2019
|
+
cost_cap: costCtx.cap,
|
|
2020
|
+
cost_breakdown: capacityLedger,
|
|
1394
2021
|
};
|
|
1395
2022
|
}
|
|
1396
2023
|
// Genuine error (task_error / usage_limit) — legacy reviewer_error, no
|
|
1397
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;
|
|
1398
2040
|
return {
|
|
1399
2041
|
decision: "reviewer_error",
|
|
1400
|
-
reasons
|
|
1401
|
-
`claude exited ${exitCode}`,
|
|
1402
|
-
...(claudeStderr ? [`stderr: ${claudeStderr.slice(0, 200)}`] : []),
|
|
1403
|
-
],
|
|
2042
|
+
reasons,
|
|
1404
2043
|
confidence: 0,
|
|
1405
2044
|
session_id: null,
|
|
1406
2045
|
cost_usd: 0,
|
|
1407
2046
|
model_used: modelId,
|
|
2047
|
+
stdout_tail: diagnostics.stdoutTail,
|
|
2048
|
+
stderr_tail: diagnostics.stderrTail,
|
|
1408
2049
|
};
|
|
1409
2050
|
}
|
|
1410
2051
|
// Unreachable in practice (chain always has ≥1 entry): a blank policy model
|