diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 12f62450c..50050294b 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -5,13 +5,13 @@ }, "metadata": { "description": "Codex plugins to use in Claude Code for delegation and code review.", - "version": "1.0.40" + "version": "1.0.41" }, "plugins": [ { "name": "codex", "description": "Use Codex from Claude Code to review code or delegate tasks.", - "version": "1.0.40", + "version": "1.0.41", "author": { "name": "OpenAI" }, diff --git a/README.md b/README.md index 14e14f1d0..729a4d4aa 100644 --- a/README.md +++ b/README.md @@ -297,7 +297,28 @@ Examples: Checks whether Codex is installed and authenticated. If Codex is missing and npm is available, it can offer to install Codex for you. -You can also use `/codex:setup` to manage the optional review gate. +You can also use `/codex:setup` to manage the optional review gate and the backup model. + +#### Choosing a backup model + +```bash +/codex:setup --fallback-model gpt-5.6-terra +/codex:setup --clear-fallback-model +``` + +When a Codex run is rejected because the selected model is at capacity, the plugin retries the run once on a backup model instead of failing. Capacity rejections are transient and have nothing to do with your prompt, so the same work usually succeeds immediately on another model. + +The backup model is resolved in this order: + +1. the `CODEX_COMPANION_FALLBACK_MODEL` environment variable — set it to `none` to turn the fallback off entirely +2. the model configured with `/codex:setup --fallback-model` +3. whatever Codex itself advertises, picking its default model and skipping the one that was at capacity + +Nothing is hardcoded, so the third step keeps working as OpenAI renames models. + +The retry only happens when the rejected turn produced nothing at all — no output, no commands, no file edits. A capacity rejection that arrives after work has started leaves the run failed rather than risking a repeat of something that already ran. + +Either way the failure is machine-readable: `/codex:status` and `/codex:result` report `failureClass` and `retryable` in their JSON, so a caller never has to pattern-match an error message to tell a transient capacity rejection from a real failure. #### Enabling review gate @@ -346,6 +367,8 @@ For rescue, `--background` backgrounds the subagent rather than the Codex run: t `/codex:status` and `/codex:result ` inspect a tracked job from another turn, or recover a run whose turn ended before its result was read. +A background review pins the target it resolved at enqueue time, along with the repository state that target points at. If the repository moves before the detached worker starts — you commit the changes it was going to review, switch branches, or the base branch is deleted — the job fails with `failureClass: "state-drift"` and `retryable: true` rather than reviewing whatever is there now. Re-running the review picks up the new state. This trades a rare visible failure for the far worse alternative: a clean review of a change nobody looked at. + ## Codex Integration The Codex plugin wraps the [Codex app server](https://developers.openai.com/codex/app-server). It uses the global `codex` binary installed in your environment and [applies the same configuration](https://developers.openai.com/codex/config-basic). diff --git a/package-lock.json b/package-lock.json index 6f61eaca1..0366a327a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@openai/codex-plugin-cc", - "version": "1.0.40", + "version": "1.0.41", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@openai/codex-plugin-cc", - "version": "1.0.40", + "version": "1.0.41", "license": "Apache-2.0", "devDependencies": { "@types/node": "^25.5.0", diff --git a/package.json b/package.json index a3e19c574..57f195741 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@openai/codex-plugin-cc", - "version": "1.0.40", + "version": "1.0.41", "private": true, "type": "module", "description": "Use Codex from Claude Code to review code or delegate tasks.", diff --git a/plugins/codex/.claude-plugin/plugin.json b/plugins/codex/.claude-plugin/plugin.json index 34155caba..b2602867c 100644 --- a/plugins/codex/.claude-plugin/plugin.json +++ b/plugins/codex/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codex", - "version": "1.0.40", + "version": "1.0.41", "description": "Use Codex from Claude Code to review code or delegate tasks.", "author": { "name": "OpenAI" diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index f4e49c463..24f19f331 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -26,8 +26,15 @@ import { runAppServerTurn } from "./lib/codex.mjs"; import { resolveClaudeSessionPath } from "./lib/claude-session-transfer.mjs"; +import { STATE_DRIFT } from "./lib/failure-class.mjs"; import { readStdinIfPiped } from "./lib/fs.mjs"; -import { collectReviewContext, ensureGitRepository, resolveReviewTarget } from "./lib/git.mjs"; +import { + captureRepoStateIdentity, + collectReviewContext, + describeRepoStateDrift, + ensureGitRepository, + resolveReviewTarget +} from "./lib/git.mjs"; import { binaryAvailable, getProcessStartTime, @@ -87,7 +94,7 @@ const VALID_REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "hi const MODEL_ALIASES = new Map([["spark", "gpt-5.3-codex-spark"]]); const STOP_REVIEW_TASK_MARKER = "Run a stop-gate review of the previous Claude turn."; const SUBCOMMAND_USAGE = new Map([ - ["setup", " node scripts/codex-companion.mjs setup [--enable-review-gate|--disable-review-gate] [--json]"], + ["setup", " node scripts/codex-companion.mjs setup [--enable-review-gate|--disable-review-gate] [--fallback-model |--clear-fallback-model] [--json]"], ["review", " node scripts/codex-companion.mjs review [--wait|--background] [--base ] [--scope ]"], ["adversarial-review", " node scripts/codex-companion.mjs adversarial-review [--wait|--background] [--base ] [--scope ] [--model ] [--effort ] [focus text]"], ["deep-review", " node scripts/codex-companion.mjs deep-review [--wait|--background] [--base ] [--scope ] [--model ] [--effort ] [focus text]"], @@ -122,8 +129,8 @@ const TASK_PARSE_CONFIG = { stopAtFirstPositional: true }; const SETUP_PARSE_CONFIG = { - valueOptions: ["cwd"], - booleanOptions: ["json", "enable-review-gate", "disable-review-gate"] + valueOptions: ["cwd", "fallback-model"], + booleanOptions: ["json", "enable-review-gate", "disable-review-gate", "clear-fallback-model"] }; const TRANSFER_PARSE_CONFIG = { valueOptions: ["cwd", "source"], @@ -330,6 +337,7 @@ async function buildSetupReport(cwd, actionsTaken = []) { auth: authStatus, sessionRuntime: getSessionRuntimeStatus(process.env, workspaceRoot), reviewGateEnabled: Boolean(config.stopReviewGate), + fallbackModel: config.fallbackModel ?? null, actionsTaken, nextSteps }; @@ -341,6 +349,15 @@ async function handleSetup(argv) { if (options["enable-review-gate"] && options["disable-review-gate"]) { throw new Error("Choose either --enable-review-gate or --disable-review-gate."); } + if (options["fallback-model"] != null && options["clear-fallback-model"]) { + throw new Error("Choose either --fallback-model or --clear-fallback-model."); + } + const fallbackModel = options["fallback-model"] == null + ? null + : String(options["fallback-model"]).trim(); + if (fallbackModel === "") { + throw new Error("--fallback-model requires a non-empty model name."); + } const cwd = resolveCommandCwd(options); const workspaceRoot = resolveCommandWorkspace(options); @@ -353,6 +370,13 @@ async function handleSetup(argv) { setConfig(workspaceRoot, "stopReviewGate", false); actionsTaken.push(`Disabled the stop-time review gate for ${workspaceRoot}.`); } + if (fallbackModel != null) { + setConfig(workspaceRoot, "fallbackModel", fallbackModel); + actionsTaken.push(`Configured fallback model ${fallbackModel} for ${workspaceRoot}.`); + } else if (options["clear-fallback-model"]) { + setConfig(workspaceRoot, "fallbackModel", null); + actionsTaken.push(`Cleared the configured fallback model for ${workspaceRoot}.`); + } const finalReport = await buildSetupReport(cwd, actionsTaken); outputResult(options.json ? finalReport : renderSetupReport(finalReport), options.json); @@ -496,6 +520,21 @@ async function executeReviewRun(request) { base: request.base, scope: request.scope }); + const assertPinnedState = (completed = false) => { + if (!request.stateIdentity) { + return; + } + const drift = describeRepoStateDrift(request.cwd, target, request.stateIdentity); + if (!drift) { + return; + } + const message = completed + ? `Review completed against repository state that has since moved: ${drift}. Discarding the result; re-run the review.` + : `Review target moved between enqueue and execution: ${drift}. Re-run the review.`; + throw Object.assign(new Error(message), { failureClass: STATE_DRIFT, retryable: true }); + }; + + assertPinnedState(); const focusText = request.focusText?.trim() ?? ""; const reviewName = request.reviewName ?? "Review"; if (reviewName === "Review") { @@ -505,11 +544,16 @@ async function executeReviewRun(request) { model: request.model, onProgress: request.onProgress }); + assertPinnedState(true); + const effectiveModel = result.modelFallback?.to ?? request.model; const payload = { review: reviewName, target, - model: request.model ?? null, + model: effectiveModel ?? null, effort: request.effort ?? null, + failureClass: result.failureClass, + modelFallback: result.modelFallback ?? null, + retryable: result.retryable, threadId: result.threadId, sourceThreadId: result.sourceThreadId, codex: { @@ -523,13 +567,17 @@ async function executeReviewRun(request) { { status: result.status, stdout: result.reviewText, - stderr: result.stderr + stderr: result.stderr, + failureClass: result.failureClass, + retryable: result.retryable }, - { reviewLabel: reviewName, targetLabel: target.label, model: request.model, reasoningSummary: result.reasoningSummary } + { reviewLabel: reviewName, targetLabel: target.label, model: effectiveModel, reasoningSummary: result.reasoningSummary } ); return { exitStatus: result.status, + failureClass: result.failureClass, + retryable: result.retryable, threadId: result.threadId, turnId: result.turnId, payload, @@ -542,6 +590,10 @@ async function executeReviewRun(request) { } const context = collectReviewContext(request.cwd, target); + // Validate the state the context was actually built from, not the state a few + // statements earlier: an inline diff is frozen into the prompt here, so this is + // the last moment its content can be checked against what was pinned. + assertPinnedState(); const prompt = reviewName === "Deep Review" ? buildDeepReviewPrompt(context, focusText) @@ -555,16 +607,25 @@ async function executeReviewRun(request) { outputSchema: readOutputSchema(REVIEW_SCHEMA), onProgress: request.onProgress }); + if (context.inputMode === "self-collect") { + assertPinnedState(true); + } + const effectiveModel = result.modelFallback?.to ?? request.model; const parsed = parseStructuredOutput(result.status === 0 ? result.finalMessage : "", { status: result.status, - failureMessage: result.error?.message ?? result.stderr + failureMessage: result.error?.message ?? result.stderr, + failureClass: result.failureClass, + retryable: result.retryable }); const payload = { review: reviewName, target, - model: request.model ?? null, + model: effectiveModel ?? null, effort: request.effort ?? null, effortWarning: result.effortWarning, + failureClass: result.failureClass, + modelFallback: result.modelFallback, + retryable: result.retryable, threadId: result.threadId, context: { repoRoot: context.repoRoot, @@ -585,13 +646,15 @@ async function executeReviewRun(request) { return { exitStatus: result.status, + failureClass: result.failureClass, + retryable: result.retryable, threadId: result.threadId, turnId: result.turnId, payload, rendered: renderReviewResult(parsed, { reviewLabel: reviewName, targetLabel: context.target.label, - model: request.model, + model: effectiveModel, effort: request.effort ?? "codex default", reasoningSummary: result.reasoningSummary }), @@ -650,6 +713,8 @@ async function executeTaskRun(request) { rawOutput, partialOutput, failureMessage, + failureClass: result.failureClass, + retryable: result.retryable, touchedFiles: result.touchedFiles, reasoningSummary: result.reasoningSummary }, @@ -661,6 +726,9 @@ async function executeTaskRun(request) { ); const payload = { status: result.status, + failureClass: result.failureClass, + modelFallback: result.modelFallback, + retryable: result.retryable, threadId: result.threadId, rawOutput, partialOutput, @@ -672,6 +740,8 @@ async function executeTaskRun(request) { return { exitStatus: result.status, + failureClass: result.failureClass, + retryable: result.retryable, threadId: result.threadId, turnId: result.turnId, payload, @@ -996,6 +1066,7 @@ async function handleReviewCommand(argv, config) { base: options.base, scope: options.scope, target, + stateIdentity: captureRepoStateIdentity(cwd, target), model, effort, effortOverride, diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index b4c30624a..327f51a08 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -28,6 +28,7 @@ * activityTimer: ReturnType | null, * lastActivityAt: number | null, * activityCount: number, + * itemActivityCount: number, * stallCleanup: Promise | null, * stalled: boolean, * activeTools: Map | null, armedAt: number | null }>, @@ -49,8 +50,10 @@ import path from "node:path"; import { readJsonFile } from "./fs.mjs"; import { BROKER_BUSY_RPC_CODE, BROKER_ENDPOINT_ENV, CodexAppServerClient } from "./app-server.mjs"; import { loadBrokerSession } from "./broker-lifecycle.mjs"; +import { CAPACITY, classifyFailureMessage } from "./failure-class.mjs"; import { resolveWorktreeWritableRoots } from "./git.mjs"; import { binaryAvailable } from "./process.mjs"; +import { getConfig } from "./state.mjs"; const SERVICE_NAME = "claude_code_codex_plugin"; const TASK_THREAD_PREFIX = "Codex Companion Task"; @@ -227,6 +230,16 @@ function collectTouchedFiles(fileChanges) { return [...paths]; } +function turnProducedNothing(turnState) { + return ( + turnState.itemActivityCount === 0 && + turnState.messages.length === 0 && + turnState.fileChanges.length === 0 && + turnState.commandExecutions.length === 0 && + !turnState.lastAgentMessage + ); +} + function normalizeReasoningText(text) { return String(text ?? "").replace(/\s+/g, " ").trim(); } @@ -420,6 +433,7 @@ function createTurnCaptureState(threadId, options = {}) { activityTimer: null, lastActivityAt: null, activityCount: 0, + itemActivityCount: 0, stallCleanup: null, stalled: false, activeTools: new Map(), @@ -777,6 +791,8 @@ function belongsToTurn(state, message) { } function recordItem(state, item, lifecycle, threadId = null) { + state.itemActivityCount += 1; + if (item.type === "collabAgentToolCall") { if (!threadId || threadId === state.threadId) { if (lifecycle === "started" || item.status === "inProgress") { @@ -1456,43 +1472,88 @@ export async function runAppServerReview(cwd, options = {}) { } return withAppServer(cwd, async (client) => { - emitProgress(options.onProgress, "Starting Codex review thread.", "starting"); - const thread = await startThread(client, cwd, { - model: options.model, - sandbox: "read-only", - ephemeral: true, - threadName: options.threadName - }); - const sourceThreadId = thread.thread.id; - emitProgress(options.onProgress, `Thread ready (${sourceThreadId}).`, "starting", { - threadId: sourceThreadId - }); const delivery = options.delivery ?? "inline"; + const captureModelReview = async (model) => { + emitProgress(options.onProgress, "Starting Codex review thread.", "starting"); + const thread = await startThread(client, cwd, { + model, + sandbox: "read-only", + ephemeral: true, + threadName: options.threadName + }); + const sourceThreadId = thread.thread.id; + emitProgress(options.onProgress, `Thread ready (${sourceThreadId}).`, "starting", { + threadId: sourceThreadId + }); - const turnState = await captureTurn( - client, - sourceThreadId, - () => - client.request("review/start", { - threadId: sourceThreadId, - delivery, - target: options.target - }), - { - onProgress: options.onProgress, - onResponse(response, state) { - if (response.reviewThreadId) { - state.threadIds.add(response.reviewThreadId); - if (delivery === "detached") { - state.threadId = response.reviewThreadId; + const turnState = await captureTurn( + client, + sourceThreadId, + () => + client.request("review/start", { + threadId: sourceThreadId, + delivery, + target: options.target + }), + { + onProgress: options.onProgress, + onResponse(response, state) { + if (response.reviewThreadId) { + state.threadIds.add(response.reviewThreadId); + if (delivery === "detached") { + state.threadId = response.reviewThreadId; + } } } } + ); + return { + resolvedModel: thread.model ?? model ?? null, + sourceThreadId, + turnState + }; + }; + + let reviewAttempt = await captureModelReview(options.model ?? null); + const failedModel = reviewAttempt.resolvedModel; + const initialStatus = buildResultStatus(reviewAttempt.turnState); + const initialFailure = initialStatus === 0 + ? { failureClass: null, retryable: false } + : classifyFailureMessage(extractErrorMessage(reviewAttempt.turnState.error)); + let modelFallback = null; + + if ( + initialStatus !== 0 && + initialFailure.failureClass === CAPACITY && + turnProducedNothing(reviewAttempt.turnState) + ) { + const fallbackModel = await resolveFallbackModel(client, { + failedModel, + workspaceRoot: cwd, + env: process.env + }); + if (fallbackModel && fallbackModel !== failedModel) { + emitProgress( + options.onProgress, + `Model ${failedModel} is at capacity; retrying on ${fallbackModel}.`, + "starting" + ); + reviewAttempt = await captureModelReview(fallbackModel); + modelFallback = { from: failedModel, to: fallbackModel, reason: "capacity" }; } - ); + } + + const { sourceThreadId, turnState } = reviewAttempt; + const status = buildResultStatus(turnState); + const failure = status === 0 + ? { failureClass: null, retryable: false } + : classifyFailureMessage(extractErrorMessage(turnState.error)); return { - status: buildResultStatus(turnState), + status, + failureClass: failure.failureClass, + retryable: failure.retryable && turnProducedNothing(turnState), + modelFallback, threadId: turnState.threadId, sourceThreadId, turnId: turnState.turnId, @@ -1542,6 +1603,79 @@ export async function importExternalAgentSession(cwd, options = {}) { }); } +// `stopWhen` lets a caller that is hunting one entry stop as soon as it appears +// instead of paying for the remaining pages. +async function listAdvertisedModels(client, stopWhen = null) { + try { + const modelListDeadline = Date.now() + 3000; + const seenCursors = new Set(); + const advertisedModels = []; + let cursor = null; + // Bound pages and total time so a malformed server cannot delay the turn indefinitely. + for (let page = 0; page < 10 && Date.now() < modelListDeadline; page += 1) { + const params = { includeHidden: true }; + if (cursor !== null) { + params.cursor = cursor; + } + const remainingMs = modelListDeadline - Date.now(); + if (remainingMs <= 0) { + break; + } + const modelListResponse = await client.request( + "model/list", + params, + { timeoutMs: Math.min(3000, remainingMs) } + ); + const models = modelListResponse?.data; + const nextCursor = modelListResponse?.nextCursor; + if (!Array.isArray(models) || (nextCursor !== null && typeof nextCursor !== "string")) { + throw new Error("Unexpected model/list response."); + } + advertisedModels.push(...models); + if (stopWhen?.(advertisedModels) || nextCursor === null || seenCursors.has(nextCursor)) { + break; + } + seenCursors.add(nextCursor); + cursor = nextCursor; + } + return advertisedModels; + } catch { + // This lookup is advisory; fail open so model discovery cannot make a working run fail. + return []; + } +} + +function advertisedModelName(entry) { + for (const value of [entry?.model, entry?.id]) { + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + return null; +} + +export async function resolveFallbackModel(client, { failedModel, workspaceRoot, env = process.env }) { + const envModel = typeof env?.CODEX_COMPANION_FALLBACK_MODEL === "string" + ? env.CODEX_COMPANION_FALLBACK_MODEL.trim() + : ""; + if (envModel.toLowerCase() === "none") { + return null; + } + if (envModel) { + return envModel; + } + + const configuredModel = getConfig(workspaceRoot).fallbackModel; + if (typeof configuredModel === "string" && configuredModel.trim()) { + return configuredModel.trim(); + } + + const models = await listAdvertisedModels(client); + const eligibleModels = models.filter((entry) => !entry?.hidden && advertisedModelName(entry) !== failedModel); + const fallbackEntry = eligibleModels.find((entry) => entry?.isDefault === true) ?? eligibleModels[0]; + return advertisedModelName(fallbackEntry); +} + /** @param {RunAppServerTurnOptions} [options] */ export async function runAppServerTurn(cwd, options = {}) { const availability = getCodexAvailability(cwd); @@ -1587,53 +1721,21 @@ export async function runAppServerTurn(cwd, options = {}) { let effortWarning = null; // model/list is a conservative advertisement, so an omitted effort is a warning rather than a rejection. if (typeof options.effort === "string" && options.effortOverride !== false) { - try { - const modelListDeadline = Date.now() + 3000; - const seenCursors = new Set(); - let cursor = null; - let modelEntry = null; - // Bound pages and total time so a malformed server cannot delay the turn indefinitely. - for (let page = 0; page < 10 && Date.now() < modelListDeadline; page += 1) { - const params = { includeHidden: true }; - if (cursor !== null) { - params.cursor = cursor; - } - const remainingMs = modelListDeadline - Date.now(); - if (remainingMs <= 0) { - break; - } - const modelListResponse = await client.request( - "model/list", - params, - { timeoutMs: Math.min(3000, remainingMs) } - ); - const models = modelListResponse?.data; - const nextCursor = modelListResponse?.nextCursor; - if (!Array.isArray(models) || (nextCursor !== null && typeof nextCursor !== "string")) { - throw new Error("Unexpected model/list response."); - } - modelEntry = typeof resolvedModel === "string" - ? models.find((entry) => entry?.model === resolvedModel || entry?.id === resolvedModel) - : null; - if (modelEntry || nextCursor === null || seenCursors.has(nextCursor)) { - break; - } - seenCursors.add(nextCursor); - cursor = nextCursor; - } - const advertisedEfforts = modelEntry?.supportedReasoningEfforts; - if ( - modelEntry && - Array.isArray(advertisedEfforts) && - advertisedEfforts.every((entry) => typeof entry?.reasoningEffort === "string") && - !advertisedEfforts.some((entry) => entry.reasoningEffort === options.effort) - ) { - const advertised = advertisedEfforts.map((entry) => entry.reasoningEffort).join(", "); - effortWarning = `${resolvedModel} does not advertise reasoning effort "${options.effort}" (advertised: ${advertised}). Codex may ignore or reject it; the effort actually used is not reported by the protocol.`; - emitProgress(options.onProgress, effortWarning); - } - } catch { - // This lookup is advisory; fail open so model discovery cannot make a working run fail. + const matchesResolvedModel = (entry) => entry?.model === resolvedModel || entry?.id === resolvedModel; + const models = typeof resolvedModel === "string" + ? await listAdvertisedModels(client, (entries) => entries.some(matchesResolvedModel)) + : await listAdvertisedModels(client); + const modelEntry = typeof resolvedModel === "string" ? models.find(matchesResolvedModel) : null; + const advertisedEfforts = modelEntry?.supportedReasoningEfforts; + if ( + modelEntry && + Array.isArray(advertisedEfforts) && + advertisedEfforts.every((entry) => typeof entry?.reasoningEffort === "string") && + !advertisedEfforts.some((entry) => entry.reasoningEffort === options.effort) + ) { + const advertised = advertisedEfforts.map((entry) => entry.reasoningEffort).join(", "); + effortWarning = `${resolvedModel} does not advertise reasoning effort "${options.effort}" (advertised: ${advertised}). Codex may ignore or reject it; the effort actually used is not reported by the protocol.`; + emitProgress(options.onProgress, effortWarning); } } @@ -1643,23 +1745,56 @@ export async function runAppServerTurn(cwd, options = {}) { } const sandboxPolicy = buildTurnSandboxPolicy(options.sandbox, writableRoots, resolvedSandbox); - const turnState = await captureTurn( - client, - threadId, - () => - client.request("turn/start", { + const captureModelTurn = (model) => + captureTurn( + client, + threadId, + () => client.request("turn/start", { threadId, input: buildTurnInput(prompt), - model: options.model ?? null, + model, effort: options.effort ?? null, outputSchema: options.outputSchema ?? null, sandboxPolicy }), - { onProgress: options.onProgress } - ); + { onProgress: options.onProgress } + ); + + const failedModel = resolvedModel ?? options.model ?? null; + let turnState = await captureModelTurn(options.model ?? null); + const initialStatus = buildResultStatus(turnState); + const initialFailure = initialStatus === 0 + ? { failureClass: null, retryable: false } + : classifyFailureMessage(extractErrorMessage(turnState.error)); + let modelFallback = null; + + if (initialStatus !== 0 && initialFailure.failureClass === CAPACITY && turnProducedNothing(turnState)) { + const fallbackModel = await resolveFallbackModel(client, { + failedModel, + workspaceRoot: cwd, + env: process.env + }); + if (fallbackModel && fallbackModel !== failedModel) { + emitProgress( + options.onProgress, + `Model ${failedModel} is at capacity; retrying on ${fallbackModel}.`, + "starting" + ); + turnState = await captureModelTurn(fallbackModel); + modelFallback = { from: failedModel, to: fallbackModel, reason: "capacity" }; + } + } + + const status = buildResultStatus(turnState); + const failure = status === 0 + ? { failureClass: null, retryable: false } + : classifyFailureMessage(extractErrorMessage(turnState.error)); return { - status: buildResultStatus(turnState), + status, + failureClass: failure.failureClass, + retryable: failure.retryable && turnProducedNothing(turnState), + modelFallback, threadId, turnId: turnState.turnId, finalMessage: turnState.lastAgentMessage, diff --git a/plugins/codex/scripts/lib/failure-class.mjs b/plugins/codex/scripts/lib/failure-class.mjs new file mode 100644 index 000000000..d5ff8c3aa --- /dev/null +++ b/plugins/codex/scripts/lib/failure-class.mjs @@ -0,0 +1,18 @@ +export const CAPACITY = "capacity"; +export const STATE_DRIFT = "state-drift"; + +export function classifyFailureMessage(message) { + if (typeof message !== "string") { + return { failureClass: null, retryable: false }; + } + + // Match capacity wording only. "Try a different model" is advice the server + // also appends to unrelated model-compatibility errors, so on its own it says + // nothing about capacity — the real capacity message carries "at capacity" + // alongside it. + if (/\bat capacity\b/i.test(message) || /\bis (currently )?overloaded\b/i.test(message)) { + return { failureClass: CAPACITY, retryable: true }; + } + + return { failureClass: null, retryable: false }; +} diff --git a/plugins/codex/scripts/lib/git.mjs b/plugins/codex/scripts/lib/git.mjs index 33dba4210..bb4e7a291 100644 --- a/plugins/codex/scripts/lib/git.mjs +++ b/plugins/codex/scripts/lib/git.mjs @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; @@ -17,6 +18,144 @@ function gitChecked(cwd, args, options = {}) { return runCommandChecked("git", args, { cwd, ...options, shell: false }); } +function gitNullTerminatedPaths(cwd, args) { + return gitChecked(cwd, [...args, "-z"]).stdout.split("\0").filter(Boolean); +} + +function resolveOid(cwd, ref) { + return gitChecked(cwd, ["rev-parse", ref]).stdout.trim(); +} + +function tryResolveOid(cwd, ref) { + const result = git(cwd, ["rev-parse", ref]); + if (result.error) { + throw result.error; + } + return result.status === 0 ? result.stdout.trim() || null : null; +} + +function hashNestedGitRepository(absolutePath) { + if (!fs.existsSync(path.join(absolutePath, ".git"))) { + return null; + } + + const head = git(absolutePath, ["rev-parse", "HEAD"]); + const status = git(absolutePath, ["status", "--porcelain", "--untracked-files=all"]); + const stagedDiff = git(absolutePath, ["diff", "--cached", "--binary", "--no-ext-diff"]); + const unstagedDiff = git(absolutePath, ["diff", "--binary", "--no-ext-diff"]); + if ( + head.error || head.status !== 0 || + status.error || status.status !== 0 || + stagedDiff.error || stagedDiff.status !== 0 || + unstagedDiff.error || unstagedDiff.status !== 0 + ) { + return null; + } + + const statusDigest = createHash("sha256").update(status.stdout).digest("hex"); + const stagedDiffDigest = createHash("sha256").update(stagedDiff.stdout).digest("hex"); + const unstagedDiffDigest = createHash("sha256").update(unstagedDiff.stdout).digest("hex"); + return `submodule:${head.stdout.trim()}:${statusDigest}:${stagedDiffDigest}:${unstagedDiffDigest}`; +} + +function hashWorkingTreePath(cwd, relativePath) { + const absolutePath = path.join(cwd, relativePath); + let stat; + try { + stat = fs.lstatSync(absolutePath); + } catch { + return "missing"; + } + + if (stat.isSymbolicLink()) { + return `symlink:${createHash("sha256").update(fs.readlinkSync(absolutePath)).digest("hex")}`; + } + if (stat.isDirectory()) { + return hashNestedGitRepository(absolutePath) ?? `other:${stat.mode}:${stat.size}`; + } + if (!stat.isFile()) { + return `other:${stat.mode}:${stat.size}`; + } + + return `file:${gitChecked(cwd, ["hash-object", "--no-filters", "--", relativePath]).stdout.trim()}`; +} + +function inspectUntrackedFile(cwd, relativePath) { + const absolutePath = path.join(cwd, relativePath); + let stat; + try { + stat = fs.statSync(absolutePath); + } catch { + return { skip: "(skipped: broken symlink or unreadable file)" }; + } + + if (stat.isDirectory()) { + return { skip: "(skipped: directory)" }; + } + if (stat.size > MAX_UNTRACKED_BYTES) { + return { skip: `(skipped: ${stat.size} bytes exceeds ${MAX_UNTRACKED_BYTES} byte limit)` }; + } + + let buffer; + try { + buffer = fs.readFileSync(absolutePath); + } catch { + return { skip: "(skipped: broken symlink or unreadable file)" }; + } + if (!isProbablyText(buffer)) { + return { skip: "(skipped: binary file)" }; + } + + return { content: buffer.toString("utf8").trimEnd() }; +} + +function hashUntrackedPath(cwd, relativePath) { + const absolutePath = path.join(cwd, relativePath); + let stat; + try { + stat = fs.lstatSync(absolutePath); + } catch { + return "skipped:(skipped: broken symlink or unreadable file)"; + } + + if (stat.isFile()) { + const result = git(cwd, ["hash-object", "--no-filters", "--", relativePath]); + if (!result.error && result.status === 0) { + return `file:${result.stdout.trim()}`; + } + return "skipped:(skipped: broken symlink or unreadable file)"; + } + + // Preserve the existing handling for symlinks, directories, and other + // non-regular paths. Display limits must not affect regular-file identity. + const inspected = inspectUntrackedFile(cwd, relativePath); + if (inspected.skip) { + return `skipped:${inspected.skip}`; + } + return `file:${createHash("sha256").update(inspected.content).digest("hex")}`; +} + +function captureWorkingTreeDigest(cwd) { + const digest = createHash("sha256"); + const status = gitChecked(cwd, ["status", "--porcelain=v2", "--untracked-files=all", "-z"]).stdout; + digest.update(status); + + // Porcelain v2 records tracked object IDs, but not an OID for unstaged + // working-tree content. Fold dirty tracked paths in so repeated edits cannot + // resolve to the same identity. + const trackedPaths = listUniqueFiles(gitNullTerminatedPaths(cwd, ["diff", "--name-only"])); + for (const relativePath of trackedPaths) { + digest.update(`\0tracked\0${relativePath}\0${hashWorkingTreePath(cwd, relativePath)}`); + } + + const untrackedPaths = gitNullTerminatedPaths(cwd, ["ls-files", "--others", "--exclude-standard"]).sort(); + for (const relativePath of untrackedPaths) { + digest.update(`\0untracked\0${relativePath}\0${hashUntrackedPath(cwd, relativePath)}`); + } + + return digest.digest("hex"); +} + function listUniqueFiles(...groups) { return [...new Set(groups.flat().filter(Boolean))].sort(); } @@ -152,6 +291,45 @@ export function getWorkingTreeState(cwd) { }; } +export function captureRepoStateIdentity(cwd, target) { + const repoRoot = getRepoRoot(cwd); + const identity = { + headOid: resolveOid(repoRoot, "HEAD") + }; + + if (target.mode === "branch") { + identity.baseOid = resolveOid(repoRoot, target.baseRef); + } else if (target.mode === "working-tree") { + identity.worktreeDigest = captureWorkingTreeDigest(repoRoot); + } + + return identity; +} + +export function describeRepoStateDrift(cwd, target, expected) { + const repoRoot = getRepoRoot(cwd); + const headOid = tryResolveOid(repoRoot, "HEAD"); + if (!headOid) { + return "HEAD no longer resolves"; + } + if (headOid !== expected.headOid) { + return "HEAD moved"; + } + if (target.mode === "branch") { + const baseOid = tryResolveOid(repoRoot, target.baseRef); + if (!baseOid) { + return `base ref ${target.baseRef} no longer resolves`; + } + if (baseOid !== expected.baseOid) { + return `base ref ${target.baseRef} moved`; + } + } + if (target.mode === "working-tree" && captureWorkingTreeDigest(repoRoot) !== expected.worktreeDigest) { + return "working tree moved"; + } + return null; +} + export function resolveReviewTarget(cwd, options = {}) { ensureGitRepository(cwd); @@ -215,31 +393,12 @@ function formatSection(title, body) { } function formatUntrackedFile(cwd, relativePath) { - const absolutePath = path.join(cwd, relativePath); - let stat; - try { - stat = fs.statSync(absolutePath); - } catch { - return `### ${relativePath}\n(skipped: broken symlink or unreadable file)`; - } - if (stat.isDirectory()) { - return `### ${relativePath}\n(skipped: directory)`; - } - if (stat.size > MAX_UNTRACKED_BYTES) { - return `### ${relativePath}\n(skipped: ${stat.size} bytes exceeds ${MAX_UNTRACKED_BYTES} byte limit)`; - } - - let buffer; - try { - buffer = fs.readFileSync(absolutePath); - } catch { - return `### ${relativePath}\n(skipped: broken symlink or unreadable file)`; - } - if (!isProbablyText(buffer)) { - return `### ${relativePath}\n(skipped: binary file)`; + const inspected = inspectUntrackedFile(cwd, relativePath); + if (inspected.skip) { + return `### ${relativePath}\n${inspected.skip}`; } - return [`### ${relativePath}`, "```", buffer.toString("utf8").trimEnd(), "```"].join("\n"); + return [`### ${relativePath}`, "```", inspected.content, "```"].join("\n"); } function collectWorkingTreeContext(cwd, state, options = {}) { diff --git a/plugins/codex/scripts/lib/render.mjs b/plugins/codex/scripts/lib/render.mjs index 155d42c1b..7104d4d52 100644 --- a/plugins/codex/scripts/lib/render.mjs +++ b/plugins/codex/scripts/lib/render.mjs @@ -32,6 +32,28 @@ function appendModelAttribution(lines, meta) { return lines.length > before; } +function failureClassificationLine(source) { + if (typeof source?.failureClass !== "string" || !source.failureClass.trim()) { + return null; + } + return `Failure class: ${source.failureClass}${source.retryable ? " (retryable)" : ""}`; +} + +function appendFailureClassification(lines, source, prefix = "") { + const line = failureClassificationLine(source); + if (line) { + lines.push(`${prefix}${line}`); + } +} + +function appendFailureClassificationToOutput(output, source) { + const line = failureClassificationLine(source); + if (!line || output.split(/\r?\n/).includes(line)) { + return output; + } + return `${output.trimEnd()}\n\n${line}\n`; +} + function validateReviewResultShape(data) { if (!data || typeof data !== "object" || Array.isArray(data)) { return "Expected a top-level JSON object."; @@ -143,6 +165,7 @@ function pushJobDetails(lines, job, options = {}) { if (job.status === "failed" && job.errorMessage) { lines.push(` Error: ${job.errorMessage}`); } + appendFailureClassification(lines, job, " "); if (options.showElapsed && job.elapsed) { lines.push(` Elapsed: ${job.elapsed}`); } @@ -201,6 +224,7 @@ export function renderSetupReport(report) { `- auth: ${report.auth.detail}`, `- session runtime: ${report.sessionRuntime.label}`, `- review gate: ${report.reviewGateEnabled ? "enabled" : "disabled"}`, + `- fallback model: ${report.fallbackModel ?? "not configured"}`, "" ]; @@ -231,6 +255,7 @@ export function renderReviewResult(parsedResult, meta) { if (appendModelAttribution(lines, meta)) { lines.push(""); } + appendFailureClassification(lines, parsedResult); lines.push("Codex did not return valid structured JSON.", "", `- Parse error: ${parsedResult.parseError}`); if (parsedResult.rawOutput) { @@ -250,6 +275,7 @@ export function renderReviewResult(parsedResult, meta) { `Target: ${meta.targetLabel}` ]; appendModelAttribution(lines, meta); + appendFailureClassification(lines, parsedResult); lines.push("Codex returned JSON with an unexpected review shape.", "", `- Validation error: ${validationError}`); if (parsedResult.rawOutput) { @@ -269,6 +295,7 @@ export function renderReviewResult(parsedResult, meta) { `Target: ${meta.targetLabel}` ]; appendModelAttribution(lines, meta); + appendFailureClassification(lines, parsedResult); lines.push(`Verdict: ${data.verdict}`, "", data.summary, ""); if (findings.length === 0) { @@ -306,6 +333,7 @@ export function renderNativeReviewResult(result, meta) { `Target: ${meta.targetLabel}` ]; appendModelAttribution(lines, meta); + appendFailureClassification(lines, result); lines.push(""); if (stdout) { @@ -337,6 +365,7 @@ export function renderTaskResult(parsedResult, meta) { : []; const partialOutput = typeof parsedResult?.partialOutput === "string" ? parsedResult.partialOutput : ""; const lines = [message]; + appendFailureClassification(lines, parsedResult); if (touchedFiles.length > 0) { lines.push("", "Files already modified by this run:", "", ...touchedFiles.map((file) => `- ${file}`)); @@ -417,8 +446,14 @@ export function renderJobStatusReport(job) { export function renderStoredJobResult(job, storedJob) { const threadId = storedJob?.threadId ?? job.threadId ?? null; const resumeCommand = threadId ? `codex resume ${threadId}` : null; + const failureSource = storedJob?.failureClass + ? storedJob + : storedJob?.result?.failureClass + ? storedJob.result + : job; if (isStructuredReviewStoredResult(storedJob) && storedJob?.rendered) { - const output = storedJob.rendered.endsWith("\n") ? storedJob.rendered : `${storedJob.rendered}\n`; + const rendered = storedJob.rendered.endsWith("\n") ? storedJob.rendered : `${storedJob.rendered}\n`; + const output = appendFailureClassificationToOutput(rendered, failureSource); if (!threadId) { return output; } @@ -430,7 +465,8 @@ export function renderStoredJobResult(job, storedJob) { (typeof storedJob?.result?.codex?.stdout === "string" && storedJob.result.codex.stdout) || ""; if (rawOutput) { - const output = rawOutput.endsWith("\n") ? rawOutput : `${rawOutput}\n`; + const rendered = rawOutput.endsWith("\n") ? rawOutput : `${rawOutput}\n`; + const output = appendFailureClassificationToOutput(rendered, failureSource); if (!threadId) { return output; } @@ -438,7 +474,8 @@ export function renderStoredJobResult(job, storedJob) { } if (storedJob?.rendered) { - const output = storedJob.rendered.endsWith("\n") ? storedJob.rendered : `${storedJob.rendered}\n`; + const rendered = storedJob.rendered.endsWith("\n") ? storedJob.rendered : `${storedJob.rendered}\n`; + const output = appendFailureClassificationToOutput(rendered, failureSource); if (!threadId) { return output; } @@ -461,6 +498,8 @@ export function renderStoredJobResult(job, storedJob) { lines.push(`Summary: ${job.summary}`); } + appendFailureClassification(lines, failureSource); + if (job.errorMessage) { lines.push("", job.errorMessage); } else if (storedJob?.errorMessage) { diff --git a/plugins/codex/scripts/lib/tracked-jobs.mjs b/plugins/codex/scripts/lib/tracked-jobs.mjs index 5bd4c6f21..8d1520e0c 100644 --- a/plugins/codex/scripts/lib/tracked-jobs.mjs +++ b/plugins/codex/scripts/lib/tracked-jobs.mjs @@ -1,6 +1,7 @@ import fs from "node:fs"; import process from "node:process"; +import { classifyFailureMessage } from "./failure-class.mjs"; import { getProcessStartTime } from "./process.mjs"; import { listJobs, @@ -265,6 +266,8 @@ export async function runTrackedJob(job, runner, options = {}) { try { const execution = await runner(); const completionStatus = execution.exitStatus === 0 ? "completed" : "failed"; + const failureClass = completionStatus === "completed" ? null : execution.failureClass ?? null; + const retryable = completionStatus === "completed" ? false : execution.retryable === true; const completedAt = nowIso(); if (isPersistenceBlocked(job.workspaceRoot, job.id)) { return execution; @@ -282,6 +285,8 @@ export async function runTrackedJob(job, runner, options = {}) { pid: null, phase: completionStatus === "completed" ? "done" : "failed", completedAt, + failureClass, + retryable, result: execution.payload, rendered: execution.rendered }); @@ -293,7 +298,9 @@ export async function runTrackedJob(job, runner, options = {}) { summary: execution.summary, phase: completionStatus === "completed" ? "done" : "failed", pid: null, - completedAt + completedAt, + failureClass, + retryable }); return true; }); @@ -303,6 +310,11 @@ export async function runTrackedJob(job, runner, options = {}) { return execution; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); + const classified = classifyFailureMessage(errorMessage); + const carriesFailureClass = error != null && Object.prototype.hasOwnProperty.call(Object(error), "failureClass"); + const carriesRetryable = error != null && Object.prototype.hasOwnProperty.call(Object(error), "retryable"); + const failureClass = carriesFailureClass ? error.failureClass : classified.failureClass; + const retryable = carriesRetryable ? error.retryable : classified.retryable; const completedAt = nowIso(); withJobPersistenceLock(job.workspaceRoot, job.id, () => { if (isPersistenceBlocked(job.workspaceRoot, job.id)) { @@ -316,6 +328,8 @@ export async function runTrackedJob(job, runner, options = {}) { errorMessage, pid: null, completedAt, + failureClass, + retryable, logFile: options.logFile ?? job.logFile ?? existing.logFile ?? null }); upsertJob(job.workspaceRoot, { @@ -324,7 +338,9 @@ export async function runTrackedJob(job, runner, options = {}) { phase: "failed", pid: null, errorMessage, - completedAt + completedAt, + failureClass, + retryable }); }); throw error; diff --git a/tests/fake-codex-fixture.mjs b/tests/fake-codex-fixture.mjs index 013823bbc..046b6ad93 100644 --- a/tests/fake-codex-fixture.mjs +++ b/tests/fake-codex-fixture.mjs @@ -68,6 +68,10 @@ const MODEL_CATALOG = [ { model: "gpt-5.3-codex-spark", efforts: ["low", "medium", "high", "xhigh"], isDefault: false } ]; +// The model the "model-at-capacity" behavior refuses. Matches the companion's +// own default so a plain run hits it without passing --model. +const CAPACITY_BOUND_MODEL = "gpt-5.5"; + function buildModelListResult() { return { data: MODEL_CATALOG.map((entry) => ({ @@ -272,6 +276,27 @@ function emitTurnCompletedLater(threadId, turnId, item, delayMs) { }, delayMs); } +function emitReviewCompleted(threadId, turnId, item) { + if (BEHAVIOR !== "wait-for-review-release") { + emitTurnCompleted(threadId, turnId, item); + return; + } + + const startedPath = process.env.CODEX_TEST_REVIEW_STARTED; + const releasePath = process.env.CODEX_TEST_REVIEW_RELEASE; + if (!startedPath || !releasePath) { + throw new Error("wait-for-review-release requires review marker paths"); + } + fs.writeFileSync(startedPath, "started\\n"); + const interval = setInterval(() => { + if (!fs.existsSync(releasePath)) { + return; + } + clearInterval(interval); + emitTurnCompleted(threadId, turnId, item); + }, 10); +} + function nativeReviewText(target) { if (target.type === "baseBranch") { return "Reviewed changes against " + target.branch + ".\\nNo material issues found."; @@ -424,6 +449,7 @@ rl.on("line", (line) => { throw new Error("thread/start.persistFullHistory requires experimentalApi capability"); } const thread = nextThread(state, message.params.cwd, message.params.ephemeral); + thread.model = message.params.model || "gpt-5.4"; state.lastThreadStart = { model: message.params.model ?? null, sandbox: message.params.sandbox ?? null, approvalPolicy: message.params.approvalPolicy ?? null, config: message.params.config }; saveState(state); send({ id: message.id, result: { thread: buildThread(thread), model: message.params.model || "gpt-5.4", modelProvider: "openai", serviceTier: null, cwd: thread.cwd, approvalPolicy: "never", sandbox: { type: "readOnly", access: { type: "fullAccess" }, networkAccess: false }, reasoningEffort: null } }); @@ -529,8 +555,29 @@ rl.on("line", (line) => { send({ method: "thread/started", params: { thread: { id: reviewThread.id } } }); } const turnId = nextTurnId(state); + state.reviewStarts = (state.reviewStarts || 0) + 1; + state.lastReviewStart = { threadId: thread.id, turnId, model: thread.model ?? null }; + saveState(state); send({ id: message.id, result: { turn: buildTurn(turnId), reviewThreadId: reviewThread.id } }); - emitTurnCompleted(reviewThread.id, turnId, [ + if ( + BEHAVIOR === "all-models-at-capacity" || + (BEHAVIOR === "model-at-capacity" && thread.model === CAPACITY_BOUND_MODEL) + ) { + state.capacityRejections = (state.capacityRejections || 0) + 1; + saveState(state); + send({ method: "turn/started", params: { threadId: reviewThread.id, turn: buildTurn(turnId) } }); + send({ + method: "error", + params: { + threadId: reviewThread.id, + turnId, + error: { message: "Selected model is at capacity. Please try a different model." } + } + }); + send({ method: "turn/completed", params: { threadId: reviewThread.id, turn: buildTurn(turnId, "failed") } }); + break; + } + emitReviewCompleted(reviewThread.id, turnId, [ { started: { type: "enteredReviewMode", id: turnId, review: "current changes" } }, @@ -561,6 +608,7 @@ rl.on("line", (line) => { .join("\\n"); const turnId = nextTurnId(state); thread.updatedAt = now(); + state.turnStarts = (state.turnStarts || 0) + 1; state.lastTurnStart = { threadId: message.params.threadId, turnId, @@ -583,6 +631,96 @@ rl.on("line", (line) => { } send({ id: message.id, result: { turn: buildTurn(turnId) } }); + // Capacity rejections are transient and content-independent: the server + // refuses one model outright and the same prompt succeeds on another. + // Usually that lands before any item is produced, but the + // "-after-output" and "-after-command-start" variants below emit it + // once work is already under way, which is what the retry guard has to + // recognize as unsafe. + const fallbackCapacityAfterCommand = + BEHAVIOR === "fallback-at-capacity-after-command-start" && + (message.params.model ?? null) !== CAPACITY_BOUND_MODEL; + if ( + BEHAVIOR === "all-models-at-capacity" || + ((BEHAVIOR === "model-at-capacity" || + BEHAVIOR === "model-at-capacity-after-output" || + BEHAVIOR === "model-at-capacity-after-command-start" || + BEHAVIOR === "capacity-then-auth-failure" || + BEHAVIOR === "fallback-at-capacity-after-command-start") && + (message.params.model ?? null) === CAPACITY_BOUND_MODEL) || + fallbackCapacityAfterCommand + ) { + state.capacityRejections = (state.capacityRejections || 0) + 1; + saveState(state); + send({ method: "turn/started", params: { threadId: thread.id, turn: buildTurn(turnId) } }); + if (BEHAVIOR === "model-at-capacity-after-output") { + send({ + method: "item/completed", + params: { + threadId: thread.id, + turnId, + item: { + type: "agentMessage", + id: "msg_preamble_" + turnId, + text: "I already started applying the requested changes." + } + } + }); + send({ + method: "item/completed", + params: { + threadId: thread.id, + turnId, + item: { + type: "fileChange", + id: "change_" + turnId, + status: "completed", + changes: [{ path: path.join(process.cwd(), "README.md"), kind: "update" }] + } + } + }); + } + if (BEHAVIOR === "model-at-capacity-after-command-start" || fallbackCapacityAfterCommand) { + send({ + method: "item/started", + params: { + threadId: thread.id, + turnId, + item: { + type: "commandExecution", + id: "cmd_" + turnId, + command: "apply-side-effect", + status: "inProgress" + } + } + }); + } + send({ + method: "error", + params: { + threadId: thread.id, + turnId, + error: { message: "Selected model is at capacity. Please try a different model." } + } + }); + send({ method: "turn/completed", params: { threadId: thread.id, turn: buildTurn(turnId, "failed") } }); + break; + } + + if (BEHAVIOR === "capacity-then-auth-failure") { + send({ method: "turn/started", params: { threadId: thread.id, turn: buildTurn(turnId) } }); + send({ + method: "error", + params: { + threadId: thread.id, + turnId, + error: { message: "Authentication expired; run codex login." } + } + }); + send({ method: "turn/completed", params: { threadId: thread.id, turn: buildTurn(turnId, "failed") } }); + break; + } + const payload = message.params.outputSchema && message.params.outputSchema.properties && message.params.outputSchema.properties.verdict ? structuredReviewPayload(prompt) : taskPayload(prompt, thread.name && thread.name.startsWith("Codex Companion Task") && prompt.includes("Continue from the current thread state")); @@ -1077,6 +1215,8 @@ rl.on("line", (line) => { } }); interruptibleTurns.set(turnId, { threadId: thread.id, timer: null }); + } else if (BEHAVIOR === "wait-for-review-release") { + emitReviewCompleted(thread.id, turnId, items); } else if (BEHAVIOR === "slow-task") { emitTurnCompletedLater(thread.id, turnId, items, 400); } else { diff --git a/tests/git.test.mjs b/tests/git.test.mjs index acffe215e..0596fd11d 100644 --- a/tests/git.test.mjs +++ b/tests/git.test.mjs @@ -3,7 +3,13 @@ import path from "node:path"; import test from "node:test"; import assert from "node:assert/strict"; -import { collectReviewContext, resolveReviewTarget, resolveWorktreeWritableRoots } from "../plugins/codex/scripts/lib/git.mjs"; +import { + captureRepoStateIdentity, + collectReviewContext, + describeRepoStateDrift, + resolveReviewTarget, + resolveWorktreeWritableRoots +} from "../plugins/codex/scripts/lib/git.mjs"; import { initGitRepo, makeTempDir, run } from "./helpers.mjs"; test("resolveWorktreeWritableRoots returns the common git dir for linked worktrees only", () => { @@ -124,6 +130,198 @@ test("resolveReviewTarget requires an explicit base when no default branch can b ); }); +test("repo state identity reports no drift when a branch target is unchanged", () => { + const cwd = makeTempDir(); + initGitRepo(cwd); + fs.writeFileSync(path.join(cwd, "app.js"), "console.log('v1');\n"); + run("git", ["add", "app.js"], { cwd }); + run("git", ["commit", "-m", "init"], { cwd }); + run("git", ["checkout", "-b", "feature/test"], { cwd }); + + const target = resolveReviewTarget(cwd, { base: "main" }); + const identity = captureRepoStateIdentity(cwd, target); + + assert.match(identity.headOid, /^[0-9a-f]{40}$/); + assert.match(identity.baseOid, /^[0-9a-f]{40}$/); + assert.equal(describeRepoStateDrift(cwd, target, identity), null); +}); + +test("repo state identity detects when a branch target's base ref moves", () => { + const cwd = makeTempDir(); + initGitRepo(cwd); + fs.writeFileSync(path.join(cwd, "app.js"), "console.log('base');\n"); + run("git", ["add", "app.js"], { cwd }); + run("git", ["commit", "-m", "base"], { cwd }); + run("git", ["checkout", "-b", "feature/test"], { cwd }); + fs.writeFileSync(path.join(cwd, "app.js"), "console.log('feature');\n"); + run("git", ["add", "app.js"], { cwd }); + run("git", ["commit", "-m", "feature"], { cwd }); + + const target = resolveReviewTarget(cwd, { base: "main" }); + const identity = captureRepoStateIdentity(cwd, target); + run("git", ["branch", "-f", "main", "HEAD"], { cwd }); + + assert.match(describeRepoStateDrift(cwd, target, identity), /base ref main moved/i); +}); + +test("repo state identity reports when a branch target's base ref disappears", () => { + const cwd = makeTempDir(); + initGitRepo(cwd); + fs.writeFileSync(path.join(cwd, "app.js"), "console.log('base');\n"); + run("git", ["add", "app.js"], { cwd }); + run("git", ["commit", "-m", "base"], { cwd }); + run("git", ["checkout", "-b", "feature/test"], { cwd }); + + const target = resolveReviewTarget(cwd, { base: "main" }); + const identity = captureRepoStateIdentity(cwd, target); + run("git", ["branch", "-D", "main"], { cwd }); + + assert.match(describeRepoStateDrift(cwd, target, identity), /base ref main no longer resolves/i); +}); + +test("repo state identity detects further edits to an already-dirty tracked file", () => { + const cwd = makeTempDir(); + initGitRepo(cwd); + fs.writeFileSync(path.join(cwd, "app.js"), "console.log('v1');\n"); + run("git", ["add", "app.js"], { cwd }); + run("git", ["commit", "-m", "init"], { cwd }); + fs.writeFileSync(path.join(cwd, "app.js"), "console.log('v2');\n"); + + const target = resolveReviewTarget(cwd, { scope: "working-tree" }); + const identity = captureRepoStateIdentity(cwd, target); + fs.writeFileSync(path.join(cwd, "app.js"), "console.log('v3');\n"); + + assert.match(describeRepoStateDrift(cwd, target, identity), /working tree moved/i); +}); + +test("repo state identity detects changes to an untracked file's contents", () => { + const cwd = makeTempDir(); + initGitRepo(cwd); + fs.writeFileSync(path.join(cwd, "app.js"), "console.log('v1');\n"); + run("git", ["add", "app.js"], { cwd }); + run("git", ["commit", "-m", "init"], { cwd }); + fs.writeFileSync(path.join(cwd, "notes.txt"), "first draft\n"); + + const target = resolveReviewTarget(cwd, { scope: "working-tree" }); + const identity = captureRepoStateIdentity(cwd, target); + fs.writeFileSync(path.join(cwd, "notes.txt"), "second draft\n"); + + assert.match(describeRepoStateDrift(cwd, target, identity), /working tree moved/i); +}); + +test("repo state identity detects changes to an untracked binary file", () => { + const cwd = makeTempDir(); + initGitRepo(cwd); + fs.writeFileSync(path.join(cwd, "app.js"), "console.log('v1');\n"); + run("git", ["add", "app.js"], { cwd }); + run("git", ["commit", "-m", "init"], { cwd }); + fs.writeFileSync(path.join(cwd, "artifact.bin"), Buffer.from([0, 1, 2, 3])); + + const target = resolveReviewTarget(cwd, { scope: "working-tree" }); + const identity = captureRepoStateIdentity(cwd, target); + fs.writeFileSync(path.join(cwd, "artifact.bin"), Buffer.from([0, 1, 2, 4])); + + assert.match(describeRepoStateDrift(cwd, target, identity), /working tree moved/i); +}); + +test("repo state identity detects same-size changes to a large untracked file", () => { + const cwd = makeTempDir(); + initGitRepo(cwd); + fs.writeFileSync(path.join(cwd, "app.js"), "console.log('v1');\n"); + run("git", ["add", "app.js"], { cwd }); + run("git", ["commit", "-m", "init"], { cwd }); + fs.writeFileSync(path.join(cwd, "large.txt"), "a".repeat(25 * 1024)); + + const target = resolveReviewTarget(cwd, { scope: "working-tree" }); + const identity = captureRepoStateIdentity(cwd, target); + fs.writeFileSync(path.join(cwd, "large.txt"), `${"a".repeat(25 * 1024 - 1)}b`); + + assert.match(describeRepoStateDrift(cwd, target, identity), /working tree moved/i); +}); + +test("repo state identity detects trailing-newline-only changes to an untracked file", () => { + const cwd = makeTempDir(); + initGitRepo(cwd); + fs.writeFileSync(path.join(cwd, "app.js"), "console.log('v1');\n"); + run("git", ["add", "app.js"], { cwd }); + run("git", ["commit", "-m", "init"], { cwd }); + fs.writeFileSync(path.join(cwd, "notes.txt"), "draft\n"); + + const target = resolveReviewTarget(cwd, { scope: "working-tree" }); + const identity = captureRepoStateIdentity(cwd, target); + fs.writeFileSync(path.join(cwd, "notes.txt"), "draft\n\n"); + + assert.match(describeRepoStateDrift(cwd, target, identity), /working tree moved/i); +}); + +test("repo state identity detects further changes inside a dirty submodule", () => { + const cwd = makeTempDir(); + const nestedRepo = path.join(cwd, "vendor", "dependency"); + initGitRepo(cwd); + fs.mkdirSync(nestedRepo, { recursive: true }); + initGitRepo(nestedRepo); + fs.writeFileSync(path.join(nestedRepo, "version.txt"), "v1\n"); + run("git", ["add", "version.txt"], { cwd: nestedRepo }); + run("git", ["commit", "-m", "dependency v1"], { cwd: nestedRepo }); + run("git", ["add", "vendor/dependency"], { cwd }); + run("git", ["commit", "-m", "track dependency"], { cwd }); + fs.writeFileSync(path.join(nestedRepo, "version.txt"), "v2\n"); + run("git", ["add", "version.txt"], { cwd: nestedRepo }); + run("git", ["commit", "-m", "dependency v2"], { cwd: nestedRepo }); + + const target = resolveReviewTarget(cwd, { scope: "working-tree" }); + const identity = captureRepoStateIdentity(cwd, target); + fs.writeFileSync(path.join(nestedRepo, "version.txt"), "v3\n"); + + assert.match(describeRepoStateDrift(cwd, target, identity), /working tree moved/i); +}); + +test("repo state identity hashes staged and unstaged diff content inside a dirty submodule", () => { + const cwd = makeTempDir(); + const nestedRepo = path.join(cwd, "vendor", "dependency"); + initGitRepo(cwd); + fs.mkdirSync(nestedRepo, { recursive: true }); + initGitRepo(nestedRepo); + fs.writeFileSync(path.join(nestedRepo, "version.txt"), "v1\n"); + run("git", ["add", "version.txt"], { cwd: nestedRepo }); + run("git", ["commit", "-m", "dependency v1"], { cwd: nestedRepo }); + run("git", ["add", "vendor/dependency"], { cwd }); + run("git", ["commit", "-m", "track dependency"], { cwd }); + + fs.writeFileSync(path.join(nestedRepo, "version.txt"), "v2 unstaged\n"); + const target = resolveReviewTarget(cwd, { scope: "working-tree" }); + const unstagedIdentity = captureRepoStateIdentity(cwd, target); + fs.writeFileSync(path.join(nestedRepo, "version.txt"), "v3 unstaged\n"); + assert.match(describeRepoStateDrift(cwd, target, unstagedIdentity), /working tree moved/i); + + run("git", ["add", "version.txt"], { cwd: nestedRepo }); + run("git", ["commit", "-m", "dependency v3"], { cwd: nestedRepo }); + fs.writeFileSync(path.join(nestedRepo, "version.txt"), "v4 staged\n"); + run("git", ["add", "version.txt"], { cwd: nestedRepo }); + const stagedIdentity = captureRepoStateIdentity(cwd, target); + fs.writeFileSync(path.join(nestedRepo, "version.txt"), "v5 staged\n"); + run("git", ["add", "version.txt"], { cwd: nestedRepo }); + assert.match(describeRepoStateDrift(cwd, target, stagedIdentity), /working tree moved/i); +}); + +test("repo state identity detects changes through an untracked symlink", () => { + const cwd = makeTempDir(); + const outside = makeTempDir(); + const destination = path.join(outside, "notes.txt"); + initGitRepo(cwd); + fs.writeFileSync(path.join(cwd, "app.js"), "console.log('v1');\n"); + run("git", ["add", "app.js"], { cwd }); + run("git", ["commit", "-m", "init"], { cwd }); + fs.writeFileSync(destination, "first draft\n"); + fs.symlinkSync(destination, path.join(cwd, "linked-notes.txt")); + + const target = resolveReviewTarget(cwd, { scope: "working-tree" }); + const identity = captureRepoStateIdentity(cwd, target); + fs.writeFileSync(destination, "second draft\n"); + + assert.match(describeRepoStateDrift(cwd, target, identity), /working tree moved/i); +}); + test("collectReviewContext keeps inline diffs for tiny adversarial reviews", () => { const cwd = makeTempDir(); initGitRepo(cwd); diff --git a/tests/job-control.test.mjs b/tests/job-control.test.mjs index 8384d0b75..a30b37a61 100644 --- a/tests/job-control.test.mjs +++ b/tests/job-control.test.mjs @@ -278,6 +278,52 @@ test("runTrackedJob persists the worker start time in its running record", async assert.equal(storedJob.pidStartTime, "worker-start"); }); +test("runTrackedJob catch persistence prefers explicit failure metadata and otherwise classifies the message", async () => { + const workspace = makeTempDir(); + + async function runFailure(jobId, error) { + await assert.rejects( + runTrackedJob( + { + id: jobId, + workspaceRoot: workspace, + title: "Codex Task", + status: "queued" + }, + async () => { + throw error; + }, + { getProcessStartTime: () => null } + ), + new RegExp(error.message.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + ); + + return { + stored: JSON.parse(fs.readFileSync(resolveJobFile(workspace, jobId), "utf8")), + indexed: listJobs(workspace).find((job) => job.id === jobId) + }; + } + + const explicitError = Object.assign(new Error("The selected model is at capacity."), { + failureClass: "state-drift", + retryable: false + }); + const explicit = await runFailure("task-explicit-failure", explicitError); + assert.equal(explicit.stored.failureClass, "state-drift"); + assert.equal(explicit.stored.retryable, false); + assert.equal(explicit.indexed.failureClass, "state-drift"); + assert.equal(explicit.indexed.retryable, false); + + const classified = await runFailure( + "task-classified-failure", + new Error("The selected model is currently overloaded.") + ); + assert.equal(classified.stored.failureClass, "capacity"); + assert.equal(classified.stored.retryable, true); + assert.equal(classified.indexed.failureClass, "capacity"); + assert.equal(classified.indexed.retryable, true); +}); + test("runTrackedJob cannot overwrite cancellation that lands before result persistence", async () => { const workspace = makeTempDir(); const jobId = "task-cancel-race"; diff --git a/tests/render.test.mjs b/tests/render.test.mjs index 9bebf941f..143207942 100644 --- a/tests/render.test.mjs +++ b/tests/render.test.mjs @@ -191,6 +191,65 @@ test("renderStoredJobResult prefers rendered output for structured review jobs", assert.match(output, /Resume in Codex: codex resume thr_123/); }); +test("renderStoredJobResult shows one failure classification line only for failed jobs", () => { + const failedOutput = renderStoredJobResult( + { + id: "review-failed", + status: "failed", + title: "Codex Review", + failureClass: "capacity", + retryable: true + }, + { + status: "failed", + failureClass: "capacity", + retryable: true, + result: { + codex: { stdout: "Partial native review output." } + }, + rendered: "Codex review failed.\nFailure class: capacity (retryable)\n" + } + ); + + assert.match(failedOutput, /Partial native review output\./); + assert.equal(failedOutput.match(/^Failure class: capacity \(retryable\)$/gm)?.length, 1); + + const alreadyRenderedOutput = renderStoredJobResult( + { + id: "review-rendered-failure", + status: "failed", + title: "Codex Review", + failureClass: "capacity", + retryable: true + }, + { + status: "failed", + failureClass: "capacity", + retryable: true, + rendered: "Codex review failed.\nFailure class: capacity (retryable)\n", + result: { result: null, parseError: "Review failed." } + } + ); + + assert.equal(alreadyRenderedOutput.match(/^Failure class: capacity \(retryable\)$/gm)?.length, 1); + + const completedOutput = renderStoredJobResult( + { + id: "task-completed", + status: "completed", + title: "Codex Task" + }, + { + status: "completed", + failureClass: null, + retryable: false, + result: { rawOutput: "Task completed." } + } + ); + + assert.doesNotMatch(completedOutput, /^Failure class:/m); +}); + test("renderTaskResult leads with the failure reason instead of the partial message (#88)", () => { const output = renderTaskResult( { diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index b94d77c8c..3e088a04f 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -18,7 +18,9 @@ import { } from "../plugins/codex/scripts/lib/broker-lifecycle.mjs"; import { parseBrokerEndpoint } from "../plugins/codex/scripts/lib/broker-endpoint.mjs"; import { CodexAppServerClient } from "../plugins/codex/scripts/lib/app-server.mjs"; -import { runAppServerTurn } from "../plugins/codex/scripts/lib/codex.mjs"; +import { resolveFallbackModel, runAppServerTurn } from "../plugins/codex/scripts/lib/codex.mjs"; +import { classifyFailureMessage } from "../plugins/codex/scripts/lib/failure-class.mjs"; +import { captureRepoStateIdentity } from "../plugins/codex/scripts/lib/git.mjs"; import { getProcessStartTime } from "../plugins/codex/scripts/lib/process.mjs"; import { splitRawArgumentString } from "../plugins/codex/scripts/lib/args.mjs"; import { @@ -27,9 +29,11 @@ import { } from "../plugins/codex/scripts/lib/claude-session-transfer.mjs"; import { ensureStateDir, + getConfig, resolveJobFile, resolveJobLogFile, resolveStateDir, + setConfig, upsertJob, writeJobFile } from "../plugins/codex/scripts/lib/state.mjs"; @@ -132,6 +136,67 @@ function waitForChildExit(child, timeoutMs) { }); } +function startStoredReviewJob(repo, binDir, jobId, request, env = buildEnv(binDir)) { + const logFile = resolveJobLogFile(repo, jobId); + ensureStateDir(repo); + fs.writeFileSync(logFile, "", "utf8"); + const child = spawn( + process.execPath, + [SCRIPT, "job-worker", "--cwd", repo, "--job-id", jobId], + { cwd: repo, env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true } + ); + const queuedJob = { + id: jobId, + kind: "adversarial-review", + kindLabel: "adversarial-review", + title: "Codex Adversarial Review", + workspaceRoot: repo, + jobClass: "review", + summary: `Adversarial Review ${request.target.label}`, + write: false, + createdAt: new Date().toISOString(), + status: "queued", + phase: "queued", + pid: child.pid, + pidStartTime: null, + logFile, + request + }; + writeJobFile(repo, jobId, queuedJob); + upsertJob(repo, queuedJob); + + return waitForChildExit(child, 15000); +} + +async function runStoredReviewJob(repo, binDir, jobId, request) { + const processResult = await startStoredReviewJob(repo, binDir, jobId, request); + const storedJob = JSON.parse(fs.readFileSync(resolveJobFile(repo, jobId), "utf8")); + return { processResult, storedJob }; +} + +async function runStoredReviewJobWithMidRunChange(repo, binDir, jobId, request, change) { + const markerDir = makeTempDir(); + const startedPath = path.join(markerDir, "review-started"); + const releasePath = path.join(markerDir, "review-release"); + const env = { + ...buildEnv(binDir), + CODEX_TEST_REVIEW_STARTED: startedPath, + CODEX_TEST_REVIEW_RELEASE: releasePath + }; + const processResultPromise = startStoredReviewJob(repo, binDir, jobId, request, env); + + try { + await waitFor(() => fs.existsSync(startedPath), { timeoutMs: 5000, intervalMs: 10 }); + change(); + } finally { + fs.writeFileSync(releasePath, "release\n"); + } + + const processResult = await processResultPromise; + const storedJob = JSON.parse(fs.readFileSync(resolveJobFile(repo, jobId), "utf8")); + return { processResult, storedJob }; +} + after(async () => { for (const dir of trackedTempDirs()) { let session = null; @@ -300,6 +365,51 @@ test("setup reports ready when fake codex is installed and authenticated", () => assert.equal(payload.sessionRuntime.mode, "direct"); }); +test("setup configures, reports, and clears the fallback model", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + + const configured = run("node", [SCRIPT, "setup", "--fallback-model", " configured-backup ", "--json"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(configured.status, 0, configured.stderr); + const configuredPayload = JSON.parse(configured.stdout); + assert.equal(configuredPayload.fallbackModel, "configured-backup"); + assert.match(configuredPayload.actionsTaken.join("\n"), /Configured fallback model configured-backup/); + assert.equal(getConfig(repo).fallbackModel, "configured-backup"); + + const blank = run("node", [SCRIPT, "setup", "--fallback-model", " ", "--json"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.notEqual(blank.status, 0); + assert.match(blank.stderr, /--fallback-model requires a non-empty model name/); + assert.equal(getConfig(repo).fallbackModel, "configured-backup"); + + const conflicting = run( + "node", + [SCRIPT, "setup", "--fallback-model", "another-backup", "--clear-fallback-model", "--json"], + { cwd: repo, env: buildEnv(binDir) } + ); + assert.notEqual(conflicting.status, 0); + assert.match(conflicting.stderr, /Choose either --fallback-model or --clear-fallback-model/); + + const cleared = run("node", [SCRIPT, "setup", "--clear-fallback-model", "--json"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(cleared.status, 0, cleared.stderr); + const clearedPayload = JSON.parse(cleared.stdout); + assert.equal(clearedPayload.fallbackModel, null); + assert.match(clearedPayload.actionsTaken.join("\n"), /Cleared the configured fallback model/); + assert.equal(getConfig(repo).fallbackModel, null); +}); + test("setup is ready without npm when Codex is already installed and authenticated", () => { const binDir = makeTempDir(); installFakeCodex(binDir); @@ -487,6 +597,57 @@ test("native review reports the default model without effort attribution", () => assert.doesNotMatch(result.stdout, /^Effort:/m); }); +test("native review falls back after an empty capacity rejection and attributes the effective model", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "model-at-capacity"); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + fs.writeFileSync(path.join(repo, "README.md"), "hello again\n"); + const env = { ...buildEnv(binDir), CODEX_COMPANION_FALLBACK_MODEL: "gpt-5.6-terra" }; + + const jsonResult = run("node", [SCRIPT, "review", "--json"], { cwd: repo, env }); + + assert.equal(jsonResult.status, 0, jsonResult.stderr); + const payload = JSON.parse(jsonResult.stdout); + assert.equal(payload.model, "gpt-5.6-terra"); + assert.deepEqual(payload.modelFallback, { + from: "gpt-5.5", + to: "gpt-5.6-terra", + reason: "capacity" + }); + const state = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); + assert.equal(state.reviewStarts, 2); + assert.equal(state.lastReviewStart.model, "gpt-5.6-terra"); + + const renderedResult = run("node", [SCRIPT, "review"], { cwd: repo, env }); + assert.equal(renderedResult.status, 0, renderedResult.stderr); + assert.match(renderedResult.stdout, /^Model: gpt-5\.6-terra$/m); + assert.doesNotMatch(renderedResult.stdout, /^Model: gpt-5\.5$/m); +}); + +test("structured review attributes a successful fallback to the effective model", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "model-at-capacity"); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + fs.writeFileSync(path.join(repo, "README.md"), "hello again\n"); + + const result = run("node", [SCRIPT, "adversarial-review"], { + cwd: repo, + env: { ...buildEnv(binDir), CODEX_COMPANION_FALLBACK_MODEL: "gpt-5.6-terra" } + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /^Model: gpt-5\.6-terra$/m); + assert.doesNotMatch(result.stdout, /^Model: gpt-5\.5$/m); +}); + test("deep-review JSON reports the resolved model and effort", () => { const { repo, binDir } = setupDeepReviewRepo(); @@ -918,6 +1079,299 @@ test("task reports the actual Codex auth error when the run is rejected", () => assert.match(result.stderr, /authentication expired; run codex login/); }); +test("classifyFailureMessage recognizes capacity failures conservatively", () => { + const capacityMessages = [ + "The selected model is at capacity.", + "Selected model is at capacity. Please try a different model.", + "The selected model is overloaded.", + "The selected model is currently overloaded." + ]; + for (const message of capacityMessages) { + assert.deepEqual(classifyFailureMessage(message), { + failureClass: "capacity", + retryable: true + }, message); + } + + const ordinaryMessages = [ + "Authentication expired.", + "Capacity planning is unavailable.", + "The request overloaded the worker.", + "Try another model.", + // "Try a different model" is advice the server also appends to errors that + // have nothing to do with capacity, so it must not classify on its own. + "This model does not support image input. Please try a different model.", + "Try a different model and retry the request." + ]; + for (const message of ordinaryMessages) { + assert.deepEqual(classifyFailureMessage(message), { + failureClass: null, + retryable: false + }, message); + } + + for (const value of [null, undefined, 42, { message: "The selected model is at capacity." }]) { + assert.deepEqual(classifyFailureMessage(value), { + failureClass: null, + retryable: false + }); + } +}); + +test("fallback model resolution honors env, config, discovery, and none precedence", async () => { + const repo = makeTempDir(); + initGitRepo(repo); + setConfig(repo, "fallbackModel", "configured-backup"); + + let modelListCalls = 0; + const client = { + async request(method) { + assert.equal(method, "model/list"); + modelListCalls += 1; + return { + data: [ + { model: "hidden-default", hidden: true, isDefault: true }, + { model: "discovered-first", hidden: false, isDefault: false }, + { model: "discovered-default", hidden: false, isDefault: true } + ], + nextCursor: null + }; + } + }; + + assert.equal( + await resolveFallbackModel(client, { + failedModel: "failed-model", + workspaceRoot: repo, + env: { CODEX_COMPANION_FALLBACK_MODEL: "env-backup" } + }), + "env-backup" + ); + assert.equal(modelListCalls, 0); + + assert.equal( + await resolveFallbackModel(client, { failedModel: "failed-model", workspaceRoot: repo, env: {} }), + "configured-backup" + ); + assert.equal(modelListCalls, 0); + + assert.equal( + await resolveFallbackModel(client, { + failedModel: "failed-model", + workspaceRoot: repo, + env: { CODEX_COMPANION_FALLBACK_MODEL: "NoNe" } + }), + null + ); + assert.equal(modelListCalls, 0); + + setConfig(repo, "fallbackModel", null); + assert.equal( + await resolveFallbackModel(client, { failedModel: "failed-model", workspaceRoot: repo, env: {} }), + "discovered-default" + ); + assert.equal(modelListCalls, 1); + + assert.equal( + await resolveFallbackModel(client, { failedModel: "discovered-default", workspaceRoot: repo, env: {} }), + "discovered-first" + ); + assert.equal(modelListCalls, 2); +}); + +test("a capacity rejection retries on the designated backup model", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "model-at-capacity"); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const result = run("node", [SCRIPT, "task", "summarize the repo", "--json"], { + cwd: repo, + env: { ...buildEnv(binDir), CODEX_COMPANION_FALLBACK_MODEL: "gpt-5.6-terra" } + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.modelFallback.from, "gpt-5.5"); + assert.equal(payload.modelFallback.to, "gpt-5.6-terra"); + assert.equal(payload.modelFallback.reason, "capacity"); + + const state = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); + assert.equal(state.capacityRejections, 1); + assert.equal(state.lastTurnStart.model, "gpt-5.6-terra"); +}); + +test("a capacity rejection with no designated backup model reports a retryable failure class", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "model-at-capacity"); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + // No backup model designated and model discovery disabled, so the run has + // nowhere to fall back to and must say why in a machine-readable way. + const result = run("node", [SCRIPT, "task", "summarize the repo", "--json"], { + cwd: repo, + env: { ...buildEnv(binDir), CODEX_COMPANION_FALLBACK_MODEL: "none" } + }); + + assert.notEqual(result.status, 0); + const payload = JSON.parse(result.stdout); + assert.equal(payload.failureClass, "capacity"); + assert.equal(payload.retryable, true); +}); + +test("a failing capacity fallback runs only once and remains retryable", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "all-models-at-capacity"); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const result = run("node", [SCRIPT, "task", "summarize the repo", "--json"], { + cwd: repo, + env: { ...buildEnv(binDir), CODEX_COMPANION_FALLBACK_MODEL: "gpt-5.6-terra" } + }); + + assert.notEqual(result.status, 0); + const payload = JSON.parse(result.stdout); + assert.equal(payload.failureClass, "capacity"); + assert.equal(payload.retryable, true); + assert.deepEqual(payload.modelFallback, { + from: "gpt-5.5", + to: "gpt-5.6-terra", + reason: "capacity" + }); + + const state = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); + assert.equal(state.capacityRejections, 2); + assert.equal(state.lastTurnStart.model, "gpt-5.6-terra"); + + const companionState = JSON.parse( + fs.readFileSync(path.join(resolveStateDir(repo), "state.json"), "utf8") + ); + const jobLog = fs.readFileSync(companionState.jobs[0].logFile, "utf8"); + assert.match(jobLog, /Model gpt-5\.5 is at capacity; retrying on gpt-5\.6-terra\./); +}); + +test("a capacity fallback failure reports the fallback turn's non-capacity error", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "capacity-then-auth-failure"); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const result = run("node", [SCRIPT, "task", "summarize the repo", "--json"], { + cwd: repo, + env: { ...buildEnv(binDir), CODEX_COMPANION_FALLBACK_MODEL: "gpt-5.6-terra" } + }); + + assert.notEqual(result.status, 0); + const payload = JSON.parse(result.stdout); + assert.equal(payload.failureClass, null); + assert.equal(payload.retryable, false); + assert.match(payload.failureMessage, /Authentication expired; run codex login/); + assert.deepEqual(payload.modelFallback, { + from: "gpt-5.5", + to: "gpt-5.6-terra", + reason: "capacity" + }); + + const state = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); + assert.equal(state.capacityRejections, 1); + assert.equal(state.turnStarts, 2); +}); + +test("a capacity failure after a command starts does not retry", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "model-at-capacity-after-command-start"); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const result = run("node", [SCRIPT, "task", "--write", "apply the change", "--json"], { + cwd: repo, + env: { ...buildEnv(binDir), CODEX_COMPANION_FALLBACK_MODEL: "gpt-5.6-terra" } + }); + + assert.notEqual(result.status, 0); + const payload = JSON.parse(result.stdout); + assert.equal(payload.failureClass, "capacity"); + assert.equal(payload.retryable, false); + assert.equal(payload.modelFallback, null); + + const state = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); + assert.equal(state.capacityRejections, 1); + assert.equal(state.turnStarts, 1); + assert.equal(state.lastTurnStart.model, "gpt-5.5"); +}); + +test("a fallback capacity failure after a command starts is not retryable", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "fallback-at-capacity-after-command-start"); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const result = run("node", [SCRIPT, "task", "--write", "apply the change", "--json"], { + cwd: repo, + env: { ...buildEnv(binDir), CODEX_COMPANION_FALLBACK_MODEL: "gpt-5.6-terra" } + }); + + assert.notEqual(result.status, 0); + const payload = JSON.parse(result.stdout); + assert.equal(payload.failureClass, "capacity"); + assert.equal(payload.retryable, false); + assert.deepEqual(payload.modelFallback, { + from: "gpt-5.5", + to: "gpt-5.6-terra", + reason: "capacity" + }); + + const state = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); + assert.equal(state.capacityRejections, 2); + assert.equal(state.turnStarts, 2); + assert.equal(state.lastTurnStart.model, "gpt-5.6-terra"); +}); + +test("a capacity failure after producing output does not retry", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "model-at-capacity-after-output"); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const result = run("node", [SCRIPT, "task", "summarize the repo", "--json"], { + cwd: repo, + env: { ...buildEnv(binDir), CODEX_COMPANION_FALLBACK_MODEL: "gpt-5.6-terra" } + }); + + assert.notEqual(result.status, 0); + const payload = JSON.parse(result.stdout); + assert.equal(payload.failureClass, "capacity"); + assert.equal(payload.retryable, false); + assert.equal(payload.modelFallback, null); + + const state = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); + assert.equal(state.capacityRejections, 1); + assert.equal(state.lastTurnStart.model, "gpt-5.5"); +}); + test("review accepts the quoted raw argument style for built-in base-branch review", () => { const repo = makeTempDir(); const binDir = makeTempDir(); @@ -3575,6 +4029,340 @@ test("a background review reviews the target it was validated and named for", as assert.doesNotMatch(state.lastTurnStart.prompt, /export const value = 3;/); }); +test("a background review refuses to review a repository that moved under its pinned target", async (t) => { + if (process.platform === "win32") { + t.skip("uses the POSIX process-start-time probe to hold the worker"); + return; + } + + const repo = makeTempDir(); + const binDir = makeTempDir(); + const probeDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "src.js"), "export const value = 1;\n"); + run("git", ["add", "src.js"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + run("git", ["checkout", "-b", "feature"], { cwd: repo }); + // Dirty tree, so `--scope auto` resolves and pins the working tree. + fs.writeFileSync(path.join(repo, "src.js"), "export const value = 2;\n"); + + const parentProbeLock = path.join(probeDir, "parent-lock"); + const parentProbeStarted = path.join(probeDir, "parent-started"); + const parentProbeRelease = path.join(probeDir, "parent-release"); + const lockOwnerProbeLock = path.join(probeDir, "lock-owner-lock"); + const workerProbeLock = path.join(probeDir, "worker-lock"); + const workerProbeStarted = path.join(probeDir, "worker-started"); + const workerProbeRelease = path.join(probeDir, "worker-release"); + writeExecutable( + path.join(probeDir, "ps"), + [ + "#!/bin/sh", + 'if mkdir "$CODEX_TEST_PARENT_PROBE_LOCK" 2>/dev/null; then', + ' printf \'started\\n\' > "$CODEX_TEST_PARENT_PROBE_STARTED"', + ' while [ ! -e "$CODEX_TEST_PARENT_PROBE_RELEASE" ]; do sleep 0.01; done', + 'elif mkdir "$CODEX_TEST_LOCK_OWNER_PROBE_LOCK" 2>/dev/null; then', + " :", + 'elif mkdir "$CODEX_TEST_WORKER_PROBE_LOCK" 2>/dev/null; then', + ' printf \'started\\n\' > "$CODEX_TEST_WORKER_PROBE_STARTED"', + ' while [ ! -e "$CODEX_TEST_WORKER_PROBE_RELEASE" ]; do sleep 0.01; done', + "fi", + "printf 'Mon Jul 27 12:34:56 2026\\n'" + ].join("\n") + "\n" + ); + fs.writeFileSync(parentProbeRelease, ""); + + const baseEnv = buildEnv(binDir); + const env = { + ...baseEnv, + PATH: `${probeDir}:${baseEnv.PATH}`, + CODEX_TEST_PARENT_PROBE_LOCK: parentProbeLock, + CODEX_TEST_PARENT_PROBE_STARTED: parentProbeStarted, + CODEX_TEST_PARENT_PROBE_RELEASE: parentProbeRelease, + CODEX_TEST_LOCK_OWNER_PROBE_LOCK: lockOwnerProbeLock, + CODEX_TEST_WORKER_PROBE_LOCK: workerProbeLock, + CODEX_TEST_WORKER_PROBE_STARTED: workerProbeStarted, + CODEX_TEST_WORKER_PROBE_RELEASE: workerProbeRelease + }; + t.after(() => { + if (!fs.existsSync(workerProbeRelease)) { + fs.writeFileSync(workerProbeRelease, ""); + } + }); + + const launched = run("node", [SCRIPT, "adversarial-review", "--background", "--json"], { + cwd: repo, + env + }); + + assert.equal(launched.status, 0, launched.stderr); + const launchPayload = JSON.parse(launched.stdout); + assert.equal(launchPayload.summary, "Adversarial Review working tree diff"); + + // Commit while the worker is held. The pinned working-tree target is still + // honored, but the content it names has moved into the branch diff. + await waitFor(() => fs.existsSync(workerProbeStarted), { timeoutMs: 10000, intervalMs: 10 }); + run("git", ["add", "src.js"], { cwd: repo }); + run("git", ["commit", "-m", "feature work"], { cwd: repo }); + fs.writeFileSync(workerProbeRelease, ""); + + const waitedStatus = run( + "node", + [SCRIPT, "status", launchPayload.jobId, "--wait", "--timeout-ms", "15000", "--json"], + { cwd: repo, env } + ); + + assert.equal(waitedStatus.status, 0, waitedStatus.stderr); + + // The whole point: Codex must never have been asked to review the empty + // post-commit working tree. Without this the review reports a clean result + // for a change it never saw. + const stateFile = path.join(binDir, "fake-codex-state.json"); + const codexState = fs.existsSync(stateFile) ? JSON.parse(fs.readFileSync(stateFile, "utf8")) : {}; + assert.equal(codexState.lastTurnStart ?? null, null, codexState.lastTurnStart?.prompt); + + const job = JSON.parse(waitedStatus.stdout).job; + assert.equal(job.status, "failed", waitedStatus.stdout); + assert.match(job.errorMessage, /moved between enqueue and execution/); + + // A controller must be able to tell a moved-repository failure from a real + // review failure without reading the message. + assert.equal(job.failureClass, "state-drift"); + assert.equal(job.retryable, true); +}); + +test("a native review discards results when the working tree changes during the run", async () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "wait-for-review-release"); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "src.js"), "export const value = 1;\n"); + run("git", ["add", "src.js"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + fs.writeFileSync(path.join(repo, "src.js"), "export const value = 2;\n"); + + const target = { + mode: "working-tree", + label: "working tree diff", + explicit: false + }; + const { processResult, storedJob } = await runStoredReviewJobWithMidRunChange( + repo, + binDir, + `review-native-drift-${Date.now().toString(36)}`, + { + cwd: repo, + target, + stateIdentity: captureRepoStateIdentity(repo, target), + model: "gpt-5.5", + effort: null, + effortOverride: false, + focusText: "", + reviewName: "Review" + }, + () => fs.writeFileSync(path.join(repo, "src.js"), "export const value = 3;\n") + ); + + assert.equal(processResult.code, 1); + assert.equal(storedJob.status, "failed"); + assert.match(storedJob.errorMessage, /review completed.*state that has since moved/i); + assert.match(storedJob.errorMessage, /discarding the result/i); + assert.equal(storedJob.failureClass, "state-drift"); + assert.equal(storedJob.retryable, true); +}); + +test("a self-collect review discards results when the working tree changes during the run", async () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "wait-for-review-release"); + initGitRepo(repo); + for (const name of ["a.js", "b.js", "c.js"]) { + fs.writeFileSync(path.join(repo, name), `export const value = "${name}-v1";\n`); + } + run("git", ["add", "a.js", "b.js", "c.js"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + for (const name of ["a.js", "b.js", "c.js"]) { + fs.writeFileSync(path.join(repo, name), `export const value = "${name}-v2";\n`); + } + + const target = { + mode: "working-tree", + label: "working tree diff", + explicit: false + }; + const { processResult, storedJob } = await runStoredReviewJobWithMidRunChange( + repo, + binDir, + `review-self-collect-drift-${Date.now().toString(36)}`, + { + cwd: repo, + target, + stateIdentity: captureRepoStateIdentity(repo, target), + model: "gpt-5.5", + effort: null, + effortOverride: false, + focusText: "", + reviewName: "Adversarial Review" + }, + () => fs.writeFileSync(path.join(repo, "a.js"), 'export const value = "a.js-v3";\n') + ); + + assert.equal(processResult.code, 1); + assert.equal(storedJob.status, "failed"); + assert.match(storedJob.errorMessage, /review completed.*state that has since moved/i); + assert.match(storedJob.errorMessage, /discarding the result/i); + assert.equal(storedJob.failureClass, "state-drift"); + assert.equal(storedJob.retryable, true); + const codexState = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); + assert.match(codexState.lastTurnStart.prompt, /lightweight summary/i); +}); + +test("an inline-diff review returns its findings when the working tree changes during the run", async () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "wait-for-review-release"); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "src.js"), "export const value = 1;\n"); + run("git", ["add", "src.js"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + fs.writeFileSync(path.join(repo, "src.js"), "export const value = 2;\n"); + + const target = { + mode: "working-tree", + label: "working tree diff", + explicit: false + }; + const { processResult, storedJob } = await runStoredReviewJobWithMidRunChange( + repo, + binDir, + `review-inline-diff-${Date.now().toString(36)}`, + { + cwd: repo, + target, + stateIdentity: captureRepoStateIdentity(repo, target), + model: "gpt-5.5", + effort: null, + effortOverride: false, + focusText: "", + reviewName: "Adversarial Review" + }, + () => fs.writeFileSync(path.join(repo, "src.js"), "export const value = 3;\n") + ); + + assert.equal(processResult.code, 0, processResult.stderr); + assert.equal(storedJob.status, "completed"); + assert.match(storedJob.rendered, /Missing empty-state guard/); + const codexState = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); + assert.match(codexState.lastTurnStart.prompt, /primary evidence/i); + assert.match(codexState.lastTurnStart.prompt, /export const value = 2;/); + assert.doesNotMatch(codexState.lastTurnStart.prompt, /export const value = 3;/); +}); + +test("a background branch review reports a deleted base ref as state drift", async () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "src.js"), "export const value = 1;\n"); + run("git", ["add", "src.js"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + run("git", ["checkout", "-b", "feature"], { cwd: repo }); + fs.writeFileSync(path.join(repo, "src.js"), "export const value = 2;\n"); + run("git", ["add", "src.js"], { cwd: repo }); + run("git", ["commit", "-m", "feature work"], { cwd: repo }); + const target = { + mode: "branch", + label: "branch diff against main", + baseRef: "main", + explicit: false + }; + const stateIdentity = captureRepoStateIdentity(repo, target); + run("git", ["branch", "-D", "main"], { cwd: repo }); + + const { processResult, storedJob } = await runStoredReviewJob( + repo, + binDir, + `review-deleted-base-${Date.now().toString(36)}`, + { + cwd: repo, + target, + stateIdentity, + model: "gpt-5.5", + effort: null, + effortOverride: false, + focusText: "", + reviewName: "Adversarial Review" + } + ); + + assert.equal(processResult.code, 1); + assert.equal(storedJob.status, "failed"); + assert.match(storedJob.errorMessage, /moved between enqueue and execution/); + assert.match(storedJob.errorMessage, /base ref main no longer resolves/); + assert.equal(storedJob.failureClass, "state-drift"); + assert.equal(storedJob.retryable, true); + const stateFile = path.join(binDir, "fake-codex-state.json"); + const codexState = fs.existsSync(stateFile) ? JSON.parse(fs.readFileSync(stateFile, "utf8")) : {}; + assert.equal(codexState.lastTurnStart ?? null, null); +}); + +test("a background review rejects an unresolvable base ref before enqueue", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "src.js"), "export const value = 1;\n"); + run("git", ["add", "src.js"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const result = run( + "node", + [SCRIPT, "adversarial-review", "--background", "--base", "missing-base", "--json"], + { cwd: repo, env: buildEnv(binDir) } + ); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /rev-parse.*missing-base/i); + assert.doesNotMatch(result.stderr, /moved between enqueue and execution/); +}); + +test("a legacy background review without state identity still runs", async () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "src.js"), "export const value = 1;\n"); + run("git", ["add", "src.js"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + fs.writeFileSync(path.join(repo, "src.js"), "export const value = 2;\n"); + const target = { + mode: "working-tree", + label: "working tree diff", + explicit: false + }; + run("git", ["add", "src.js"], { cwd: repo }); + run("git", ["commit", "-m", "move pinned work"], { cwd: repo }); + + const { processResult, storedJob } = await runStoredReviewJob( + repo, + binDir, + `review-legacy-${Date.now().toString(36)}`, + { + cwd: repo, + target, + model: "gpt-5.5", + effort: null, + effortOverride: false, + focusText: "", + reviewName: "Adversarial Review" + } + ); + + assert.equal(processResult.code, 0, processResult.stderr); + assert.equal(storedJob.status, "completed"); + assert.ok(JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")).lastTurnStart); +}); + test("review rejects --wait and --background together", () => { const repo = makeTempDir(); const binDir = makeTempDir();