fix(#4255): resolve reviewer-lane effort from the lane, not from gsd-plan-checker (#4275)

`review-lane plan` resolved every cross-AI reviewer lane's reasoning effort by
spawning `query resolve-execution gsd-plan-checker --host <slug>`. The agent id
was a hardcoded literal, so `--host` chose only the argv RENDERING while the
LEVEL always came from the installed plan-checker's frontmatter — `low` under
every shipped model profile. Every prompt-fed lane therefore ran at a fast
structural verifier's effort, and because the rendered argument is a CLI config
override it silently beat the effort the operator had configured for that CLI.
At `low` a large source-grounded prompt makes a model end its turn with no final
message, so the lane came back empty and its stub read as a crash.

Effort is a property of the review, so the lane declares it. Two new fields on
ReviewerLane — `effortConfigKey` (`review.effort.<slug>`) and `defaultEffort` —
carried through each capability manifest and the generated registry, set on the
three lanes with an argv effort channel and null on the other nine. A new pure
`resolveLaneEffort()` resolves config key -> lane default -> nothing, where
"nothing" emits no effort argument at all and the reviewer CLI's own
configuration decides; `inherit` selects that path explicitly and an
unrecognized level falls back to the lane default rather than being forwarded to
a CLI that would reject it. The host's negotiated effortSurface still gates the
rendering, so ADR-1239/#2481's trust boundary holds on this path too. Resolving
in-process also removes up to twelve subprocess spawns per review.

The empty-output stub now names the effort the lane ran at and distinguishes a
clean exit from a timeout kill, a non-zero exit, and a process that never ran —
`status` is null for both a timeout and a signal, so those were indistinguishable
before. The hint is hedged: a clean empty exit is most often a model stopping
short, but it is also consistent with a CLI writing its output elsewhere.

Also: the capability validator now knows both fields, rejects a malformed key or
an out-of-vocabulary default, and rejects a default declared without a config
key (a level the operator could never override). An existing end-to-end row in
tests/effort-surface-axis.test.cjs asserted the old coupling; it now configures
the lane's own key and pins the decoupling in the same real spawn, with the
agent execution tier set to a level that must not appear.

Emitted-Drift-Ack-Growth: review.md — the effort/model resolution-order table this fix adds. The workflow is where an operator looks to find out which knob set a lane's model and effort; leaving the new key undocumented there is the same invisibility that made the plan-checker coupling survive this long.

Emitted-Drift-Ack-Growth: review.md — the effort/model resolution-order table this fix adds. The workflow is where an operator looks to find out which knob set a lane's model and effort, so leaving the new key undocumented there is the same invisibility that let the plan-checker coupling survive.

Claude-Session: https://claude.ai/code/session_01CRMEuzNMWn3gs5uUW2ghcF

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
Behruz Nassre Esfahani
2026-09-05 02:25:44 -07:00
committed by GitHub
parent 925a363879
commit 5ad9a36f35
26 changed files with 759 additions and 61 deletions

View File

@@ -721,6 +721,11 @@ function cmdResolveExecution(cwd: string, agentType: string | undefined, raw: bo
* applies here exactly as everywhere else: an unknown host, a missing axis, or the
* `undocumented` sentinel all degrade to the safe floor rather than being trusted.
* Never throws — a lookup failure yields `'none'`, which renders no argument.
*
* On the module's export surface for #4255: the reviewer-lane effort resolver renders a lane's own
* configured level through this same negotiation, so a lane can never emit an argument for a host
* whose negotiated surface does not accept one. One negotiation, both channels — a second copy in
* the lane path is exactly how the two would drift.
*/
function effortSurfaceForHost(cwd: string, host: string): string {
void cwd;
@@ -3458,6 +3463,7 @@ function cmdCommitDocsGuardDisable(cwd: string, raw: boolean): void {
}
export = {
effortSurfaceForHost,
groupFilesBySubrepo,
determinePhaseStatus,
foldPhaseStatus,

View File

@@ -224,6 +224,30 @@ interface ReviewerLaneCommon {
* the lane, and a naming convention that one shipped lane already breaks is not a contract.
*/
modelConfigKey: string | null;
/**
* Dotted config key holding this lane's REVIEW reasoning effort, or null when the lane has no
* effort channel to feed.
*
* #4255. Lane effort used to be resolved by querying `gsd-plan-checker`'s execution settings
* with a hardcoded agent id, so every prompt-fed lane ran at the plan CHECKER's frontmatter
* effort — `low` under every shipped profile. A cross-AI review is the opposite workload from a
* fast structural verifier, and `low` is where a large prompt makes a model end its turn with no
* final message: the lane came back empty and the stub read as a crash. Effort is a property of
* the REVIEW, so it is declared here beside the lane's other keys and never inherited from an
* agent that has nothing to do with reviewing.
*/
effortConfigKey: string | null;
/**
* The effort this lane runs at when `effortConfigKey` is unset — the review-specific default,
* declared per lane rather than centrally so a lane that wants a different floor can say so.
*
* #4255. `'high'` for the prompt-fed, source-grounded lanes: they read the repository against a
* plan set, which is the one place effort is load-bearing. `null` means GSD has NO
* review-specific value for this lane and the invocation emits no effort argument at all, so the
* CLI's own configuration decides — the correct behaviour when GSD has nothing of its own to
* say. A configured `'inherit'` selects that same no-argument path explicitly (#3533).
*/
defaultEffort: string | null;
handler: LaneHandler;
}
@@ -280,6 +304,8 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
requiresBinaries: [],
promptBudgetKey: 'review.max_prompt_tokens_per_reviewer.gemini',
modelConfigKey: 'review.models.gemini',
effortConfigKey: null,
defaultEffort: null,
handler: null,
},
{
@@ -313,6 +339,8 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
requiresBinaries: [],
promptBudgetKey: 'review.max_prompt_tokens_per_reviewer.claude',
modelConfigKey: 'review.models.claude',
effortConfigKey: 'review.effort.claude',
defaultEffort: 'high',
handler: null,
},
{
@@ -342,6 +370,8 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
requiresBinaries: [],
promptBudgetKey: 'review.max_prompt_tokens_per_reviewer.codex',
modelConfigKey: 'review.models.codex',
effortConfigKey: 'review.effort.codex',
defaultEffort: 'high',
handler: null,
},
{
@@ -369,6 +399,8 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
promptBudgetKey: 'review.max_prompt_tokens_per_reviewer.coderabbit',
// Accepts no model flag at all (review.md:367) — not merely "none configured".
modelConfigKey: null,
effortConfigKey: null,
defaultEffort: null,
handler: null,
},
{
@@ -396,6 +428,8 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
requiresBinaries: [],
promptBudgetKey: 'review.max_prompt_tokens_per_reviewer.opencode',
modelConfigKey: 'review.models.opencode',
effortConfigKey: 'review.effort.opencode',
defaultEffort: 'high',
// Phase 5b (#2799): was `null`. The review is REBUILT from assistant `text` parts; a plain
// stdout copy would write the raw JSON envelope as the review (#1936). See LaneHandler.
handler: 'opencode',
@@ -420,6 +454,8 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
requiresBinaries: [],
promptBudgetKey: 'review.max_prompt_tokens_per_reviewer.qwen',
modelConfigKey: null,
effortConfigKey: null,
defaultEffort: null,
handler: null,
},
{
@@ -447,6 +483,8 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
promptBudgetKey: 'review.max_prompt_tokens_per_reviewer.cursor',
// #3653: cursor-agent exposes --model (204 selectable models); wired the same as codex.
modelConfigKey: 'review.models.cursor',
effortConfigKey: null,
defaultEffort: null,
handler: null,
},
{
@@ -482,6 +520,8 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
// NOT `review.models.antigravity` — the shipped key is `review.models.agy` (review.md:291) and
// Phase 4 federated it under that name. This lane is why the key is declared, not derived.
modelConfigKey: 'review.models.agy',
effortConfigKey: null,
defaultEffort: null,
handler: 'antigravity',
},
{
@@ -512,6 +552,8 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
requiresBinaries: [],
promptBudgetKey: 'review.max_prompt_tokens_per_reviewer.ollama',
modelConfigKey: 'review.models.ollama',
effortConfigKey: null,
defaultEffort: null,
handler: 'openai-compatible',
},
{
@@ -540,6 +582,8 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
requiresBinaries: [],
promptBudgetKey: 'review.max_prompt_tokens_per_reviewer.lm_studio',
modelConfigKey: 'review.models.lm_studio',
effortConfigKey: null,
defaultEffort: null,
handler: 'openai-compatible',
},
{
@@ -568,6 +612,8 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
requiresBinaries: [],
promptBudgetKey: 'review.max_prompt_tokens_per_reviewer.llama_cpp',
modelConfigKey: 'review.models.llama_cpp',
effortConfigKey: null,
defaultEffort: null,
handler: 'openai-compatible',
},
{
@@ -614,6 +660,8 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
requiresBinaries: [],
promptBudgetKey: 'review.max_prompt_tokens_per_reviewer.kimi-code',
modelConfigKey: 'review.models.kimi-code',
effortConfigKey: null,
defaultEffort: null,
handler: null,
},
].map((lane) => Object.freeze(lane)) as ReviewerLane[]);

View File

@@ -265,6 +265,70 @@ export function normalizeHost(raw: string): string {
* negative values are deliberately treated as unset too — a timeout has no legitimate zero or
* negative value, so no second sentinel (unlike the prompt-budget keys, which use -1) is needed.
*/
/**
* The reasoning effort a reviewer lane runs at, and its host-rendered argv (#4255).
*
* `argv` is spliced into `{{effort}}`; `value` is the bare level the runner folds into the
* recorded model designation (`gpt-5.6-sol (reasoning=high)`, #2295). Both are empty/null when
* this lane emits no effort argument, which is a real and correct outcome — see `resolveLaneEffort`.
*/
export interface LaneEffort {
argv: readonly string[];
value: string | null;
/** Where `value` came from, for diagnostics: the config key, the lane default, or nothing. */
source: 'config' | 'lane-default' | 'none';
}
/** Levels GSD's effort axis accepts (#3533). `inherit` selects the no-argument path. */
const EFFORT_LEVELS: ReadonlySet<string> = new Set([
'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'inherit',
]);
/**
* Resolve one lane's reasoning effort from REVIEW configuration (#4255).
*
* Resolution order, highest first:
* 1. `lane.effortConfigKey` — the per-lane review effort the operator set
* 2. `lane.defaultEffort` — the lane's declared review default (`high` for prompt-fed,
* source-grounded lanes)
* 3. nothing — no effort argument is emitted and the reviewer CLI's own configuration decides
*
* A configured `'inherit'` selects (3) explicitly. An unrecognized level is REFUSED rather than
* passed to the host: it falls back to the lane default, because forwarding a typo would render an
* argument the CLI rejects and kill the lane outright.
*
* What this function deliberately does NOT do is consult any agent's execution settings. Before
* #4255 the level came from `gsd-plan-checker`'s installed frontmatter through a hardcoded agent
* id, so every lane ran at a fast structural verifier's `low` — and, because the rendered argument
* is a CLI config override, it silently beat the effort the operator had configured for that CLI
* itself. A value inherited from an unrelated agent is worse than no value at all, which is why
* (3) emits nothing rather than falling back to some other agent's number.
*
* `renderArgv` is injected (the host table and the ADR-2481 surface negotiation live in
* `model-catalog` / `commands`, above this module's layer) so this stays a pure function of its
* inputs and the golden lane table can assert it without a spawn.
*/
export function resolveLaneEffort(
lane: ReviewerLane,
configGet: (key: string) => unknown,
renderArgv: (host: string, level: string) => { argv: readonly string[]; value: string | null },
): LaneEffort {
const none: LaneEffort = { argv: [], value: null, source: 'none' };
if (!lane || typeof lane !== 'object') return none;
const configured = lane.effortConfigKey ? configString(configGet(lane.effortConfigKey)) : null;
const valid = configured !== null && EFFORT_LEVELS.has(configured) ? configured : null;
const level = valid ?? configString(lane.defaultEffort);
if (level === null || level === 'inherit') return none;
const rendered = renderArgv(lane.slug, level);
const argv = (rendered.argv ?? []).filter((a): a is string => typeof a === 'string' && a !== '');
if (argv.length === 0) return none;
return {
argv,
value: configString(rendered.value) ?? level,
source: valid !== null ? 'config' : 'lane-default',
};
}
export function resolveTimeoutMs(
timeoutConfigKey: string | null | undefined,
floorMs: number,

View File

@@ -420,12 +420,20 @@ export async function probeLane(
* `extraDiagnostics` carries the raw HTTP response body for the OpenAI-compatible lanes: an error
* from such a server arrives with HTTP 4xx/5xx and the JSON in the BODY, so stderr alone is empty
* and the body is the only evidence.
*
* `outcome` (#4255) carries the spawn's exit status so the stub can say WHICH empty it is. The
* header alone cannot: a crash, a timeout kill and a model that ended its turn without writing a
* final message all reach here as the same zero bytes, and the third is what a too-low reasoning
* effort produces on a large source-grounded prompt. A clean exit inside the timeout with no
* output is a stopped-short model, and the stub now says so — with the effort it ran at, which is
* the value the operator would change.
*/
export function writeReviewOrStub(
plan: LanePlan,
content: string,
deps: RunnerDeps,
extraDiagnostics?: string,
outcome?: { status: number | null; errorCode?: string },
): { stubbed: boolean } {
if (!isEmptyReview(content)) {
deps.writeFile(plan.reviewPath, content.endsWith('\n') ? content : `${content}\n`);
@@ -434,10 +442,57 @@ export function writeReviewOrStub(
const stderr = deps.exists(plan.errPath) ? deps.readFile(plan.errPath) : '';
const parts = [`${plan.slug} review failed or returned empty output. stderr:`, stderr];
if (extraDiagnostics) parts.push('Raw response body:', extraDiagnostics);
parts.push(emptyOutputDiagnosis(plan, outcome));
deps.writeFile(plan.reviewPath, `${parts.join('\n')}\n`);
return { stubbed: true };
}
/**
* One line naming the effort the lane ran at and how the process ended (#4255).
*
* Kept out of the header so the `failed or returned empty output` string every downstream reader
* greps for is untouched — this is an added line, not a reworded one.
*/
export function emptyOutputDiagnosis(
plan: LanePlan,
outcome?: { status: number | null; errorCode?: string },
): string {
// `effort` lives on the spawn plan only. An HTTP lane reaches a server directly and has no
// reviewer CLI at all, so naming one there would be a lie about what ran (Codex review of
// #4255) — the two transports get different, accurate wording.
const spawned = plan.transport === 'spawn';
const level = spawned ? plan.effort : null;
const effort = level
? `ran at effort=${level}`
: spawned
? "ran with no effort argument, so the reviewer CLI's own configuration applied"
: 'is an HTTP lane and carries no reasoning-effort setting';
if (!outcome) return `Diagnosis: ${plan.slug} ${effort}.`;
// Four endings, not two. `status` is null for BOTH a timeout kill and a process that never
// ran or died on a signal (ENOENT, SIGKILL) — reporting the latter as "status null" said
// nothing, and folding them together would attach the stopped-short hint to a crash.
const timedOut = outcome.errorCode === 'ETIMEDOUT';
const neverRan = !timedOut && outcome.status === null;
const cleanExit = outcome.status === 0;
const ending = timedOut
? 'was killed by the outer timeout'
: neverRan
? `did not exit normally (${outcome.errorCode ?? 'killed by a signal'})`
: cleanExit
? 'exited cleanly inside the timeout'
: `exited with status ${String(outcome.status)}`;
// Hedged deliberately. A clean exit with no output is CONSISTENT with a model ending its turn
// without a final message — which is what too low an effort produces on a large prompt — but it
// is equally consistent with the CLI writing its output somewhere this lane did not read. The
// line points at the likeliest cause without asserting it.
const tail = cleanExit
? ' — a clean exit that produced no output is most often a model ending its turn without'
+ ' writing a final message rather than a crash; if this lane carries a reasoning effort,'
+ ' raising it is the usual fix.'
: '.';
return `Diagnosis: ${plan.slug} ${effort} and ${ending}${tail}`;
}
/* ------------------------------------------------------------------ *
* Handlers (D6) — named first-party code, never conditionals in data
* ------------------------------------------------------------------ */
@@ -1218,7 +1273,7 @@ function runSpawnLane(plan: SpawnPlan, deps: RunnerDeps, repoRoot: string): Lane
// folded in as a diff observation, and the citation check must not change that surface.
if (plan.evidenceClass !== 'diff-only') review = stampUngroundedReview(review);
const { stubbed } = writeReviewOrStub(plan, review, deps, extra);
const { stubbed } = writeReviewOrStub(plan, review, deps, extra, out);
return { slug: plan.slug, ok: true, stubbed, model };
}