diff --git a/.codex/hooks/dual-review.cjs b/.codex/hooks/dual-review.cjs index d9cd634..9284368 100644 --- a/.codex/hooks/dual-review.cjs +++ b/.codex/hooks/dual-review.cjs @@ -1084,6 +1084,39 @@ function reviewerAvailabilityReason(message) { return ""; } +function explicitClaudeAvailabilityReason(stdout) { + let events; + try { events = parseClaudeEvents(stdout); } catch { return ""; } + for (const event of events) { + if (event?.type === "rate_limit_event" && event?.rate_limit_info?.status === "rejected") { + return "quota_exhausted"; + } + if (event?.type !== "result") continue; + const explicitError = event?.is_error === true + || event?.terminal_reason === "api_error" + || Number(event?.api_error_status || 0) >= 400; + if (!explicitError) continue; + if (Number(event?.api_error_status || 0) === 429) return "quota_exhausted"; + const reason = reviewerAvailabilityReason(JSON.stringify({ + terminal_reason: event?.terminal_reason, + api_error_status: event?.api_error_status, + error: event?.error, + result: event?.result, + })); + if (reason !== "") return reason; + } + return ""; +} + +function claudeAvailabilityReason(result) { + const explicitReason = explicitClaudeAvailabilityReason(result?.stdout); + if (explicitReason !== "") return explicitReason; + if (result?.status !== 0 || result?.error) { + return reviewerAvailabilityReason(`${result?.stderr || ""}\n${result?.stdout || ""}`); + } + return ""; +} + function actualClaudeModel(parsed, envelope) { const entries = Array.isArray(parsed) ? parsed : [parsed]; const candidates = [ @@ -1118,12 +1151,20 @@ async function runClaudeReviewer(prompt, temporaryDirectory, signal, reviewer, r logPath: path.join(logDirectory, `cross-model-${phase}-attempt-${attempt}.log`), windowsVerbatimArguments: prepared.windowsVerbatimArguments, }); - if (result.status !== 0 || result.error || result.timedOut || result.cancelled) { - const availabilityReason = reviewerAvailabilityReason(`${result.stderr}\n${result.stdout}`); + const availabilityReason = claudeAvailabilityReason(result); + if (availabilityReason !== "") { return { ...result, availabilityReason, - retryable: availabilityReason === "" && result.terminal_state === REVIEW_STATES.PROVIDER_OR_TRANSPORT_FAILURE, + retryable: false, + terminal_state: REVIEW_STATES.PROVIDER_OR_TRANSPORT_FAILURE, + }; + } + if (result.status !== 0 || result.error || result.timedOut || result.cancelled) { + return { + ...result, + availabilityReason: "", + retryable: result.terminal_state === REVIEW_STATES.PROVIDER_OR_TRANSPORT_FAILURE, }; } try { @@ -1169,6 +1210,7 @@ async function confirmClaudeReviewerUnavailable(temporaryDirectory, signal, revi "-p", "--model", reviewer.model, "--effort", reviewer.effort, "--safe-mode", "--max-turns", "1", "--setting-sources", "user", "--tools", "", "--disable-slash-commands", "--no-session-persistence", "--mcp-config", '{"mcpServers":{}}', "--strict-mcp-config", + "--output-format", "stream-json", "--verbose", ]; const prepared = preparedLaunch(launch, args); const result = await runSupervisedProcess(prepared.command, prepared.args, { @@ -1179,13 +1221,14 @@ async function confirmClaudeReviewerUnavailable(temporaryDirectory, signal, revi stallTimeout: configuredDuration("CODEX_SDLC_AVAILABILITY_TIMEOUT_MS", 60 * 1000), heartbeatInterval: configuredDuration("CODEX_SDLC_REVIEW_HEARTBEAT_MS", 30 * 1000), killGrace: configuredDuration("CODEX_SDLC_REVIEW_KILL_GRACE_MS", 2000), + structuredEventsRequired: true, signal, label: `${reviewer.label} availability probe`, logPath: path.join(logDirectory, "cross-model-availability-probe.log"), windowsVerbatimArguments: prepared.windowsVerbatimArguments, }); - if (result.error || result.timedOut || result.status === 0) return false; - return reviewerAvailabilityReason(`${result.stderr}\n${result.stdout}`) === suspectedReason; + if (result.error || result.timedOut || result.cancelled) return false; + return claudeAvailabilityReason(result) === suspectedReason; } async function runCrossModel(prompt, temporaryDirectory, signal, selectedReviewer, logDirectory, phase) { diff --git a/tests/test-adapter.sh b/tests/test-adapter.sh index ea3cd43..57902ec 100755 --- a/tests/test-adapter.sh +++ b/tests/test-adapter.sh @@ -5626,10 +5626,32 @@ if (model === "fable" && ["fallback", "both-unavailable"].includes(mode)) { process.stdout.write("You're out of usage credits. Run /usage-credits to keep using Fable 5.\n"); process.exit(1); } +if (model === "fable" && mode === "zero-exit-quota") { + process.stdout.write(`${JSON.stringify({ type: "rate_limit_event", rate_limit_info: { status: "rejected" } })}\n`); + process.stdout.write(`${JSON.stringify({ + type: "result", + subtype: "success", + is_error: true, + terminal_reason: "api_error", + api_error_status: 429, + result: "Weekly usage limit reached.", + })}\n`); + process.exit(0); +} if (model === "fable" && mode === "spoofed-unavailability" && !probe) { process.stdout.write("You're out of usage credits. Run /usage-credits to keep using Fable 5.\n"); process.exit(1); } +if (model === "fable" && mode === "zero-exit-malformed" && !probe) { + process.stdout.write(JSON.stringify({ + type: "result", + subtype: "success", + is_error: false, + model: "claude-fable-5", + result: "quota exhausted", + })); + process.exit(0); +} if (probe) { process.stdout.write("available\n"); process.exit(0); @@ -5719,6 +5741,41 @@ if (receipt.reviewers.cross_model.fallback_reason !== "quota_exhausted") process if (!receipt.initial.cross_model || Object.hasOwn(receipt.initial, "fable")) process.exit(1); NODE + rm -f "$receipt" + : > "$calls" + set +e + output=$(cd "$ws" && CODEX_SDLC_TEST_MODE=1 CODEX_SDLC_CODEX_PATH="$fake_codex" \ + CODEX_SDLC_CLAUDE_PATH="$fake_claude" CLAUDE_CALLS="$calls" \ + FALLBACK_TEST_MODE=zero-exit-quota node .codex/hooks/dual-review.cjs --base HEAD \ + --consent-subscription-quota 2>&1) + status=$? + set -e + [ "$status" -eq 0 ] || valid=false + RECEIPT_PATH="$receipt" CLAUDE_CALLS="$calls" node <<'NODE' || valid=false +const fs = require("node:fs"); +const receipt = JSON.parse(fs.readFileSync(process.env.RECEIPT_PATH, "utf8")); +const calls = fs.readFileSync(process.env.CLAUDE_CALLS, "utf8").trim().split("\n").filter(Boolean).map(JSON.parse); +if (calls.length !== 3 || !calls[1].probe) process.exit(1); +if (calls[0].model !== "fable" || calls[1].model !== "fable" || calls[2].model !== "claude-opus-4-8") process.exit(1); +if (receipt.reviewers.cross_model.route !== "fallback") process.exit(1); +if (receipt.reviewers.cross_model.fallback_reason !== "quota_exhausted") process.exit(1); +if (receipt.reviewers.cross_model.model !== "claude-opus-4-8-20260801") process.exit(1); +NODE + + rm -f "$receipt" + : > "$calls" + set +e + output=$(cd "$ws" && CODEX_SDLC_TEST_MODE=1 CODEX_SDLC_CODEX_PATH="$fake_codex" \ + CODEX_SDLC_CLAUDE_PATH="$fake_claude" CLAUDE_CALLS="$calls" \ + FALLBACK_TEST_MODE=zero-exit-malformed node .codex/hooks/dual-review.cjs --base HEAD \ + --consent-subscription-quota 2>&1) + status=$? + set -e + [ "$status" -eq 2 ] || valid=false + [ "$(wc -l < "$calls" | tr -d ' ')" -eq 1 ] || valid=false + grep -q 'claude-opus-4-8' "$calls" && valid=false + [ ! -f "$receipt" ] || valid=false + rm -f "$receipt" : > "$calls" set +e