`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:
committed by
GitHub
parent
925a363879
commit
5ad9a36f35
@@ -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,
|
||||
|
||||
@@ -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[]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user