@tokenfactory/acc-runner 0.43.0 → 0.44.1
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 +75 -3
- package/dist/companion-run.d.ts.map +1 -1
- package/dist/companion-run.js +20 -0
- package/dist/companion-run.js.map +1 -1
- package/dist/config.d.ts +72 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +117 -3
- package/dist/config.js.map +1 -1
- package/dist/cost-pricing.d.ts +45 -0
- package/dist/cost-pricing.d.ts.map +1 -1
- package/dist/cost-pricing.js +57 -0
- package/dist/cost-pricing.js.map +1 -1
- package/dist/doctor.d.ts +4 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +32 -3
- package/dist/doctor.js.map +1 -1
- package/dist/engines/registry.d.ts +17 -6
- package/dist/engines/registry.d.ts.map +1 -1
- package/dist/engines/registry.js +38 -13
- package/dist/engines/registry.js.map +1 -1
- package/dist/git-auth/github-token-source.d.ts +54 -0
- package/dist/git-auth/github-token-source.d.ts.map +1 -0
- package/dist/git-auth/github-token-source.js +44 -0
- package/dist/git-auth/github-token-source.js.map +1 -0
- package/dist/git-auth/index.d.ts +4 -0
- package/dist/git-auth/index.d.ts.map +1 -1
- package/dist/git-auth/index.js +4 -0
- package/dist/git-auth/index.js.map +1 -1
- package/dist/git-auth/installation-token.d.ts +95 -0
- package/dist/git-auth/installation-token.d.ts.map +1 -0
- package/dist/git-auth/installation-token.js +192 -0
- package/dist/git-auth/installation-token.js.map +1 -0
- 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 +4 -8
- package/dist/keychain.d.ts.map +1 -1
- package/dist/keychain.js +10 -4
- package/dist/keychain.js.map +1 -1
- package/dist/local-repo/command.d.ts +28 -0
- package/dist/local-repo/command.d.ts.map +1 -0
- package/dist/local-repo/command.js +178 -0
- package/dist/local-repo/command.js.map +1 -0
- package/dist/local-repo/index.d.ts +119 -0
- package/dist/local-repo/index.d.ts.map +1 -0
- package/dist/local-repo/index.js +141 -0
- package/dist/local-repo/index.js.map +1 -0
- package/dist/login.d.ts +4 -0
- package/dist/login.d.ts.map +1 -1
- package/dist/login.js +6 -1
- package/dist/login.js.map +1 -1
- package/dist/mcp-spawn.d.ts +19 -0
- package/dist/mcp-spawn.d.ts.map +1 -1
- package/dist/mcp-spawn.js +52 -26
- 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/program.d.ts.map +1 -1
- package/dist/program.js +34 -0
- package/dist/program.js.map +1 -1
- package/dist/provider-auth.d.ts +70 -1
- package/dist/provider-auth.d.ts.map +1 -1
- package/dist/provider-auth.js +106 -2
- 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/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 +75 -0
- package/dist/runtime/reviewer.d.ts.map +1 -1
- package/dist/runtime/reviewer.js +235 -81
- package/dist/runtime/reviewer.js.map +1 -1
- 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/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 +19 -2
- package/dist/task-runner.d.ts.map +1 -1
- package/dist/task-runner.js +110 -28
- package/dist/task-runner.js.map +1 -1
- package/dist/watch.d.ts +13 -0
- package/dist/watch.d.ts.map +1 -1
- package/dist/watch.js +128 -20
- package/dist/watch.js.map +1 -1
- package/package.json +3 -2
package/dist/runtime/reviewer.js
CHANGED
|
@@ -24,6 +24,10 @@ import { prepareReviewWorktree, ReviewFetchError } from "./worktree.js";
|
|
|
24
24
|
import { isEmptyReviewDiff } from "../review-diff-guard.js";
|
|
25
25
|
import { diffFilePaths, splitDiffByFile } from "./review-diff.js";
|
|
26
26
|
import { planReviewChunks, partitionAbsenceClaims, MAX_REVIEW_CHUNKS, REVIEWER_ABSENCE_ESCALATION_REASON, DISCARDED_CLAIM_PREFIX, } from "./review-chunking.js";
|
|
27
|
+
import { decisionProvenanceReason, reconcileReviewDecision, PROSE_KEYWORD_OVERRIDE_REASON, } from "./decision-provenance.js";
|
|
28
|
+
import { describePromptRenderBleed, findPromptRenderBleed, substitutePlaceholders, } from "./prompt-substitute.js";
|
|
29
|
+
import { abstainReasons, describeAbstainForLog, } from "./reviewer-abstain.js";
|
|
30
|
+
import { chargeReviewAttempt, classifyReviewCost, openReviewCostContext, reviewCostLedger, reviewCostOverrunReason, reviewCostRunawayReason, } from "./review-cost-cap.js";
|
|
27
31
|
import { classifyTeardownFailure } from "../review-worktree-cleanup.js";
|
|
28
32
|
// Byte-identical to prompts/reviewer-agent.md. The runner does not have
|
|
29
33
|
// the markdown file at runtime (the package ships without prompts/) so
|
|
@@ -516,7 +520,7 @@ export function isRetriableError(stderr) {
|
|
|
516
520
|
function defaultSleep(ms) {
|
|
517
521
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
518
522
|
}
|
|
519
|
-
export function
|
|
523
|
+
export function renderReviewerPromptChecked(args) {
|
|
520
524
|
// RF-3: ONE truncation policy — float the AC-named proof files to the front,
|
|
521
525
|
// cut at a whole-file boundary, and name what was withheld. Under the cap
|
|
522
526
|
// nothing reorders and no notice renders, so the prompt is byte-identical to
|
|
@@ -538,14 +542,41 @@ export function renderReviewerPrompt(args) {
|
|
|
538
542
|
const acceptance = (args.acceptance_criteria ?? [])
|
|
539
543
|
.map((a, i) => `${i + 1}. ${a}`)
|
|
540
544
|
.join("\n");
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
545
|
+
// S15-FU/REVIEW-1 (AC1): substitute VERBATIM. `String.replace` with a string
|
|
546
|
+
// replacement expands `$$`/`$&`/`` $` ``/`$'`/`$n`, so a diff containing `$'`
|
|
547
|
+
// — e.g. the clean literal `... where seq ~ '^[0-9]+$' order by 1` — used to
|
|
548
|
+
// splice the template text FOLLOWING `{{pr_diff}}` ("Now answer in this exact
|
|
549
|
+
// JSON shape…") into the diff, and the reviewer then reported that prose as a
|
|
550
|
+
// corruption defect in the file (#1933). Never re-introduce a string
|
|
551
|
+
// replacement here; see ./prompt-substitute.ts.
|
|
552
|
+
const pairs = reviewerPlaceholderPairs(args, diff, acceptance);
|
|
553
|
+
const rendered = substitutePlaceholders(REVIEWER_PROMPT_TEMPLATE, pairs);
|
|
547
554
|
const note = truncationInfraNote(omitted, partialFile, prioritized.reordered);
|
|
548
|
-
|
|
555
|
+
const prompt = note.length > 0 ? `${rendered}\n\n${note}\n` : rendered;
|
|
556
|
+
return { prompt, bleed: findPromptRenderBleed(REVIEWER_PROMPT_TEMPLATE, prompt, pairs) };
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* S15-FU/REVIEW-1 (AC1): the ORDERED placeholder/value pairs a reviewer prompt
|
|
560
|
+
* substitutes, so the render-integrity check (and its test) can assert that every
|
|
561
|
+
* one of these values survived into the prompt VERBATIM.
|
|
562
|
+
* // keep in sync with reviewerPlaceholderPairs in api/_lib/reviewer-agent.ts
|
|
563
|
+
*/
|
|
564
|
+
export function reviewerPlaceholderPairs(args, diff, acceptance) {
|
|
565
|
+
return [
|
|
566
|
+
["{{task_description}}", args.task_description ?? ""],
|
|
567
|
+
["{{acceptance_criteria}}", acceptance],
|
|
568
|
+
["{{pr_title}}", args.pr_title ?? ""],
|
|
569
|
+
["{{pr_body}}", args.pr_body ?? ""],
|
|
570
|
+
["{{pr_diff}}", diff],
|
|
571
|
+
];
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* String façade over `renderReviewerPromptChecked` — the signature every existing
|
|
575
|
+
* caller and the RF-3 parity tests use. Integrity is enforced at the review seam,
|
|
576
|
+
* which needs the `bleed` list to build its abstain.
|
|
577
|
+
*/
|
|
578
|
+
export function renderReviewerPrompt(args) {
|
|
579
|
+
return renderReviewerPromptChecked(args).prompt;
|
|
549
580
|
}
|
|
550
581
|
/* ─────────── REVIEWER-DIFF-CAP: chunked review of an over-cap diff ───────────
|
|
551
582
|
*
|
|
@@ -740,7 +771,18 @@ export function extractReviewerDecision(text) {
|
|
|
740
771
|
"parsed from prose reviewer output (no JSON found)",
|
|
741
772
|
`decision_keyword: ${decision}`,
|
|
742
773
|
];
|
|
743
|
-
|
|
774
|
+
// REVIEW-DECISION-PROVENANCE: this verdict was INFERRED from a keyword, not
|
|
775
|
+
// asserted. PR #1913 persisted a reject this way over an all-"Met." analysis
|
|
776
|
+
// with no trace of the inference; runReview stamps the marker at submit time.
|
|
777
|
+
return {
|
|
778
|
+
decision,
|
|
779
|
+
reasons,
|
|
780
|
+
confidence,
|
|
781
|
+
decision_provenance: {
|
|
782
|
+
decision_source: "prose_keyword",
|
|
783
|
+
override_reason: PROSE_KEYWORD_OVERRIDE_REASON,
|
|
784
|
+
},
|
|
785
|
+
};
|
|
744
786
|
}
|
|
745
787
|
async function defaultFetchPRMeta(repo, prNumber) {
|
|
746
788
|
const { stdout } = await execa("gh", ["pr", "view", String(prNumber), "-R", repo, "--json", "title,body"], { env: buildEnv() });
|
|
@@ -877,16 +919,22 @@ async function cleanupReviewTree(tree, supabase, assignment) {
|
|
|
877
919
|
await emitReviewWorktreeOrphaned(supabase, assignment, tree.path ?? "", result);
|
|
878
920
|
}
|
|
879
921
|
}
|
|
880
|
-
// M2 (ENGINE-EXTRACT): the coding engine the reviewer drives
|
|
881
|
-
// `claude-code`
|
|
882
|
-
// byte-identical to the pre-refactor inline
|
|
883
|
-
// src/engines/claude-code.ts. buildEnv() is the reviewer's
|
|
884
|
-
// v0.74-B): the engine forwards ANTHROPIC_API_KEY over it
|
|
885
|
-
|
|
886
|
-
|
|
922
|
+
// M2 (ENGINE-EXTRACT): the coding engine the reviewer drives. Wave 1 fixed
|
|
923
|
+
// this to a single module-level `claude-code` constant — spawn/argv/result-
|
|
924
|
+
// parse/failure-class behavior was byte-identical to the pre-refactor inline
|
|
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.
|
|
928
|
+
//
|
|
929
|
+
// NS MM-2 (BATCH-ENGINE-DISPATCH): the reviewer now resolves the engine PER
|
|
930
|
+
// REVIEW from the reviewed task's `engine` column (`driveReview` below), so it
|
|
931
|
+
// drives the same CLI the runner built the PR with instead of always assuming
|
|
932
|
+
// claude-code. `getEngine(id)` defaults to claude-code on a null/blank id —
|
|
933
|
+
// unstamped/pre-MM-1 rows are byte-identical to Wave 1.
|
|
934
|
+
function defaultSpawnClaude(modelId, engine) {
|
|
887
935
|
return engine.spawn({ modelId, baseEnv: buildEnv() });
|
|
888
936
|
}
|
|
889
|
-
function parseEnvelope(stdout, fallbackModel) {
|
|
937
|
+
function parseEnvelope(stdout, fallbackModel, engine) {
|
|
890
938
|
const parsed = engine.parseResult(stdout);
|
|
891
939
|
if (!parsed) {
|
|
892
940
|
return { result: stdout.trim(), session_id: null, cost_usd: 0, model: fallbackModel };
|
|
@@ -914,10 +962,12 @@ function parseEnvelope(stdout, fallbackModel) {
|
|
|
914
962
|
export async function runReview(assignment, deps) {
|
|
915
963
|
const fetchMeta = deps.fetchPRMeta ?? defaultFetchPRMeta;
|
|
916
964
|
const prepareTree = deps.prepareReviewTree ?? defaultPrepareReviewTree;
|
|
917
|
-
|
|
965
|
+
// NS MM-2: `spawnFn`'s default needs the REVIEWED task's `engine` column,
|
|
966
|
+
// which driveReview fetches — so its binding moves there instead of here.
|
|
967
|
+
// `deps.spawnClaude` (test override) is unaffected either way.
|
|
918
968
|
let outcome;
|
|
919
969
|
try {
|
|
920
|
-
outcome = await driveReview(assignment, deps, fetchMeta, prepareTree
|
|
970
|
+
outcome = await driveReview(assignment, deps, fetchMeta, prepareTree);
|
|
921
971
|
}
|
|
922
972
|
catch (err) {
|
|
923
973
|
// V96 (ADDED SCOPE): a worktree teardown crash that escaped driveReview
|
|
@@ -951,18 +1001,34 @@ export async function runReview(assignment, deps) {
|
|
|
951
1001
|
cost_usd: 0,
|
|
952
1002
|
};
|
|
953
1003
|
}
|
|
1004
|
+
// REVIEW-DECISION-PROVENANCE — the persistence seam. Two stamps, both purely
|
|
1005
|
+
// additive to `reasons` and neither touching the verdict:
|
|
1006
|
+
// 1. a SYNTHESIZED decision (prose fallback) renders its marker, so a
|
|
1007
|
+
// keyword-inferred verdict is never stored as if the analysis argued it;
|
|
1008
|
+
// 2. reconcile catches a reject whose own reasons record a clean full pass
|
|
1009
|
+
// and no override rationale (the PR #1913 shape) and flags it.
|
|
1010
|
+
// Applied here, immediately before the RPC, so EVERY path that reaches
|
|
1011
|
+
// submit_review — chunked, single-pass, capacity fallback — is covered.
|
|
1012
|
+
const provenanceNote = outcome.decision_provenance
|
|
1013
|
+
? [decisionProvenanceReason(outcome.decision_provenance)]
|
|
1014
|
+
: [];
|
|
1015
|
+
const reconciled = reconcileReviewDecision({
|
|
1016
|
+
decision: outcome.decision,
|
|
1017
|
+
reasons: [...outcome.reasons, ...provenanceNote],
|
|
1018
|
+
});
|
|
1019
|
+
const submitted = { ...outcome, reasons: reconciled.reasons };
|
|
954
1020
|
const { error } = await deps.supabase.rpc("submit_review", {
|
|
955
1021
|
p_review_id: assignment.review_id,
|
|
956
|
-
p_decision:
|
|
957
|
-
p_reasons:
|
|
958
|
-
p_confidence:
|
|
959
|
-
p_session_id:
|
|
960
|
-
p_cost_usd:
|
|
1022
|
+
p_decision: submitted.decision,
|
|
1023
|
+
p_reasons: submitted.reasons,
|
|
1024
|
+
p_confidence: submitted.confidence,
|
|
1025
|
+
p_session_id: submitted.session_id,
|
|
1026
|
+
p_cost_usd: submitted.cost_usd,
|
|
961
1027
|
});
|
|
962
1028
|
if (error) {
|
|
963
1029
|
process.stderr.write(`[acc-runner] submit_review(${assignment.review_id}) failed: ${error.message}\n`);
|
|
964
1030
|
}
|
|
965
|
-
return
|
|
1031
|
+
return submitted;
|
|
966
1032
|
}
|
|
967
1033
|
async function invokeClaude(spawnFn, modelId, prompt) {
|
|
968
1034
|
const spawnedAt = Date.now();
|
|
@@ -978,7 +1044,7 @@ async function invokeClaude(spawnFn, modelId, prompt) {
|
|
|
978
1044
|
]);
|
|
979
1045
|
return { stdout, stderr, exitCode: exit.exitCode ?? 1, durationMs: Date.now() - spawnedAt };
|
|
980
1046
|
}
|
|
981
|
-
async function driveReview(assignment, deps, fetchMeta, prepareTree
|
|
1047
|
+
async function driveReview(assignment, deps, fetchMeta, prepareTree) {
|
|
982
1048
|
const { data, error } = await deps.supabase.rpc("fetch_task_for_runner", {
|
|
983
1049
|
p_task_id: assignment.task_id,
|
|
984
1050
|
});
|
|
@@ -1003,6 +1069,12 @@ async function driveReview(assignment, deps, fetchMeta, prepareTree, spawnFn) {
|
|
|
1003
1069
|
cost_usd: 0,
|
|
1004
1070
|
};
|
|
1005
1071
|
}
|
|
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));
|
|
1006
1078
|
// Look up the org's reviewer policy. The runner-side reviewer needs
|
|
1007
1079
|
// confidence_threshold + max_cost_usd_per_review to stay consistent
|
|
1008
1080
|
// with the cron-side gate, since the cron now just lifts the decision
|
|
@@ -1075,7 +1147,7 @@ async function driveReview(assignment, deps, fetchMeta, prepareTree, spawnFn) {
|
|
|
1075
1147
|
pr_title: meta.title,
|
|
1076
1148
|
pr_body: meta.body,
|
|
1077
1149
|
pr_diff: tree.diff,
|
|
1078
|
-
}, policy, deps, spawnFn);
|
|
1150
|
+
}, policy, deps, spawnFn, engine);
|
|
1079
1151
|
}
|
|
1080
1152
|
finally {
|
|
1081
1153
|
// Remove the dedicated review worktree so it can never become a stale
|
|
@@ -1096,10 +1168,35 @@ async function driveReview(assignment, deps, fetchMeta, prepareTree, spawnFn) {
|
|
|
1096
1168
|
* Past `MAX_REVIEW_CHUNKS` even chunking is not a sane spend, so the review
|
|
1097
1169
|
* settles as an INFRA outcome the escalation path owns — never a reject.
|
|
1098
1170
|
*/
|
|
1099
|
-
async function produceVerdict(input, policy, deps, spawnFn) {
|
|
1171
|
+
async function produceVerdict(input, policy, deps, spawnFn, engine) {
|
|
1172
|
+
// REV-COST: ONE cost context per review — the SIZE-SCALED cap plus the
|
|
1173
|
+
// attempt ledger every pass charges into. Derived from the ASSEMBLED diff this
|
|
1174
|
+
// function already holds, so no caller has to change. At or under
|
|
1175
|
+
// REVIEW_CAP_REFERENCE_DIFF_CHARS the cap is EXACTLY the org's flat
|
|
1176
|
+
// `max_cost_usd_per_review` (an ordinary PR is judged against today's number);
|
|
1177
|
+
// above it the cap ramps, bounded by REVIEW_CAP_MAX_MULTIPLE.
|
|
1178
|
+
const costCtx = openReviewCostContext(policy?.max_cost_usd_per_review ?? 0, input.pr_diff.length);
|
|
1100
1179
|
const chunks = planReviewChunks(input.pr_diff, MAX_DIFF_CHARS);
|
|
1101
1180
|
if (chunks.length <= 1) {
|
|
1102
|
-
|
|
1181
|
+
// S15-FU/REVIEW-1 (AC1 + AC2): render, then PROVE the prompt carries the PR
|
|
1182
|
+
// content verbatim. If any value did not survive substitution, the reviewer
|
|
1183
|
+
// would be judging text the repo does not contain (the #1933 corruption
|
|
1184
|
+
// class) — so ABSTAIN before spawning anything. Never a reject, never a cost.
|
|
1185
|
+
const render = renderReviewerPromptChecked(input);
|
|
1186
|
+
if (render.bleed.length > 0) {
|
|
1187
|
+
const detail = describePromptRenderBleed(render.bleed);
|
|
1188
|
+
process.stderr.write(describeAbstainForLog("prompt_render", detail));
|
|
1189
|
+
return {
|
|
1190
|
+
decision: "reviewer_error",
|
|
1191
|
+
reasons: abstainReasons("prompt_render", [
|
|
1192
|
+
`rendered reviewer prompt did not carry PR content verbatim: ${detail}`,
|
|
1193
|
+
]),
|
|
1194
|
+
confidence: 0,
|
|
1195
|
+
session_id: null,
|
|
1196
|
+
cost_usd: 0,
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
return stampReviewCost(await runClaudeAndDecide(render.prompt, policy, deps, spawnFn, engine, costCtx), costCtx);
|
|
1103
1200
|
}
|
|
1104
1201
|
if (chunks.length > MAX_REVIEW_CHUNKS) {
|
|
1105
1202
|
return {
|
|
@@ -1116,7 +1213,7 @@ async function produceVerdict(input, policy, deps, spawnFn) {
|
|
|
1116
1213
|
cost_usd: 0,
|
|
1117
1214
|
};
|
|
1118
1215
|
}
|
|
1119
|
-
return runChunkedReview(input, chunks, policy, deps, spawnFn);
|
|
1216
|
+
return runChunkedReview(input, chunks, policy, deps, spawnFn, engine, costCtx);
|
|
1120
1217
|
}
|
|
1121
1218
|
/**
|
|
1122
1219
|
* REVIEWER-DIFF-CAP — read an over-cap diff in `chunks.length` part passes, then
|
|
@@ -1129,13 +1226,13 @@ async function produceVerdict(input, policy, deps, spawnFn) {
|
|
|
1129
1226
|
* env-broken — short-circuits the whole review with the spend accumulated so far,
|
|
1130
1227
|
* so an infra fault never degrades into a partial-evidence verdict.
|
|
1131
1228
|
*/
|
|
1132
|
-
async function runChunkedReview(input, chunks, policy, deps, spawnFn) {
|
|
1133
|
-
//
|
|
1134
|
-
//
|
|
1135
|
-
|
|
1136
|
-
|
|
1229
|
+
async function runChunkedReview(input, chunks, policy, deps, spawnFn, engine, costCtx) {
|
|
1230
|
+
// REV-COST: the cap is judged ONCE against the WHOLE chunked spend, because
|
|
1231
|
+
// the shared ledger in `costCtx` accumulates across every part pass. That
|
|
1232
|
+
// replaces the old cap-0 `passPolicy` clone: a single part is still never
|
|
1233
|
+
// judged against the whole-review budget, and the whole review is no longer
|
|
1234
|
+
// judged against a FLAT cap.
|
|
1137
1235
|
const passes = [];
|
|
1138
|
-
let cost = 0;
|
|
1139
1236
|
let degraded = false;
|
|
1140
1237
|
let modelUsed;
|
|
1141
1238
|
for (const chunk of chunks) {
|
|
@@ -1143,53 +1240,80 @@ async function runChunkedReview(input, chunks, policy, deps, spawnFn) {
|
|
|
1143
1240
|
// a probe for every chunk. Only the first pass carries it.
|
|
1144
1241
|
const passDeps = chunk.index === 1 ? deps : { ...deps, authCanary: undefined };
|
|
1145
1242
|
const prompt = `${renderReviewerPrompt({ ...input, pr_diff: chunk.text })}\n\n${chunkScopeNotice(chunk)}\n`;
|
|
1146
|
-
|
|
1147
|
-
|
|
1243
|
+
// The RUNAWAY brake lives in finalizeReviewerSuccess, right where the spend
|
|
1244
|
+
// is charged, so a part pass that pushes the WHOLE-review total past the
|
|
1245
|
+
// tolerance short-circuits here as a non-verdict outcome and the remaining
|
|
1246
|
+
// passes never run. An overrun INSIDE the band deliberately does not stop the
|
|
1247
|
+
// review: it finishes and the paid verdict is kept (see the tail).
|
|
1248
|
+
const out = await runClaudeAndDecide(prompt, policy, passDeps, spawnFn, engine, costCtx);
|
|
1249
|
+
const partLedger = reviewCostLedger(costCtx);
|
|
1148
1250
|
modelUsed = out.model_used ?? modelUsed;
|
|
1149
1251
|
degraded = degraded || out.capacity_degraded === true;
|
|
1150
1252
|
if (out.decision !== "approve" && out.decision !== "reject") {
|
|
1151
|
-
return {
|
|
1253
|
+
return {
|
|
1254
|
+
...out,
|
|
1255
|
+
cost_usd: partLedger.total_usd,
|
|
1256
|
+
cost_cap: costCtx.cap,
|
|
1257
|
+
cost_breakdown: partLedger,
|
|
1258
|
+
capacity_degraded: degraded ? true : out.capacity_degraded,
|
|
1259
|
+
};
|
|
1152
1260
|
}
|
|
1153
|
-
if (cap > 0 && cost > cap)
|
|
1154
|
-
return overChunkedCostCap(cost, cap, modelUsed);
|
|
1155
1261
|
passes.push({ decision: out.decision, reasons: out.reasons, confidence: out.confidence });
|
|
1156
1262
|
}
|
|
1157
1263
|
const allPaths = diffFilePaths(input.pr_diff);
|
|
1158
1264
|
const evidence = renderSynthesisEvidence(chunks, passes, allPaths);
|
|
1159
1265
|
const synthPrompt = `${renderReviewerPrompt({ ...input, pr_diff: evidence })}\n\n${synthesisScopeNotice(chunks.length)}\n`;
|
|
1160
|
-
const final = await runClaudeAndDecide(synthPrompt,
|
|
1161
|
-
|
|
1266
|
+
const final = await runClaudeAndDecide(synthPrompt, policy, { ...deps, authCanary: undefined }, spawnFn, engine, costCtx);
|
|
1267
|
+
const ledger = reviewCostLedger(costCtx);
|
|
1162
1268
|
modelUsed = final.model_used ?? modelUsed;
|
|
1163
1269
|
degraded = degraded || final.capacity_degraded === true;
|
|
1164
1270
|
if (final.decision !== "approve" && final.decision !== "reject") {
|
|
1165
|
-
return {
|
|
1271
|
+
return {
|
|
1272
|
+
...final,
|
|
1273
|
+
cost_usd: ledger.total_usd,
|
|
1274
|
+
cost_cap: costCtx.cap,
|
|
1275
|
+
cost_breakdown: ledger,
|
|
1276
|
+
capacity_degraded: degraded ? true : final.capacity_degraded,
|
|
1277
|
+
};
|
|
1166
1278
|
}
|
|
1167
|
-
if (cap > 0 && cost > cap)
|
|
1168
|
-
return overChunkedCostCap(cost, cap, modelUsed);
|
|
1169
1279
|
// Guard FIRST (so a coverage claim can never survive as a verdict), then stamp
|
|
1170
1280
|
// the coverage telemetry — which must never itself be partitioned as a claim.
|
|
1171
1281
|
const guarded = guardCoverageClaims({
|
|
1172
1282
|
...final,
|
|
1173
|
-
cost_usd:
|
|
1283
|
+
cost_usd: ledger.total_usd,
|
|
1174
1284
|
model_used: modelUsed,
|
|
1175
1285
|
capacity_degraded: degraded ? true : undefined,
|
|
1176
1286
|
});
|
|
1287
|
+
// REV-COST: the overrun note (when the paid spend landed in the tolerance
|
|
1288
|
+
// band) sits between the findings and the coverage telemetry — a real reason,
|
|
1289
|
+
// so it must precede chunkedCoverageReason to keep THAT last.
|
|
1290
|
+
const stamped = stampReviewCost(guarded, costCtx);
|
|
1177
1291
|
return {
|
|
1178
|
-
...
|
|
1179
|
-
reasons: [...
|
|
1292
|
+
...stamped,
|
|
1293
|
+
reasons: [...stamped.reasons, chunkedCoverageReason(chunks.length, allPaths.length, input.pr_diff.length)],
|
|
1180
1294
|
};
|
|
1181
1295
|
}
|
|
1182
|
-
/**
|
|
1183
|
-
|
|
1296
|
+
/**
|
|
1297
|
+
* REV-COST — stamp the cost ledger onto a FINISHED review outcome and, when
|
|
1298
|
+
* the chargeable spend landed in the OVERRUN band (over the effective cap, under
|
|
1299
|
+
* the runaway threshold), keep the verdict and add the advisory note.
|
|
1300
|
+
*
|
|
1301
|
+
* Called exactly once per review, after the verdict is assembled — never on a
|
|
1302
|
+
* chunked PART pass, so a budget note can never leak into the synthesis evidence
|
|
1303
|
+
* and be re-read as a finding.
|
|
1304
|
+
*/
|
|
1305
|
+
function stampReviewCost(out, costCtx) {
|
|
1306
|
+
const ledger = reviewCostLedger(costCtx);
|
|
1307
|
+
const isVerdict = out.decision === "approve" || out.decision === "reject";
|
|
1308
|
+
const overrun = isVerdict && classifyReviewCost(ledger.chargeable_usd, costCtx.cap) === "overrun";
|
|
1184
1309
|
return {
|
|
1185
|
-
|
|
1186
|
-
reasons:
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
model_used: modelUsed,
|
|
1310
|
+
...out,
|
|
1311
|
+
reasons: overrun
|
|
1312
|
+
? [...out.reasons, reviewCostOverrunReason(costCtx.cap, ledger)]
|
|
1313
|
+
: out.reasons,
|
|
1314
|
+
cost_cap: costCtx.cap,
|
|
1315
|
+
cost_breakdown: ledger,
|
|
1316
|
+
...(overrun ? { cost_overrun: true } : {}),
|
|
1193
1317
|
};
|
|
1194
1318
|
}
|
|
1195
1319
|
/**
|
|
@@ -1200,7 +1324,7 @@ function overChunkedCostCap(cost, cap, modelUsed) {
|
|
|
1200
1324
|
* caller (which owns the capacity-fallback re-route). Behaviour of this loop is
|
|
1201
1325
|
* byte-identical to the pre-RVU-2 inline loop.
|
|
1202
1326
|
*/
|
|
1203
|
-
async function invokeReviewerModel(spawnFn, modelId, prompt, sleepFn, maxCapacityRetries) {
|
|
1327
|
+
async function invokeReviewerModel(spawnFn, modelId, prompt, sleepFn, maxCapacityRetries, engine) {
|
|
1204
1328
|
let invokeResult;
|
|
1205
1329
|
let infraRetries = 0;
|
|
1206
1330
|
let capacityRetries = 0;
|
|
@@ -1247,48 +1371,67 @@ async function invokeReviewerModel(spawnFn, modelId, prompt, sleepFn, maxCapacit
|
|
|
1247
1371
|
/** RVU-2: a budget-exempt env-broken (auth) reviewer_error. Decision stays
|
|
1248
1372
|
* `reviewer_error` (frozen); the marker reason makes the shared cron treat it
|
|
1249
1373
|
* as budget-exempt and alert once. */
|
|
1250
|
-
function envBrokenOutcome(modelId, detail) {
|
|
1374
|
+
function envBrokenOutcome(modelId, detail, ledger) {
|
|
1251
1375
|
return {
|
|
1252
1376
|
decision: "reviewer_error",
|
|
1253
1377
|
reasons: [REVIEWER_ENV_BROKEN_REASON, `detail: ${(detail ?? "").slice(0, 160)}`],
|
|
1254
1378
|
confidence: 0,
|
|
1379
|
+
// REV-COST: an auth fault costs nothing and is charged to INFRA, never to
|
|
1380
|
+
// the per-review budget — the review still needs its productive attempt.
|
|
1381
|
+
cost_usd: ledger?.total_usd ?? 0,
|
|
1255
1382
|
session_id: null,
|
|
1256
|
-
cost_usd: 0,
|
|
1257
1383
|
model_used: modelId,
|
|
1258
1384
|
env_broken: true,
|
|
1385
|
+
...(ledger ? { cost_breakdown: ledger } : {}),
|
|
1259
1386
|
};
|
|
1260
1387
|
}
|
|
1261
1388
|
/** RVU-2: assemble the outcome from a clean (exit 0) reviewer envelope. Cost-cap
|
|
1262
|
-
* parity + parse are unchanged; `degraded` marks a re-routed (lower-tier) run.
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1389
|
+
* parity + parse are unchanged; `degraded` marks a re-routed (lower-tier) run.
|
|
1390
|
+
* REV-COST: the cap is now the size-scaled decision carried in `costCtx`, and
|
|
1391
|
+
* only the RUNAWAY band refuses the verdict here. */
|
|
1392
|
+
function finalizeReviewerSuccess(stdout, modelId, degraded, costCtx, engine) {
|
|
1393
|
+
const envelope = parseEnvelope(stdout, modelId, engine);
|
|
1394
|
+
// REV-COST: the spawn returned a readable envelope, so this is the review's
|
|
1395
|
+
// PRODUCTIVE attempt — the only class charged to the per-review cap. Parity
|
|
1396
|
+
// with api/_lib/reviewer-agent.ts, which charges at exactly this point.
|
|
1397
|
+
const ledger = chargeReviewAttempt(costCtx, modelId, "productive", envelope.cost_usd);
|
|
1398
|
+
// REV-COST RUNAWAY BACKSTOP. Past `cap × REVIEW_COST_OVERRUN_TOLERANCE`
|
|
1399
|
+
// something is pathological, so the verdict is still refused. Inside the band
|
|
1400
|
+
// the spend is judged AFTER the verdict is assembled (stampReviewCost): the
|
|
1401
|
+
// money is already gone, and discarding a paid verdict only stranded the PR
|
|
1402
|
+
// (#1937 — $18 spent, $8 flat cap, NO verdict) and re-charged the same review
|
|
1403
|
+
// on the next tick. A cap of 0 disables the whole check, as before.
|
|
1404
|
+
if (classifyReviewCost(ledger.chargeable_usd, costCtx.cap) === "runaway") {
|
|
1269
1405
|
return {
|
|
1270
1406
|
decision: "cost_cap_exceeded",
|
|
1271
|
-
reasons: [
|
|
1272
|
-
`reviewer cost $${envelope.cost_usd.toFixed(4)} exceeded cap $${policy.max_cost_usd_per_review.toFixed(2)}`,
|
|
1273
|
-
],
|
|
1407
|
+
reasons: [reviewCostRunawayReason(costCtx.cap, ledger)],
|
|
1274
1408
|
confidence: 0,
|
|
1275
1409
|
session_id: envelope.session_id,
|
|
1276
|
-
cost_usd:
|
|
1410
|
+
cost_usd: ledger.total_usd,
|
|
1277
1411
|
model_used: modelId,
|
|
1412
|
+
cost_cap: costCtx.cap,
|
|
1413
|
+
cost_breakdown: ledger,
|
|
1278
1414
|
};
|
|
1279
1415
|
}
|
|
1280
1416
|
const decision = extractReviewerDecision(envelope.result);
|
|
1281
1417
|
if (!decision) {
|
|
1418
|
+
// S15-FU/REVIEW-1 (AC2): a PARSE FAULT is an explicit ABSTAIN. The unparsed
|
|
1419
|
+
// output head is carried as QUARANTINED context (never a finding) so an
|
|
1420
|
+
// operator can diagnose the format drift without any of that text being
|
|
1421
|
+
// scored as a defect.
|
|
1422
|
+
process.stderr.write(describeAbstainForLog("verdict_parse"));
|
|
1282
1423
|
return {
|
|
1283
1424
|
decision: "reviewer_error",
|
|
1284
|
-
reasons: [
|
|
1425
|
+
reasons: abstainReasons("verdict_parse", [
|
|
1285
1426
|
"could not parse reviewer JSON from claude output",
|
|
1286
1427
|
`output_head: ${envelope.result.slice(0, 200)}`,
|
|
1287
|
-
],
|
|
1428
|
+
]),
|
|
1288
1429
|
confidence: 0,
|
|
1289
1430
|
session_id: envelope.session_id,
|
|
1290
|
-
cost_usd:
|
|
1431
|
+
cost_usd: ledger.total_usd,
|
|
1291
1432
|
model_used: modelId,
|
|
1433
|
+
cost_cap: costCtx.cap,
|
|
1434
|
+
cost_breakdown: ledger,
|
|
1292
1435
|
};
|
|
1293
1436
|
}
|
|
1294
1437
|
return {
|
|
@@ -1298,6 +1441,11 @@ function finalizeReviewerSuccess(stdout, modelId, degraded, policy) {
|
|
|
1298
1441
|
session_id: envelope.session_id,
|
|
1299
1442
|
cost_usd: envelope.cost_usd,
|
|
1300
1443
|
model_used: modelId,
|
|
1444
|
+
// REVIEW-DECISION-PROVENANCE: carried as a field (never a reason) so it
|
|
1445
|
+
// survives the coverage/absence guards without being read as a finding.
|
|
1446
|
+
...(decision.decision_provenance
|
|
1447
|
+
? { decision_provenance: decision.decision_provenance }
|
|
1448
|
+
: {}),
|
|
1301
1449
|
// Signal capacity pressure to watch.ts only when the review actually
|
|
1302
1450
|
// re-routed to a lower tier (leave undefined on the primary-model path).
|
|
1303
1451
|
capacity_degraded: degraded ? true : undefined,
|
|
@@ -1312,7 +1460,7 @@ function finalizeReviewerSuccess(stdout, modelId, degraded, policy) {
|
|
|
1312
1460
|
* explicit broken CLI-auth signal surfaces as budget-exempt env_broken rather
|
|
1313
1461
|
* than looping. Cost-cap / parse behaviour is unchanged.
|
|
1314
1462
|
*/
|
|
1315
|
-
async function runClaudeAndDecide(prompt, policy, deps, spawnFn) {
|
|
1463
|
+
async function runClaudeAndDecide(prompt, policy, deps, spawnFn, engine, costCtx) {
|
|
1316
1464
|
const sleepFn = deps.sleep ?? defaultSleep;
|
|
1317
1465
|
const baseModel = policy?.model_id ?? "claude-sonnet-4-6";
|
|
1318
1466
|
const maxCapacityRetries = resolveCapacityRetries(process.env[CAPACITY_RETRIES_ENV]);
|
|
@@ -1330,7 +1478,7 @@ async function runClaudeAndDecide(prompt, policy, deps, spawnFn) {
|
|
|
1330
1478
|
probes = [];
|
|
1331
1479
|
}
|
|
1332
1480
|
if (isEnvBrokenReviewerProbe(probes)) {
|
|
1333
|
-
return envBrokenOutcome(primaryModel, "preflight auth canary: repeated silent CLI exit (broken login / rejected key)");
|
|
1481
|
+
return envBrokenOutcome(primaryModel, "preflight auth canary: repeated silent CLI exit (broken login / rejected key)", chargeReviewAttempt(costCtx, primaryModel, "infra_auth", 0));
|
|
1334
1482
|
}
|
|
1335
1483
|
}
|
|
1336
1484
|
let lastCapacityDetail = "reviewer capacity exhausted across all fallback tiers";
|
|
@@ -1338,10 +1486,10 @@ async function runClaudeAndDecide(prompt, policy, deps, spawnFn) {
|
|
|
1338
1486
|
for (let i = 0; i < chain.length; i++) {
|
|
1339
1487
|
const modelId = chain[i];
|
|
1340
1488
|
const isLast = i === chain.length - 1;
|
|
1341
|
-
const invokeResult = await invokeReviewerModel(spawnFn, modelId, prompt, sleepFn, maxCapacityRetries);
|
|
1489
|
+
const invokeResult = await invokeReviewerModel(spawnFn, modelId, prompt, sleepFn, maxCapacityRetries, engine);
|
|
1342
1490
|
const { stdout, exitCode, stderr: claudeStderr, durationMs } = invokeResult;
|
|
1343
1491
|
if (exitCode === 0) {
|
|
1344
|
-
return finalizeReviewerSuccess(stdout, modelId, modelId !== primaryModel,
|
|
1492
|
+
return finalizeReviewerSuccess(stdout, modelId, modelId !== primaryModel, costCtx, engine);
|
|
1345
1493
|
}
|
|
1346
1494
|
const classified = engine.classifyFailure(exitCode, claudeStderr, stdout, durationMs);
|
|
1347
1495
|
const combined = `${claudeStderr}\n${stdout}`;
|
|
@@ -1353,7 +1501,7 @@ async function runClaudeAndDecide(prompt, policy, deps, spawnFn) {
|
|
|
1353
1501
|
if (classified.class === "auth_expired" ||
|
|
1354
1502
|
classified.class === "env_broken" ||
|
|
1355
1503
|
isReviewerAuthFailure(combined)) {
|
|
1356
|
-
return envBrokenOutcome(modelId, classified.detail);
|
|
1504
|
+
return envBrokenOutcome(modelId, classified.detail, chargeReviewAttempt(costCtx, modelId, "infra_auth", 0));
|
|
1357
1505
|
}
|
|
1358
1506
|
const isCapacity = classified.class === "capacity_exhausted" ||
|
|
1359
1507
|
classified.class === "claude_unavailable";
|
|
@@ -1362,6 +1510,10 @@ async function runClaudeAndDecide(prompt, policy, deps, spawnFn) {
|
|
|
1362
1510
|
const resumeMs = extractResetTime(combined);
|
|
1363
1511
|
if (resumeMs !== null)
|
|
1364
1512
|
lastResumeMs = resumeMs;
|
|
1513
|
+
// REV-COST: a 429/overload brownout is INFRA. Recording it makes "N
|
|
1514
|
+
// attempts on one review" visible WITHOUT any of those attempts consuming
|
|
1515
|
+
// the per-review budget the productive attempt needs.
|
|
1516
|
+
const capacityLedger = chargeReviewAttempt(costCtx, modelId, "infra_capacity", 0);
|
|
1365
1517
|
// Re-route to the next lower catalog tier instead of stalling the lane.
|
|
1366
1518
|
if (!isLast)
|
|
1367
1519
|
continue;
|
|
@@ -1377,6 +1529,8 @@ async function runClaudeAndDecide(prompt, policy, deps, spawnFn) {
|
|
|
1377
1529
|
resume_at: lastResumeMs !== null ? new Date(lastResumeMs).toISOString() : null,
|
|
1378
1530
|
model_used: modelId,
|
|
1379
1531
|
capacity_degraded: true,
|
|
1532
|
+
cost_cap: costCtx.cap,
|
|
1533
|
+
cost_breakdown: capacityLedger,
|
|
1380
1534
|
};
|
|
1381
1535
|
}
|
|
1382
1536
|
// Genuine error (task_error / usage_limit) — legacy reviewer_error, no
|