Skip to content

Commit a780ac6

Browse files
authored
Merge pull request #5980 from JSONbored/fix/ai-provider-structural-failure-cooldown
fix(selfhost): give structural AI-provider config errors a long circuit-breaker cooldown
2 parents 78df1a1 + c5940b7 commit a780ac6

4 files changed

Lines changed: 140 additions & 6 deletions

File tree

src/selfhost/ai.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
// review proceeds deterministically. Every path returns `{ response: string }` (or throws → the caller
77
// records an error and degrades — never a silent wrong answer).
88

9+
import { isStructuralProviderConfigError } from "../services/ai-review";
910
import type { AiContentBlock, CombineStrategy, OnMerge } from "../services/ai-review";
1011
import { isConfiguredSelfHostProvider, resolveConfiguredProviderNames } from "./ai-config";
1112
export { assertNoLegacySharedAiEnv } from "./ai-config";
@@ -1148,7 +1149,17 @@ function recordAiProvidersExhausted(): void {
11481149
// provider can be skipped fast without affecting readiness semantics.
11491150
const AI_PROVIDER_FAILURE_THRESHOLD = 3;
11501151
const AI_PROVIDER_COOLDOWN_MS = 60_000;
1151-
const aiProviderCircuits = new Map<string, { failures: number; cooldownUntil: number }>();
1152+
// A STRUCTURAL failure (bad/missing credentials -- isStructuralProviderConfigError) is deterministic: unlike a
1153+
// transient outage, the same provider will fail identically on the very next attempt too, so there is no reason
1154+
// to wait for AI_PROVIDER_FAILURE_THRESHOLD consecutive failures before opening the circuit, and no reason to
1155+
// re-check nearly as often once it's open. Confirmed live (GITTENSORY-K/8): a container with codex's credential
1156+
// file simply never present generated 2094 + 544 events over 16 days under the 60s/3-failure defaults -- each
1157+
// cooldown expiry let exactly one attempt through, which immediately re-failed and re-opened the circuit for
1158+
// another 60s, forever. A much longer cooldown still re-checks periodically (so a fixed credential is picked
1159+
// back up within the hour, not stuck until a manual restart) without hammering a known-broken provider on every
1160+
// incoming review.
1161+
const AI_PROVIDER_STRUCTURAL_COOLDOWN_MS = 60 * 60_000;
1162+
const aiProviderCircuits = new Map<string, { failures: number; cooldownUntil: number; structural: boolean }>();
11521163
const EXPECTED_EMBEDDING_ROUTING_ERRORS = new Set(["claude_code_no_embed", "codex_no_embed"]);
11531164

11541165
/** Test-only reset so circuit state from one test can't leak into the next (module-level map). */
@@ -1250,7 +1261,9 @@ async function runProviderWithOtel(
12501261
if (circuit && circuit.cooldownUntil > Date.now()) {
12511262
incr("loopover_ai_provider_circuit_open_total", { provider: provider.name });
12521263
throw new Error(
1253-
`circuit_open: provider "${provider.name}" is in cooldown after ${AI_PROVIDER_FAILURE_THRESHOLD} consecutive failures — skipping this attempt`,
1264+
circuit.structural
1265+
? `circuit_open: provider "${provider.name}" has a structural config error (bad/missing credentials) — skipping until the cooldown expires; fix the underlying config, then restart`
1266+
: `circuit_open: provider "${provider.name}" is in cooldown after ${AI_PROVIDER_FAILURE_THRESHOLD} consecutive failures — skipping this attempt`,
12541267
);
12551268
}
12561269
const requestKindLabel = requestKind(options);
@@ -1290,9 +1303,16 @@ async function runProviderWithOtel(
12901303
// this catch runs, and computing `failures` from it would clobber a sibling call's write (lost-update race)
12911304
// instead of accumulating. No `await` between this read and the `.set()` below, so it's race-free.
12921305
const failures = (aiProviderCircuits.get(provider.name)?.failures ?? 0) + 1;
1306+
// A structural failure is deterministic (see AI_PROVIDER_STRUCTURAL_COOLDOWN_MS above) -- open the circuit
1307+
// on the FIRST failure, not after AI_PROVIDER_FAILURE_THRESHOLD consecutive ones, at the much longer cooldown.
1308+
const structural = isStructuralProviderConfigError(error);
12931309
aiProviderCircuits.set(provider.name, {
12941310
failures,
1295-
cooldownUntil: failures >= AI_PROVIDER_FAILURE_THRESHOLD ? Date.now() + AI_PROVIDER_COOLDOWN_MS : 0,
1311+
cooldownUntil:
1312+
structural || failures >= AI_PROVIDER_FAILURE_THRESHOLD
1313+
? Date.now() + (structural ? AI_PROVIDER_STRUCTURAL_COOLDOWN_MS : AI_PROVIDER_COOLDOWN_MS)
1314+
: 0,
1315+
structural,
12961316
});
12971317
throw error;
12981318
}

src/services/ai-review.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,6 +1020,18 @@ export function isRateLimitError(error: unknown): boolean {
10201020
return error instanceof Error && /_(?:http|error)_429$/.test(error.message);
10211021
}
10221022

1023+
/** True for a provider's own STRUCTURAL misconfiguration signal (`src/selfhost/ai.ts`'s
1024+
* `codex_auth_not_configured` / `codex_no_auth` — a missing or expired credential file). Unlike a transient
1025+
* timeout or rate limit, this will fail identically on every future attempt until an operator re-runs
1026+
* `codex auth` -- confirmed live (GITTENSORY-K/8: 2094 + 544 events over 16 days from one unfixed
1027+
* misconfiguration, the credential file was never present the whole time). Mirrors
1028+
* {@link isSubscriptionCliTimeout}/{@link isRateLimitError}'s identical non-transient-error short-circuit.
1029+
* Exported so `src/selfhost/ai.ts`'s circuit breaker can give this failure class a much longer cooldown
1030+
* than a genuinely transient one. */
1031+
export function isStructuralProviderConfigError(error: unknown): boolean {
1032+
return error instanceof Error && /^codex_(?:auth_not_configured|no_auth):/.test(error.message);
1033+
}
1034+
10231035
/** Cap on the diagnostic prefix logged for an unparseable model response (#observability-unparseable) -- long
10241036
* enough to tell a markdown-fenced/truncated-mid-JSON/plain-prose response apart, short enough to never dump
10251037
* a large chunk of model output into Sentry/audit context. */
@@ -1148,7 +1160,10 @@ async function runWorkersOpinion(
11481160
// this attempt will not have cleared by the next attempt a few hundred ms later, so an immediate
11491161
// same-model retry burns the remaining budget for zero additional chance of success -- move straight
11501162
// to the fallback model instead, which may be on a different account/provider entirely.
1151-
if (isSubscriptionCliTimeout(error) || isRateLimitError(error)) break;
1163+
// A structural config error (missing/expired credentials) is stronger still: it is DETERMINISTIC, not
1164+
// just unlikely to clear in time -- the same model will fail the identical way on attempt 2 and 3 too,
1165+
// confirmed live (GITTENSORY-K/8: 2094 + 544 events over 16 days from one never-fixed misconfiguration).
1166+
if (isSubscriptionCliTimeout(error) || isRateLimitError(error) || isStructuralProviderConfigError(error)) break;
11521167
}
11531168
}
11541169
}
@@ -1859,8 +1874,9 @@ async function runDualAiTieBreakJudgeCall(
18591874
status: "provider_error",
18601875
error: errorMessage(error),
18611876
});
1862-
// See runWorkersOpinion's identical guard: a CLI timeout or 429 will not resolve by retrying the same model.
1863-
if (isSubscriptionCliTimeout(error) || isRateLimitError(error)) break;
1877+
// See runWorkersOpinion's identical guard: a CLI timeout, 429, or structural config error (bad/missing
1878+
// credentials) will not resolve by retrying the same model.
1879+
if (isSubscriptionCliTimeout(error) || isRateLimitError(error) || isStructuralProviderConfigError(error)) break;
18641880
}
18651881
}
18661882
}

