@expo/code-review-cli 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +307 -47
- package/build/cli.js +24 -17
- package/build/commands/ci.js +410 -43
- package/build/commands/dismiss.js +16 -16
- package/build/commands/doctor.js +219 -26
- package/build/commands/init.js +244 -34
- package/build/commands/review.js +118 -30
- package/build/commands/verify-config.js +252 -0
- package/build/config/load.js +200 -55
- package/build/config/routing.js +122 -0
- package/build/config/schema.js +153 -19
- package/build/core/auth.js +237 -75
- package/build/core/coordinator.js +7 -7
- package/build/core/diff.js +19 -19
- package/build/core/exec.js +10 -10
- package/build/core/log.js +3 -3
- package/build/core/noise.js +52 -52
- package/build/core/opencode.js +495 -95
- package/build/core/prompts.js +220 -150
- package/build/core/render.js +202 -48
- package/build/core/review.js +277 -102
- package/build/core/router.js +10 -10
- package/build/core/schema.js +26 -12
- package/build/core/step-summary.js +18 -0
- package/build/core/suppress.js +7 -7
- package/build/core/tools.js +9 -9
- package/build/core/util.js +2 -2
- package/build/core/verify.js +28 -26
- package/build/reporters/github.js +103 -51
- package/build/reporters/terminal.js +19 -19
- package/build/sources/github-pr.js +21 -21
- package/build/sources/local-git.js +20 -20
- package/build/sources/source.js +35 -1
- package/package.json +8 -3
- package/templates/agents/security.md +5 -0
- package/templates/command.yml +167 -0
- package/templates/config.jsonc +26 -13
- package/templates/coordinator.md +5 -3
- package/templates/dismiss.yml +110 -0
- package/templates/routing.jsonc +27 -0
- package/templates/scope-config.jsonc +25 -0
- package/templates/shared.md +12 -0
- package/templates/workflow.yml +61 -26
package/build/core/review.js
CHANGED
|
@@ -1,18 +1,31 @@
|
|
|
1
|
-
import path from
|
|
2
|
-
import { prepareAuth } from
|
|
3
|
-
import { coordinate } from
|
|
4
|
-
import { writeRunLog } from
|
|
5
|
-
import { filterNoise, writePatchWorkspace } from
|
|
6
|
-
import { addTokenUsage, AgentTimeoutError, buildOpencodeConfig, CROSS_CUTTING_AGENT, promptAndParse, startOpencode, } from
|
|
7
|
-
import { routeAgents } from
|
|
8
|
-
import { buildCrossCuttingSystem, buildCrossCuttingTask, buildReviewerSystem, buildReviewerTask, NO_TOOLS_INSTRUCTION, } from
|
|
9
|
-
import { fingerprintFinding, parseReviewerOutput } from
|
|
10
|
-
import { sortFindings } from
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { prepareAuth } from "./auth.js";
|
|
3
|
+
import { coordinate } from "./coordinator.js";
|
|
4
|
+
import { writeRunLog } from "./log.js";
|
|
5
|
+
import { filterNoise, writePatchWorkspace } from "./noise.js";
|
|
6
|
+
import { addTokenUsage, AgentTimeoutError, assertModelsResolvable, buildOpencodeConfig, CROSS_CUTTING_AGENT, promptAndParse, startOpencode, } from "./opencode.js";
|
|
7
|
+
import { routeAgents } from "./router.js";
|
|
8
|
+
import { buildCrossCuttingSystem, buildCrossCuttingTask, buildReviewerSystem, buildReviewerTask, NO_TOOLS_INSTRUCTION, } from "./prompts.js";
|
|
9
|
+
import { fingerprintFinding, parseReviewerOutput } from "./schema.js";
|
|
10
|
+
import { sortFindings } from "./render.js";
|
|
11
|
+
import { appendStepSummary } from "./step-summary.js";
|
|
12
|
+
import { errorMessage, sleep } from "./util.js";
|
|
13
|
+
import { verifyFindings } from "./verify.js";
|
|
14
|
+
import { applyInlineIgnores } from "./suppress.js";
|
|
15
|
+
/**
|
|
16
|
+
* Filter changed files down to an explicit include set (exact-path membership, not
|
|
17
|
+
* globs — scope assignment already happened in resolveScopes). With no include set,
|
|
18
|
+
* returns the input unchanged so the non-routed path is byte-identical.
|
|
19
|
+
*/
|
|
20
|
+
export function filterByIncludePaths(files, includePaths) {
|
|
21
|
+
if (!includePaths) {
|
|
22
|
+
return files;
|
|
23
|
+
}
|
|
24
|
+
const included = new Set(includePaths);
|
|
25
|
+
return files.filter((file) => included.has(file.path));
|
|
26
|
+
}
|
|
14
27
|
function makeRunId() {
|
|
15
|
-
return new Date().toISOString().replace(/[:.]/g,
|
|
28
|
+
return new Date().toISOString().replace(/[:.]/g, "-");
|
|
16
29
|
}
|
|
17
30
|
/**
|
|
18
31
|
* The invariant, mode-agnostic review core: filter → spawn each configured agent
|
|
@@ -24,9 +37,9 @@ export async function runReview(source, options) {
|
|
|
24
37
|
const started = Date.now();
|
|
25
38
|
const runId = makeRunId();
|
|
26
39
|
const progress = options.onProgress ?? (() => { });
|
|
27
|
-
const runsRoot = path.join(config.configDir,
|
|
40
|
+
const runsRoot = path.join(config.configDir, ".runs");
|
|
28
41
|
const runDir = path.join(runsRoot, runId);
|
|
29
|
-
const logPath = path.join(runsRoot,
|
|
42
|
+
const logPath = path.join(runsRoot, "reviews.jsonl");
|
|
30
43
|
// Fail fast on an invalid explicit selection before doing any work. Routing
|
|
31
44
|
// (if requested) is resolved later, once the server is up.
|
|
32
45
|
const explicitAgents = options.agents?.length
|
|
@@ -36,24 +49,27 @@ export async function runReview(source, options) {
|
|
|
36
49
|
source.getMetadata(),
|
|
37
50
|
source.getChangedFiles(),
|
|
38
51
|
]);
|
|
39
|
-
|
|
52
|
+
// Scope isolation: when includePaths is set, this run only ever sees its own
|
|
53
|
+
// scope's files — no scope reviews another team's diff.
|
|
54
|
+
const scopedFiles = filterByIncludePaths(changedFiles, options.includePaths);
|
|
55
|
+
const { kept, filtered } = await filterNoise(scopedFiles, {
|
|
40
56
|
additionalIgnores: config.noise.additionalIgnores,
|
|
41
57
|
additionalMarkers: config.noise.additionalMarkers,
|
|
42
58
|
});
|
|
43
|
-
progress(`${
|
|
59
|
+
progress(`${scopedFiles.length} changed file(s); ${kept.length} to review, ${filtered.length} filtered.`);
|
|
44
60
|
const baseRecord = {
|
|
45
61
|
timestamp: new Date().toISOString(),
|
|
46
62
|
mode: options.mode,
|
|
47
63
|
runId,
|
|
48
64
|
metadata: { baseRef: metadata.baseRef, headRef: metadata.headRef },
|
|
49
|
-
reviewedFiles: kept.map(entry => entry.path),
|
|
65
|
+
reviewedFiles: kept.map((entry) => entry.path),
|
|
50
66
|
filteredFiles: filtered,
|
|
51
67
|
};
|
|
52
68
|
if (kept.length === 0) {
|
|
53
69
|
const output = {
|
|
54
|
-
decision:
|
|
70
|
+
decision: "approve",
|
|
55
71
|
findings: [],
|
|
56
|
-
summary:
|
|
72
|
+
summary: "No reviewable changes after noise filtering.",
|
|
57
73
|
incomplete: [],
|
|
58
74
|
};
|
|
59
75
|
await safeLog(logPath, {
|
|
@@ -86,10 +102,10 @@ export async function runReview(source, options) {
|
|
|
86
102
|
}
|
|
87
103
|
};
|
|
88
104
|
if (readRoot) {
|
|
89
|
-
progress(
|
|
105
|
+
progress("Reviewing the PR-head tree (so reads match the PR, not the checkout).");
|
|
90
106
|
process.chdir(readRoot.dir);
|
|
91
107
|
}
|
|
92
|
-
progress(
|
|
108
|
+
progress("Starting OpenCode server…");
|
|
93
109
|
let handle = null;
|
|
94
110
|
try {
|
|
95
111
|
handle = await startOpencode(buildOpencodeConfig(config));
|
|
@@ -98,22 +114,62 @@ export async function runReview(source, options) {
|
|
|
98
114
|
await auth.cleanup();
|
|
99
115
|
await restoreCwd();
|
|
100
116
|
throw new Error(`Failed to start the OpenCode server. Ensure the \`opencode\` CLI is installed and ` +
|
|
101
|
-
`model credentials are configured.\n${errorMessage(error)}`);
|
|
117
|
+
`model credentials are configured (\`ecr doctor\` checks both).\n${errorMessage(error)}`);
|
|
118
|
+
}
|
|
119
|
+
// Preflight: a model id the server can't resolve would otherwise fail EVERY pass
|
|
120
|
+
// identically — N indistinguishable coverage gaps, after spending the run's budget
|
|
121
|
+
// discovering the same fixable thing N times. Throw once, up front, naming the fix.
|
|
122
|
+
try {
|
|
123
|
+
await assertModelsResolvable(handle, [...config.agents.map((agent) => agent.model), config.coordinator.model], config.auth);
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
handle.close();
|
|
127
|
+
await auth.cleanup();
|
|
128
|
+
await restoreCwd();
|
|
129
|
+
throw error;
|
|
102
130
|
}
|
|
103
131
|
const agentCosts = {};
|
|
104
132
|
const tokenTotals = {};
|
|
133
|
+
const agentTokens = {};
|
|
134
|
+
// Declared outside the try so the error-path log still carries whatever the
|
|
135
|
+
// reviewers produced before the failure — partial findings are exactly what's
|
|
136
|
+
// needed to debug a run that died mid-way.
|
|
137
|
+
const agentFindings = {};
|
|
138
|
+
// Every model request's usage lands in the run total AND its bucket, so the run
|
|
139
|
+
// log can show cache effectiveness per pass and not just run-wide.
|
|
140
|
+
const trackTokens = (bucket, tokens) => {
|
|
141
|
+
addTokenUsage(tokenTotals, tokens);
|
|
142
|
+
addTokenUsage((agentTokens[bucket] ??= {}), tokens);
|
|
143
|
+
};
|
|
144
|
+
// The provider/model that ACTUALLY answered each pass, and any pass whose model was
|
|
145
|
+
// silently substituted for the configured one. OpenCode does that substitution
|
|
146
|
+
// quietly whenever an agent's model id is empty or unusable, so a run can review with
|
|
147
|
+
// a different model than config.jsonc names and look completely normal — which is
|
|
148
|
+
// exactly what happened for weeks behind an empty REVIEWER_MODEL. Recorded in the run
|
|
149
|
+
// log, reported in the log line, and surfaced as a coverage note when it happens.
|
|
150
|
+
const agentModels = {};
|
|
151
|
+
const substituted = new Set();
|
|
152
|
+
const trackModel = (bucket, configured, actual) => {
|
|
153
|
+
if (!actual) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
agentModels[bucket] = actual;
|
|
157
|
+
if (configured && actual !== configured) {
|
|
158
|
+
substituted.add(`${bucket}: configured ${configured}, ran ${actual}`);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
105
161
|
try {
|
|
106
162
|
const workspace = await writePatchWorkspace(kept, metadata, runDir);
|
|
107
163
|
// Resolve which agents run: an explicit list wins; otherwise route (LLM picks
|
|
108
164
|
// relevant agents + always-run) when asked, else all.
|
|
109
165
|
let selectedAgents = explicitAgents ?? config.agents;
|
|
110
166
|
if (!explicitAgents && options.route) {
|
|
111
|
-
progress(
|
|
167
|
+
progress("Routing: selecting relevant agents…");
|
|
112
168
|
const routed = await routeAgents(handle, config, workspace.files);
|
|
113
169
|
selectedAgents = routed.agents;
|
|
114
170
|
progress(routed.routed
|
|
115
|
-
? `Router selected: ${selectedAgents.map(a => a.id).join(
|
|
116
|
-
:
|
|
171
|
+
? `Router selected: ${selectedAgents.map((a) => a.id).join(", ")}`
|
|
172
|
+
: "Router unavailable; running all agents.");
|
|
117
173
|
}
|
|
118
174
|
// Split the diff into focused chunks so each reviewer call sees a small file
|
|
119
175
|
// set (better recall than one giant blob), and run all agent×chunk calls
|
|
@@ -121,10 +177,9 @@ export async function runReview(source, options) {
|
|
|
121
177
|
const chunks = chunkByLines(workspace.files, config.chunk.maxChangedLines, config.chunk.maxFiles);
|
|
122
178
|
// Only chunk (and add a cross-cutting pass) when the diff exceeds one chunk.
|
|
123
179
|
const chunked = chunks.length > 1;
|
|
124
|
-
progress(`Running ${selectedAgents.length} reviewer(s) [${selectedAgents.map(a => a.id).join(
|
|
125
|
-
`${chunked ?
|
|
180
|
+
progress(`Running ${selectedAgents.length} reviewer(s) [${selectedAgents.map((a) => a.id).join(", ")}] over ${chunks.length} chunk(s)` +
|
|
181
|
+
`${chunked ? " + cross-cutting pass" : ""} ` +
|
|
126
182
|
`(${kept.length} files, concurrency ${config.chunk.concurrency})…`);
|
|
127
|
-
const agentFindings = {};
|
|
128
183
|
for (const agent of selectedAgents) {
|
|
129
184
|
agentFindings[agent.id] = [];
|
|
130
185
|
agentCosts[agent.id] = 0;
|
|
@@ -132,7 +187,6 @@ export async function runReview(source, options) {
|
|
|
132
187
|
// These caps must fit inside PASSES_BUDGET_MS (below), which in turn fits inside
|
|
133
188
|
// the CI job's timeout-minutes.
|
|
134
189
|
const CHUNK_TIMEOUT_MS = 15 * 60 * 1000;
|
|
135
|
-
const CROSS_CUTTING_TIMEOUT_MS = 25 * 60 * 1000;
|
|
136
190
|
// A subdivided sub-chunk is smaller, so it gets a shorter cap (halved per level,
|
|
137
191
|
// floored) — enough to converge without letting the recursion balloon.
|
|
138
192
|
const SUBDIVIDE_MIN_TIMEOUT_MS = 6 * 60 * 1000;
|
|
@@ -142,25 +196,62 @@ export async function runReview(source, options) {
|
|
|
142
196
|
// Tool-call ceilings — generous for a legitimate pass, low enough to catch
|
|
143
197
|
// runaway roaming (the root cause of the non-convergent timeouts).
|
|
144
198
|
const CHUNK_MAX_TOOL_CALLS = 50;
|
|
145
|
-
const CROSS_CUTTING_MAX_TOOL_CALLS = 120;
|
|
146
199
|
// Global ceiling for ALL passes incl. subdivision/fallback waves, sized to
|
|
147
200
|
// leave room for the coordinator (10m) + verification + overhead inside the CI
|
|
148
201
|
// job timeout. Past this, a timed-out pass is reported as a gap rather than
|
|
149
202
|
// broken down further, so total wall-clock stays bounded.
|
|
150
|
-
const PASSES_BUDGET_MS =
|
|
151
|
-
const
|
|
203
|
+
const PASSES_BUDGET_MS = 55 * 60 * 1000;
|
|
204
|
+
const passesBudgetMs = options.passesBudgetMs ?? PASSES_BUDGET_MS;
|
|
205
|
+
const passesDeadline = started + passesBudgetMs;
|
|
206
|
+
// The cross-file pass is the one pass whose scope cannot be traded for
|
|
207
|
+
// convergence: halving its file set deletes exactly the coverage it exists to
|
|
208
|
+
// provide (see the timeout branch below). So instead of a fixed cap it gets the
|
|
209
|
+
// WHOLE remaining passes window. Chunk passes run concurrently alongside it under
|
|
210
|
+
// their own caps, so a long cross-file pass doesn't starve them — it only extends
|
|
211
|
+
// the run toward the passes deadline, which the job timeout is sized for.
|
|
212
|
+
//
|
|
213
|
+
// Computed here, not as a constant, because the window is what's actually left:
|
|
214
|
+
// filtering, routing and server startup already spent some of it, and `ecr ci`
|
|
215
|
+
// divides the budget across active scopes.
|
|
216
|
+
//
|
|
217
|
+
// The reserve is what keeps its own salvage paths affordable: if it expanded into
|
|
218
|
+
// the entire window, then on a timeout there would be nothing left to run the
|
|
219
|
+
// whole-diff no-tools fallback with, and "elastic budget" would have quietly
|
|
220
|
+
// reintroduced the coverage gap it exists to prevent. Sized for the finalize
|
|
221
|
+
// soft-landing plus one FALLBACK_TIMEOUT_MS pass.
|
|
222
|
+
const CROSS_CUTTING_RESERVE_MS = FALLBACK_TIMEOUT_MS + 4 * 60 * 1000;
|
|
223
|
+
// Floor: never LESS generous than one chunk pass. On a run whose window is already
|
|
224
|
+
// small (many active scopes dividing the budget) this can exceed what's left, but
|
|
225
|
+
// that exposure is exactly what chunk passes already carry — their 15m cap can also
|
|
226
|
+
// outlast a small per-scope slice — and the job timeout keeps a wide margin over
|
|
227
|
+
// the budget for it. Dropping the pass instead would silently cost the coverage no
|
|
228
|
+
// other pass provides.
|
|
229
|
+
const crossCuttingWaitMs = Math.max(CHUNK_TIMEOUT_MS, passesDeadline - Date.now() - CROSS_CUTTING_RESERVE_MS);
|
|
230
|
+
// Tool calls are for TRACING (opening the caller a changed signature affects) —
|
|
231
|
+
// the changed files' diffs are inlined, so they are not spent fetching the diff.
|
|
232
|
+
// Scale with the diff's file count instead of fixing the ceiling: under a large
|
|
233
|
+
// elastic time budget a fixed cap becomes the binding constraint, and the extra
|
|
234
|
+
// time can't be used.
|
|
235
|
+
const CROSS_CUTTING_TOOL_CALLS_PER_FILE = 10;
|
|
236
|
+
const CROSS_CUTTING_MIN_TOOL_CALLS = 120;
|
|
237
|
+
const CROSS_CUTTING_MAX_TOOL_CALLS = 400;
|
|
238
|
+
const crossCuttingMaxToolCalls = Math.min(CROSS_CUTTING_MAX_TOOL_CALLS, Math.max(CROSS_CUTTING_MIN_TOOL_CALLS, CROSS_CUTTING_TOOL_CALLS_PER_FILE * workspace.files.length));
|
|
239
|
+
// Coverage notes for passes that hit their time limit, stalled, failed, or were
|
|
240
|
+
// never started, surfaced in the final review so a cut-short run is never
|
|
241
|
+
// presented as complete.
|
|
242
|
+
const incomplete = [];
|
|
152
243
|
const tasks = [];
|
|
153
244
|
for (const agent of selectedAgents) {
|
|
154
245
|
const system = buildReviewerSystem(config, agent);
|
|
155
246
|
chunks.forEach((chunk, index) => {
|
|
156
247
|
tasks.push({
|
|
157
248
|
bucket: agent.id,
|
|
158
|
-
kind:
|
|
249
|
+
kind: "reviewer",
|
|
159
250
|
system,
|
|
160
251
|
label: chunked ? `${agent.id} [${index + 1}/${chunks.length}]` : agent.id,
|
|
161
252
|
title: `review-${agent.id}-c${index}`,
|
|
162
253
|
files: chunk,
|
|
163
|
-
coverageLabel: `the ${agent.id} review${chunked ? ` (part ${index + 1} of ${chunks.length})` :
|
|
254
|
+
coverageLabel: `the ${agent.id} review${chunked ? ` (part ${index + 1} of ${chunks.length})` : ""}`,
|
|
164
255
|
maxWaitMs: CHUNK_TIMEOUT_MS,
|
|
165
256
|
maxToolCalls: CHUNK_MAX_TOOL_CALLS,
|
|
166
257
|
depth: 0,
|
|
@@ -173,14 +264,14 @@ export async function runReview(source, options) {
|
|
|
173
264
|
if (chunked) {
|
|
174
265
|
tasks.push({
|
|
175
266
|
bucket: CROSS_CUTTING_AGENT,
|
|
176
|
-
kind:
|
|
177
|
-
system: buildCrossCuttingSystem(config
|
|
178
|
-
label:
|
|
179
|
-
title:
|
|
267
|
+
kind: "cross-cutting",
|
|
268
|
+
system: buildCrossCuttingSystem(config),
|
|
269
|
+
label: "cross-file",
|
|
270
|
+
title: "review-xcut",
|
|
180
271
|
files: workspace.files,
|
|
181
|
-
coverageLabel:
|
|
182
|
-
maxWaitMs:
|
|
183
|
-
maxToolCalls:
|
|
272
|
+
coverageLabel: "the cross-file review (issues spanning multiple changed files)",
|
|
273
|
+
maxWaitMs: crossCuttingWaitMs,
|
|
274
|
+
maxToolCalls: crossCuttingMaxToolCalls,
|
|
184
275
|
depth: 0,
|
|
185
276
|
fallback: false,
|
|
186
277
|
});
|
|
@@ -188,18 +279,26 @@ export async function runReview(source, options) {
|
|
|
188
279
|
// Longest-processing-time-first: schedule the long cross-cutting/large chunks
|
|
189
280
|
// ahead of short ones so they don't dominate the tail of the makespan.
|
|
190
281
|
tasks.sort((a, b) => b.maxWaitMs - a.maxWaitMs);
|
|
282
|
+
// What each task was CONFIGURED to run on — mirrors buildOpencodeConfig, which
|
|
283
|
+
// gives the cross-file pass the first agent's model. Compared against what actually
|
|
284
|
+
// answered so a silent substitution can't pass unnoticed.
|
|
285
|
+
const taskModel = (task) => task.kind === "cross-cutting"
|
|
286
|
+
? (selectedAgents[0]?.model ?? config.coordinator.model)
|
|
287
|
+
: (selectedAgents.find((agent) => agent.id === task.bucket)?.model ?? "");
|
|
191
288
|
// Build the task prompt on demand (so a subdivided task rebuilds over its
|
|
192
289
|
// smaller file set); a fallback task forbids tools and reviews the inlined diff.
|
|
193
290
|
const buildTaskText = (task) => {
|
|
194
|
-
const base = task.kind ===
|
|
195
|
-
? buildCrossCuttingTask(task.files, filtered
|
|
291
|
+
const base = task.kind === "cross-cutting"
|
|
292
|
+
? buildCrossCuttingTask(task.files, selectedAgents, filtered, {
|
|
293
|
+
noTools: task.fallback,
|
|
294
|
+
})
|
|
196
295
|
: buildReviewerTask(task.files, workspace.files, filtered);
|
|
197
296
|
return task.fallback ? `${base}\n\n${NO_TOOLS_INSTRUCTION}` : base;
|
|
198
297
|
};
|
|
199
298
|
const filesLabel = (files) => files.length === 1
|
|
200
299
|
? `\`${files[0].path}\``
|
|
201
300
|
: `${files.length} files (e.g. \`${files[0].path}\`)`;
|
|
202
|
-
const humanBucket = (bucket) => bucket === CROSS_CUTTING_AGENT ?
|
|
301
|
+
const humanBucket = (bucket) => bucket === CROSS_CUTTING_AGENT ? "cross-file" : bucket;
|
|
203
302
|
const childTask = (parent, files, labelSuffix, overrides) => ({
|
|
204
303
|
...parent,
|
|
205
304
|
files,
|
|
@@ -207,9 +306,6 @@ export async function runReview(source, options) {
|
|
|
207
306
|
coverageLabel: `the ${humanBucket(parent.bucket)} review of ${filesLabel(files)}`,
|
|
208
307
|
...overrides,
|
|
209
308
|
});
|
|
210
|
-
// Coverage notes for passes that hit their time limit or failed, surfaced in
|
|
211
|
-
// the final review so a cut-short run is never presented as complete.
|
|
212
|
-
const incomplete = [];
|
|
213
309
|
let completedPasses = 0;
|
|
214
310
|
let failedPasses = 0;
|
|
215
311
|
// promptAndParse already retries internally (same-session corrective, then a
|
|
@@ -220,18 +316,19 @@ export async function runReview(source, options) {
|
|
|
220
316
|
await runGrowableQueue(tasks, config.chunk.concurrency, async (task, enqueue) => {
|
|
221
317
|
const minutes = Math.round(task.maxWaitMs / 60000);
|
|
222
318
|
try {
|
|
223
|
-
const { value, cost, truncated, tokens } = await promptAndParse(handle, {
|
|
319
|
+
const { value, cost, truncated, tokens, model } = await promptAndParse(handle, {
|
|
224
320
|
agent: task.bucket,
|
|
225
321
|
system: task.system,
|
|
226
322
|
text: buildTaskText(task),
|
|
227
323
|
title: task.title,
|
|
228
|
-
onActivity: line => progress(` ${task.label}: ${line}`),
|
|
324
|
+
onActivity: (line) => progress(` ${task.label}: ${line}`),
|
|
229
325
|
maxWaitMs: task.maxWaitMs,
|
|
230
326
|
maxToolCalls: task.maxToolCalls,
|
|
231
327
|
finalizeOnTimeout: true,
|
|
232
328
|
}, parseReviewerOutput);
|
|
233
329
|
agentCosts[task.bucket] = (agentCosts[task.bucket] ?? 0) + cost;
|
|
234
|
-
|
|
330
|
+
trackTokens(task.bucket, tokens);
|
|
331
|
+
trackModel(task.bucket, taskModel(task), model);
|
|
235
332
|
(agentFindings[task.bucket] ??= []).push(...value.findings);
|
|
236
333
|
completedPasses++;
|
|
237
334
|
if (truncated) {
|
|
@@ -255,13 +352,17 @@ export async function runReview(source, options) {
|
|
|
255
352
|
}
|
|
256
353
|
// Account for the abandoned investigation's spend regardless of what's next.
|
|
257
354
|
agentCosts[task.bucket] = (agentCosts[task.bucket] ?? 0) + error.cost;
|
|
258
|
-
|
|
355
|
+
trackTokens(task.bucket, error.tokens);
|
|
259
356
|
const remaining = passesDeadline - Date.now();
|
|
260
|
-
//
|
|
261
|
-
// reviewer chunk
|
|
262
|
-
|
|
357
|
+
// Subdividing trades scope for convergence, which is the right trade for a
|
|
358
|
+
// reviewer chunk (each file still gets reviewed) and the WRONG one for the
|
|
359
|
+
// cross-file pass: an interaction between a file in the left half and one in
|
|
360
|
+
// the right half is invisible to both halves, so "splitting" it silently
|
|
361
|
+
// deletes the coverage the pass exists to provide while reporting success.
|
|
362
|
+
// It goes straight to the whole-diff no-tools fallback below instead.
|
|
363
|
+
const canSubdivide = task.kind === "reviewer" && task.files.length > 1;
|
|
263
364
|
const childCap = Math.max(SUBDIVIDE_MIN_TIMEOUT_MS, Math.floor(task.maxWaitMs / 2));
|
|
264
|
-
if (
|
|
365
|
+
if (canSubdivide && task.depth < MAX_SUBDIVIDE_DEPTH && remaining > childCap) {
|
|
265
366
|
const mid = Math.ceil(task.files.length / 2);
|
|
266
367
|
const left = task.files.slice(0, mid);
|
|
267
368
|
const right = task.files.slice(mid);
|
|
@@ -271,11 +372,14 @@ export async function runReview(source, options) {
|
|
|
271
372
|
enqueue(childTask(task, right, `↳${right.length}f`, over));
|
|
272
373
|
return;
|
|
273
374
|
}
|
|
274
|
-
// Can't subdivide
|
|
275
|
-
//
|
|
276
|
-
|
|
375
|
+
// Can't (or shouldn't) subdivide: fall back to a fast no-tools pass over the
|
|
376
|
+
// inlined diffs. This works for the cross-file pass too — every changed file's
|
|
377
|
+
// diff is inlined in its task, so it can still reason across the whole diff
|
|
378
|
+
// without tools; it just can't open a caller outside the diff. A lighter
|
|
379
|
+
// cross-file review beats the coverage gap it used to report.
|
|
380
|
+
if (!task.fallback && remaining > FALLBACK_TIMEOUT_MS) {
|
|
277
381
|
progress(` ${task.label}: exceeded ${minutes}m — retrying ${filesLabel(task.files)} with a fast no-tools pass`);
|
|
278
|
-
enqueue(childTask(task, task.files,
|
|
382
|
+
enqueue(childTask(task, task.files, "(no-tools fallback)", {
|
|
279
383
|
fallback: true,
|
|
280
384
|
maxWaitMs: FALLBACK_TIMEOUT_MS,
|
|
281
385
|
maxToolCalls: 0,
|
|
@@ -286,10 +390,24 @@ export async function runReview(source, options) {
|
|
|
286
390
|
// never silent. Distinguish WHY so the note doesn't overstate what happened:
|
|
287
391
|
// we could still have split/fallen back, but the global budget ran out first,
|
|
288
392
|
// vs. the task was already at its smallest reviewable unit and still failed.
|
|
393
|
+
// A stalled pass is called out separately: it did not run out of time doing
|
|
394
|
+
// work, its model requests went silent, which is an infrastructure symptom
|
|
395
|
+
// and not something a bigger budget or a smaller scope would have fixed.
|
|
289
396
|
failedPasses++;
|
|
290
|
-
const couldStillReduce = (
|
|
291
|
-
|
|
292
|
-
|
|
397
|
+
const couldStillReduce = (canSubdivide && task.depth < MAX_SUBDIVIDE_DEPTH) || !task.fallback;
|
|
398
|
+
if (error.reason === "stall") {
|
|
399
|
+
progress(` ${task.label}: its model requests went silent (stalled) and did not recover — ` +
|
|
400
|
+
`most likely provider rate limiting; reporting a coverage gap`);
|
|
401
|
+
// Name the likely cause. OpenCode retries a 429 internally without surfacing
|
|
402
|
+
// it, so provider throttling reaches us as pure silence — indistinguishable
|
|
403
|
+
// from a wedged connection, and the single most common reason a pass produces
|
|
404
|
+
// nothing at all. Saying "went silent" alone sends people hunting for a bug
|
|
405
|
+
// in the reviewer instead of checking their usage window.
|
|
406
|
+
incomplete.push(`${capitalize(task.coverageLabel)} could not run: its model requests went silent and produced no output, even after being retried. ` +
|
|
407
|
+
`The usual cause is the model provider rate-limiting the account (a subscription credential over its usage window), which reaches this tool as silence rather than an error; ` +
|
|
408
|
+
`those changes were not fully reviewed.`);
|
|
409
|
+
}
|
|
410
|
+
else if (couldStillReduce) {
|
|
293
411
|
progress(` ${task.label}: exceeded ${minutes}m and the run's time budget is spent — reporting a coverage gap`);
|
|
294
412
|
incomplete.push(`${capitalize(task.coverageLabel)} timed out and the overall review budget was exhausted before it could be broken down further; those changes were not fully reviewed.`);
|
|
295
413
|
}
|
|
@@ -299,6 +417,18 @@ export async function runReview(source, options) {
|
|
|
299
417
|
}
|
|
300
418
|
}
|
|
301
419
|
});
|
|
420
|
+
// A substituted model means the review did not run on the model this repo
|
|
421
|
+
// configured — the findings may be from a weaker (or free-tier) model entirely.
|
|
422
|
+
// Never silent: it goes to the log, the coverage notes, and the run log.
|
|
423
|
+
if (substituted.size > 0) {
|
|
424
|
+
for (const line of substituted) {
|
|
425
|
+
progress(` ⚠ model substituted — ${line}`);
|
|
426
|
+
}
|
|
427
|
+
incomplete.push(`Some passes did not run on the configured model (${[...substituted].join("; ")}). ` +
|
|
428
|
+
`OpenCode silently falls back to a default model when the configured id is empty or ` +
|
|
429
|
+
`unusable, so these findings may come from a different (possibly much weaker) model ` +
|
|
430
|
+
`than intended — check the agents' \`model\`, \`coordinator.model\`, REVIEWER_MODEL, and the provider credential.`);
|
|
431
|
+
}
|
|
302
432
|
// Note: routine noise filtering (lockfiles, generated, binary) is expected and
|
|
303
433
|
// NOT a coverage gap — it stays in the run log (filteredFiles), not the
|
|
304
434
|
// user-facing coverage note, which is reserved for passes that didn't finish.
|
|
@@ -306,27 +436,28 @@ export async function runReview(source, options) {
|
|
|
306
436
|
let output;
|
|
307
437
|
if (completedPasses === 0) {
|
|
308
438
|
// Nothing succeeded — do NOT let this render as a clean "approve".
|
|
309
|
-
progress(
|
|
439
|
+
progress("All review passes failed — reporting an incomplete review.");
|
|
310
440
|
output = {
|
|
311
|
-
decision:
|
|
441
|
+
decision: "approve_with_comments",
|
|
312
442
|
findings: [],
|
|
313
|
-
summary:
|
|
443
|
+
summary: "⚠️ The AI review could not complete: every review pass failed or timed out, " +
|
|
314
444
|
'so these changes were effectively NOT reviewed. Treat this as "no review", not "looks good".',
|
|
315
445
|
incomplete: coverageNotes,
|
|
316
446
|
};
|
|
317
447
|
}
|
|
318
448
|
else {
|
|
319
|
-
progress(
|
|
449
|
+
progress("Coordinating findings…");
|
|
320
450
|
let consolidated;
|
|
321
451
|
try {
|
|
322
|
-
const { output: rawOutput, cost, tokens: coordinatorTokens, truncated: coordinatorTruncated, } = await coordinate(handle, config, metadata, agentFindings, coverageNotes);
|
|
323
|
-
agentCosts[
|
|
324
|
-
|
|
452
|
+
const { output: rawOutput, cost, tokens: coordinatorTokens, truncated: coordinatorTruncated, model: coordinatorModel, } = await coordinate(handle, config, metadata, agentFindings, coverageNotes);
|
|
453
|
+
agentCosts["coordinator"] = cost;
|
|
454
|
+
trackTokens("coordinator", coordinatorTokens);
|
|
455
|
+
trackModel("coordinator", config.coordinator.model, coordinatorModel);
|
|
325
456
|
consolidated = applyReviewPolicy(rawOutput, config.policy);
|
|
326
457
|
if (coordinatorTruncated) {
|
|
327
458
|
// The coordinator ran out of time and returned partial findings — flag it
|
|
328
459
|
// like any other truncated pass so reduced coverage is never silent.
|
|
329
|
-
coverageNotes.push(
|
|
460
|
+
coverageNotes.push("The consolidation step ran out of time and returned partial findings; some findings may have been dropped or not fully de-duplicated.");
|
|
330
461
|
}
|
|
331
462
|
}
|
|
332
463
|
catch (error) {
|
|
@@ -335,11 +466,11 @@ export async function runReview(source, options) {
|
|
|
335
466
|
// merge so a comment is still posted.
|
|
336
467
|
progress(`Coordinator failed (${errorMessage(error)}); consolidating findings locally.`);
|
|
337
468
|
consolidated = fallbackConsolidation(agentFindings, config.policy);
|
|
338
|
-
coverageNotes.push(
|
|
469
|
+
coverageNotes.push("The consolidation step failed, so findings are shown merged but not de-duplicated or re-judged.");
|
|
339
470
|
}
|
|
340
471
|
// A run with any failed/timed-out pass must never present as a clean approve.
|
|
341
|
-
const decision = failedPasses > 0 && consolidated.decision ===
|
|
342
|
-
?
|
|
472
|
+
const decision = failedPasses > 0 && consolidated.decision === "approve"
|
|
473
|
+
? "approve_with_comments"
|
|
343
474
|
: consolidated.decision;
|
|
344
475
|
output = { ...consolidated, decision, incomplete: [...new Set(coverageNotes)] };
|
|
345
476
|
}
|
|
@@ -347,11 +478,15 @@ export async function runReview(source, options) {
|
|
|
347
478
|
// finding against the real file, and adversarially verify criticals. This is
|
|
348
479
|
// what stops a confident but wrong critical from shipping.
|
|
349
480
|
const findingCountBeforeChecks = output.findings.length;
|
|
481
|
+
let verifierDropped = [];
|
|
350
482
|
if (output.findings.length > 0) {
|
|
351
|
-
progress(
|
|
483
|
+
progress("Verifying findings…");
|
|
352
484
|
const verification = await verifyFindings(handle, output.findings, process.cwd(), progress);
|
|
353
|
-
agentCosts[
|
|
354
|
-
|
|
485
|
+
agentCosts["verifier"] = verification.cost;
|
|
486
|
+
trackTokens("verifier", verification.tokens);
|
|
487
|
+
// Mirrors buildOpencodeConfig, which gives the verifier the first agent's model.
|
|
488
|
+
trackModel("verifier", config.agents[0]?.model ?? config.coordinator.model, verification.model);
|
|
489
|
+
verifierDropped = verification.dropped;
|
|
355
490
|
if (verification.dropped.length > 0) {
|
|
356
491
|
progress(`Verification dropped ${verification.dropped.length} unverified finding(s).`);
|
|
357
492
|
output = {
|
|
@@ -380,12 +515,26 @@ export async function runReview(source, options) {
|
|
|
380
515
|
if (removedAfterChecks > 0) {
|
|
381
516
|
output = { ...output, summary: reconcileSummary(output.summary, output.findings.length) };
|
|
382
517
|
}
|
|
518
|
+
// Every pass says which model actually answered it — in the job log, the step
|
|
519
|
+
// summary table, and the run log — so a wrong or substituted model is always
|
|
520
|
+
// visible, not just when the substitution warning fires.
|
|
521
|
+
if (Object.keys(agentModels).length > 0) {
|
|
522
|
+
progress(`Models used — ${Object.entries(agentModels)
|
|
523
|
+
.map(([bucket, model]) => `${bucket}: ${model}`)
|
|
524
|
+
.join("; ")}`);
|
|
525
|
+
}
|
|
383
526
|
progress(formatUsageSummary(tokenTotals, sum(agentCosts)));
|
|
527
|
+
await appendStepSummary(renderUsageMarkdown(agentTokens, agentCosts, tokenTotals, sum(agentCosts), agentModels));
|
|
384
528
|
await safeLog(logPath, {
|
|
385
529
|
...baseRecord,
|
|
386
530
|
agentCosts,
|
|
387
531
|
totalCost: sum(agentCosts),
|
|
388
532
|
tokens: tokenTotals,
|
|
533
|
+
agentTokens,
|
|
534
|
+
agentModels,
|
|
535
|
+
agentFindings,
|
|
536
|
+
coverageNotes,
|
|
537
|
+
verifierDropped,
|
|
389
538
|
durationMs: Date.now() - started,
|
|
390
539
|
decision: output.decision,
|
|
391
540
|
findingCount: output.findings.length,
|
|
@@ -399,6 +548,8 @@ export async function runReview(source, options) {
|
|
|
399
548
|
agentCosts,
|
|
400
549
|
totalCost: sum(agentCosts),
|
|
401
550
|
tokens: tokenTotals,
|
|
551
|
+
agentTokens,
|
|
552
|
+
agentFindings,
|
|
402
553
|
durationMs: Date.now() - started,
|
|
403
554
|
decision: null,
|
|
404
555
|
findingCount: 0,
|
|
@@ -420,13 +571,13 @@ export async function runReview(source, options) {
|
|
|
420
571
|
export function applyReviewPolicy(output, policy) {
|
|
421
572
|
let findings = policy.includeSuggestions
|
|
422
573
|
? output.findings
|
|
423
|
-
: output.findings.filter(finding => finding.severity !==
|
|
574
|
+
: output.findings.filter((finding) => finding.severity !== "suggestion");
|
|
424
575
|
findings = sortFindings(findings);
|
|
425
576
|
if (policy.maxFindings != null) {
|
|
426
577
|
findings = findings.slice(0, policy.maxFindings);
|
|
427
578
|
}
|
|
428
|
-
const decision = output.decision ===
|
|
429
|
-
?
|
|
579
|
+
const decision = output.decision === "approve_with_comments" && findings.length === 0
|
|
580
|
+
? "approve"
|
|
430
581
|
: output.decision;
|
|
431
582
|
return { ...output, findings, decision };
|
|
432
583
|
}
|
|
@@ -448,16 +599,16 @@ function fallbackConsolidation(agentFindings, policy) {
|
|
|
448
599
|
}
|
|
449
600
|
}
|
|
450
601
|
}
|
|
451
|
-
const decision = merged.some(finding => finding.severity ===
|
|
452
|
-
?
|
|
602
|
+
const decision = merged.some((finding) => finding.severity === "critical")
|
|
603
|
+
? "request_changes"
|
|
453
604
|
: merged.length > 0
|
|
454
|
-
?
|
|
455
|
-
:
|
|
605
|
+
? "approve_with_comments"
|
|
606
|
+
: "approve";
|
|
456
607
|
return applyReviewPolicy({
|
|
457
608
|
decision,
|
|
458
609
|
findings: merged,
|
|
459
|
-
summary:
|
|
460
|
-
|
|
610
|
+
summary: "Consolidation step failed; showing the specialist reviewers’ findings " +
|
|
611
|
+
"merged and de-duplicated, but not re-judged.",
|
|
461
612
|
incomplete: [],
|
|
462
613
|
}, policy);
|
|
463
614
|
}
|
|
@@ -468,10 +619,10 @@ function fallbackConsolidation(agentFindings, policy) {
|
|
|
468
619
|
*/
|
|
469
620
|
export function decisionAfterVerification(previous, kept) {
|
|
470
621
|
if (kept.length === 0) {
|
|
471
|
-
return
|
|
622
|
+
return "approve";
|
|
472
623
|
}
|
|
473
|
-
if (previous ===
|
|
474
|
-
return
|
|
624
|
+
if (previous === "request_changes" && !kept.some((finding) => finding.severity === "critical")) {
|
|
625
|
+
return "approve_with_comments";
|
|
475
626
|
}
|
|
476
627
|
return previous;
|
|
477
628
|
}
|
|
@@ -484,10 +635,10 @@ export function decisionAfterVerification(previous, kept) {
|
|
|
484
635
|
*/
|
|
485
636
|
export function reconcileSummary(summary, remaining) {
|
|
486
637
|
if (remaining === 0) {
|
|
487
|
-
return
|
|
638
|
+
return "All candidate findings were removed by automated verification and suppression, so no issues remain to report.";
|
|
488
639
|
}
|
|
489
|
-
return (
|
|
490
|
-
|
|
640
|
+
return ("_Note: some findings were removed by automated verification/suppression after " +
|
|
641
|
+
"this summary was written, so it may mention issues no longer listed below._\n\n" +
|
|
491
642
|
summary);
|
|
492
643
|
}
|
|
493
644
|
/** Capitalize the first letter (coverage notes read as sentences). */
|
|
@@ -514,19 +665,19 @@ export function isAuthError(error) {
|
|
|
514
665
|
new RegExp(`${problem.source}\\b[^.]{0,20}${cred.source}`).test(message) ||
|
|
515
666
|
new RegExp(`${cred.source}[^.]{0,20}${problem.source}`).test(message));
|
|
516
667
|
}
|
|
517
|
-
const AUTH_FAILURE_NOTE =
|
|
518
|
-
|
|
519
|
-
|
|
668
|
+
const AUTH_FAILURE_NOTE = "The model provider rejected the request (authentication or permission). Check the " +
|
|
669
|
+
"configured credential (auth.tokenEnv, or REVIEWER_MODEL for a local run) and re-run — " +
|
|
670
|
+
"those changes were not reviewed.";
|
|
520
671
|
function selectAgents(all, filter) {
|
|
521
672
|
if (!filter?.length) {
|
|
522
673
|
return all;
|
|
523
674
|
}
|
|
524
|
-
const known = new Set(all.map(agent => agent.id));
|
|
525
|
-
const unknown = filter.filter(id => !known.has(id));
|
|
675
|
+
const known = new Set(all.map((agent) => agent.id));
|
|
676
|
+
const unknown = filter.filter((id) => !known.has(id));
|
|
526
677
|
if (unknown.length > 0) {
|
|
527
|
-
throw new Error(`Unknown agent(s): ${unknown.join(
|
|
678
|
+
throw new Error(`Unknown agent(s): ${unknown.join(", ")}. Available: ${all.map((a) => a.id).join(", ")}`);
|
|
528
679
|
}
|
|
529
|
-
return all.filter(agent => filter.includes(agent.id));
|
|
680
|
+
return all.filter((agent) => filter.includes(agent.id));
|
|
530
681
|
}
|
|
531
682
|
/**
|
|
532
683
|
* Greedily pack files into chunks bounded by total changed lines (primary) and
|
|
@@ -604,8 +755,32 @@ export function formatUsageSummary(tokens, totalCost) {
|
|
|
604
755
|
parts.push(`reasoning ${tokens.reasoning}`);
|
|
605
756
|
}
|
|
606
757
|
parts.push(`cache read ${tokens.cache?.read ?? 0}`, `cache write ${tokens.cache?.write ?? 0}`);
|
|
607
|
-
const cost = totalCost > 0 ? ` (cost $${totalCost.toFixed(4)})` :
|
|
608
|
-
return `Token usage — ${parts.join(
|
|
758
|
+
const cost = totalCost > 0 ? ` (cost $${totalCost.toFixed(4)})` : "";
|
|
759
|
+
return `Token usage — ${parts.join(", ")}${cost}`;
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Markdown-table version of the usage summary for the Actions step summary: one
|
|
763
|
+
* row per pass plus a total, and the prompt-cache hit rate (the share of prompt
|
|
764
|
+
* tokens served from cache instead of being reprocessed at full price).
|
|
765
|
+
*/
|
|
766
|
+
export function renderUsageMarkdown(agentTokens, agentCosts, totals, totalCost, agentModels = {}) {
|
|
767
|
+
const row = (label, model, tokens, cost) => `| ${label} | ${model} | ${tokens.input ?? 0} | ${tokens.output ?? 0} | ${tokens.cache?.read ?? 0} | ${tokens.cache?.write ?? 0} | $${cost.toFixed(4)} |`;
|
|
768
|
+
const lines = [
|
|
769
|
+
"### 🤖 AI review — token usage",
|
|
770
|
+
"",
|
|
771
|
+
"| pass | model | input | output | cache read | cache write | cost |",
|
|
772
|
+
"| --- | --- | ---: | ---: | ---: | ---: | ---: |",
|
|
773
|
+
...Object.keys(agentCosts).map((bucket) => row(bucket, agentModels[bucket] ?? "—", agentTokens[bucket] ?? {}, agentCosts[bucket] ?? 0)),
|
|
774
|
+
row("**total**", "", totals, totalCost),
|
|
775
|
+
];
|
|
776
|
+
const read = totals.cache?.read ?? 0;
|
|
777
|
+
const uncached = totals.input ?? 0;
|
|
778
|
+
if (read + uncached > 0) {
|
|
779
|
+
const rate = Math.round((read / (read + uncached)) * 100);
|
|
780
|
+
lines.push("", `Prompt cache hit rate: **${rate}%** (cache read / (cache read + input)). ` +
|
|
781
|
+
'See "Tokens, cost & prompt caching" in the README for how to read these numbers.');
|
|
782
|
+
}
|
|
783
|
+
return lines.join("\n");
|
|
609
784
|
}
|
|
610
785
|
async function safeLog(logPath, record) {
|
|
611
786
|
try {
|