test/unit/ai-review.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
BEST_REVIEW_MODELS,
55
buildTestEvidencePromptSection,
66
callAiProvider,
7+
isStructuralProviderConfigError,
78
resolveEffectiveAiReviewOnMerge,
89
resolveEffectiveAiReviewPlan,
910
runLoopOverAiReview,
@@ -2877,6 +2878,21 @@ describe("pure helpers", () => {
28772878
expect(run).toHaveBeenCalledTimes(2); // 1 primary (rate-limited) + 1 fallback (succeeded on its first try).
28782879
});
28792880

2881+
it("runDualAiTieBreakJudgeCall stops retrying a model after ONE structural codex-auth config error, same as a CLI timeout or 429 (GITTENSORY-K/8)", async () => {
2882+
let primaryAttempts = 0;
2883+
const run = vi.fn(async (model: string) => {
2884+
if (model === "fallback") return { response: '{"favored":"reviewer_1"}' };
2885+
primaryAttempts += 1;
2886+
throw new Error("codex_auth_not_configured: ~/.codex/auth.json not found");
2887+
});
2888+
const env = createTestEnv({ AI: { run } as unknown as Ai });
2889+
const diagnostics: Array<{ status: string; model: string }> = [];
2890+
const parsed = await runDualAiTieBreakJudgeCall(env, "primary", "fallback", blockedA, clean, false, diagnostics as never);
2891+
expect(parsed?.verdict).toBe("reviewer_1");
2892+
expect(primaryAttempts).toBe(1); // NOT 3 -- a structural config error is deterministic, so retrying is pointless.
2893+
expect(run).toHaveBeenCalledTimes(2); // 1 primary (structural failure) + 1 fallback (succeeded on its first try).
2894+
});
2895+
28802896
it("resolveDualAiTieBreakWithOrderStability returns inconclusive when judge output never parses", async () => {
28812897
const run = vi.fn(async () => ({ response: "not-json" }));
28822898
const env = createTestEnv({ AI: { run } as unknown as Ai });
@@ -3043,6 +3059,31 @@ describe("pure helpers", () => {
30433059
expect(run).toHaveBeenCalledTimes(2); // 1 primary (rate-limited) + 1 fallback (succeeded on its first try).
30443060
});
30453061

3062+
it("runWorkersOpinion stops retrying a model after ONE structural codex-auth config error, same as a CLI timeout or 429 (GITTENSORY-K/8)", async () => {
3063+
let primaryAttempts = 0;
3064+
const run = vi.fn(async (model: string) => {
3065+
if (model === "fallback") return { response: reviewJson() };
3066+
primaryAttempts += 1;
3067+
throw new Error("codex_no_auth: auth.json missing or expired");
3068+
});
3069+
const env = createTestEnv({ AI: { run } as unknown as Ai });
3070+
const diagnostics: Array<{ status: string; model: string }> = [];
3071+
const parsed = await runWorkersOpinion(env, "primary", "fallback", "sys", "user", 256, diagnostics as never);
3072+
expect(parsed.review?.assessment).toContain("reasonable");
3073+
expect(primaryAttempts).toBe(1); // NOT 3 -- a structural config error is deterministic, so retrying is pointless.
3074+
expect(run).toHaveBeenCalledTimes(2); // 1 primary (structural failure) + 1 fallback (succeeded on its first try).
3075+
});
3076+
3077+
it("isStructuralProviderConfigError matches only codex's own structural-config error messages, not other Errors or non-Error throws (GITTENSORY-K/8)", () => {
3078+
expect(isStructuralProviderConfigError(new Error("codex_auth_not_configured: ~/.codex/auth.json not found"))).toBe(true);
3079+
expect(isStructuralProviderConfigError(new Error("codex_no_auth: auth.json missing or expired"))).toBe(true);
3080+
expect(isStructuralProviderConfigError(new Error("connection reset"))).toBe(false);
3081+
// Anchored ("^codex_...") -- a wrapped/rethrown message doesn't match, only the exact provider-level throw does.
3082+
expect(isStructuralProviderConfigError(new Error("wrapped: codex_auth_not_configured: nested"))).toBe(false);
3083+
expect(isStructuralProviderConfigError("codex_auth_not_configured: not an Error instance")).toBe(false);
3084+
expect(isStructuralProviderConfigError(undefined)).toBe(false);
3085+
});
3086+
30463087
it("runWorkersOpinion still retries a genuinely transient (non-timeout, non-429) error up to the full budget", async () => {
30473088
let attempts = 0;
30483089
const run = vi.fn(async () => {

test/unit/selfhost-ai.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -735,6 +735,63 @@ describe("per-provider circuit breaker (#2540 — skip fast during a sustained o
735735
// call exhausted the (single-provider) chain — a circuit-open throw still counts as a chain exhaustion.
736736
expect(isAiProviderHealthy()).toBe(false);
737737
});
738+
739+
it("opens the circuit on the very FIRST structural codex-auth failure, not after 3 (GITTENSORY-K/8 — a deterministic failure shouldn't pay for 3 real attempts)", async () => {
740+
const calls = vi.fn(async () => {
741+
throw new Error("codex_auth_not_configured: ~/.codex/auth.json not found");
742+
});
743+
const brokenAuth = { name: "codex", ai: { run: calls } };
744+
await expect(createChainAi([brokenAuth]).run("m", { prompt: "x" })).rejects.toThrow(/codex_auth_not_configured/);
745+
expect(calls).toHaveBeenCalledTimes(1);
746+
// The very NEXT call is already skipped — no need to accumulate AI_PROVIDER_FAILURE_THRESHOLD failures first.
747+
await expect(createChainAi([brokenAuth]).run("m", { prompt: "x" })).rejects.toThrow(
748+
/circuit_open: provider "codex" has a structural config error/,
749+
);
750+
expect(calls).toHaveBeenCalledTimes(1); // unchanged — the real provider was never reached a 2nd time
751+
const metrics = await renderMetrics();
752+
expect(metrics).toContain('loopover_ai_provider_circuit_open_total{provider="codex"} 1');
753+
expect(metrics).toContain('loopover_ai_provider_failures_total{provider="codex"} 1'); // NOT 3
754+
});
755+
756+
it("uses the long structural cooldown (1h), not the 60s transient cooldown — still open just past 60s, reachable again only past 1h", async () => {
757+
vi.useFakeTimers();
758+
const calls = vi.fn(async () => {
759+
throw new Error("codex_no_auth: auth.json missing or expired");
760+
});
761+
const brokenAuth = { name: "codex-2", ai: { run: calls } };
762+
await expect(createChainAi([brokenAuth]).run("m", { prompt: "x" })).rejects.toThrow(/codex_no_auth/);
763+
expect(calls).toHaveBeenCalledTimes(1);
764+
// Past the ORDINARY 60s transient cooldown, the structural circuit must still be open.
765+
await vi.advanceTimersByTimeAsync(60_001);
766+
await expect(createChainAi([brokenAuth]).run("m", { prompt: "x" })).rejects.toThrow(/circuit_open/);
767+
expect(calls).toHaveBeenCalledTimes(1);
768+
// Past the full 1h structural cooldown, the real provider is reachable again (e.g. to notice a fixed credential).
769+
await vi.advanceTimersByTimeAsync(3_600_000);
770+
await expect(createChainAi([brokenAuth]).run("m", { prompt: "x" })).rejects.toThrow(/codex_no_auth/);
771+
expect(calls).toHaveBeenCalledTimes(2); // the real provider WAS invoked this time
772+
});
773+
774+
it("a success fully clears a structural circuit entry — a later transient failure starts fresh, not at the 1h structural cooldown", async () => {
775+
vi.useFakeTimers();
776+
let mode: "structural-fail" | "succeed" | "transient-fail" = "structural-fail";
777+
const calls = vi.fn(async () => {
778+
if (mode === "succeed") return { response: "ok" };
779+
if (mode === "transient-fail") throw new Error("connection reset");
780+
throw new Error("codex_auth_not_configured: ~/.codex/auth.json not found");
781+
});
782+
const provider = { name: "codex-3", ai: { run: calls } };
783+
await expect(createChainAi([provider]).run("m", { prompt: "x" })).rejects.toThrow(/codex_auth_not_configured/);
784+
// Credential fixed; jump past the 1h structural cooldown so the real provider is reachable again.
785+
await vi.advanceTimersByTimeAsync(3_600_001);
786+
mode = "succeed";
787+
await expect(createChainAi([provider]).run("m", { prompt: "x" })).resolves.toEqual({ response: "ok" });
788+
// A later, unrelated transient failure must start from a clean slate (1 failure, 60s-tier), not reopen
789+
// immediately at the 1h structural cooldown left over from before the success.
790+
mode = "transient-fail";
791+
await expect(createChainAi([provider]).run("m", { prompt: "x" })).rejects.toThrow(/connection reset/);
792+
await expect(createChainAi([provider]).run("m", { prompt: "x" })).rejects.toThrow(/connection reset/); // 2nd failure, still below threshold 3 — reaches the real provider again
793+
expect(calls).toHaveBeenCalledTimes(4); // structural-fail, succeed, transient-fail x2 — all reached the real provider.ai.run
794+
});
738795
});
739796

740797
describe("isAiProviderHealthy (readiness streak, #2497)", () => {

0 commit comments

Comments
 (0)