Skip to content

Commit f791f61

Browse files
fix(review): surface per-model terminal errors and readable diagnostics in review-failure telemetry (#8302)
The ai_review_provider_exhausted summary only carried the LAST error across all models x attempts, so a fallback's circuit_open masked the primary's distinct terminal failure (a rate-limit 429) during the 2026-07-23 outage (LOOPOVER-2A) -- the single Sentry event pointed at the wrong provider. runWorkersOpinion now also tracks each model's own terminal error and logs the map alongside the unchanged last-error field. Both captureReviewFailure sites passed raw AiReviewDiagnostic[] into Sentry context, where the SDK's default normalizeDepth flattened every entry to the literal string "[Object]" (LOOPOVER-2B), erasing the model/attempt/status/ error detail. formatReviewDiagnosticsForCapture renders them as compact model#attempt:status[:error] strings that survive normalization. Co-authored-by: loopover-orb[bot] <296761690+loopover-orb[bot]@users.noreply.github.com>
1 parent 7480c78 commit f791f61

4 files changed

Lines changed: 72 additions & 3 deletions

File tree

src/queue/ai-review-orchestration.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
} from "../signals/focus-manifest";
3636
import { loadRepoFocusManifest } from "../signals/focus-manifest-loader";
3737
import {
38+
formatReviewDiagnosticsForCapture,
3839
hasPublicReviewAssessment,
3940
isEnabled,
4041
runLoopOverAiReview,
@@ -801,8 +802,10 @@ export async function runAiReviewForAdvisory(
801802
ai_review_mode: args.settings.aiReviewMode,
802803
reviewer_count: result.reviewerCount,
803804
public_notes: hasPublicReviewAssessment(result.advisoryNotes),
805+
// Compact strings, not the raw objects -- Sentry's normalizeDepth flattens nested entries to "[Object]"
806+
// and destroys the per-attempt detail (LOOPOVER-2B); see formatReviewDiagnosticsForCapture.
804807
/* v8 ignore next -- current review runner always supplies diagnostics for completed AI attempts. */
805-
review_diagnostics: result.reviewDiagnostics ?? [],
808+
review_diagnostics: formatReviewDiagnosticsForCapture(result.reviewDiagnostics ?? []),
806809
}, "ai_review_inconclusive");
807810
}
808811
args.advisory.findings.push(...findings);
@@ -872,8 +875,9 @@ export async function runAiReviewForAdvisory(
872875
head_sha: args.advisory.headSha,
873876
ai_review_mode: args.settings.aiReviewMode,
874877
reviewer_count: result.reviewerCount,
878+
// Same "[Object]" flattening hazard as the inconclusive capture above (LOOPOVER-2B).
875879
/* v8 ignore next -- current review runner always supplies diagnostics for completed AI attempts. */
876-
review_diagnostics: result.reviewDiagnostics ?? [],
880+
review_diagnostics: formatReviewDiagnosticsForCapture(result.reviewDiagnostics ?? []),
877881
configured_reviewers:
878882
env.AI_REVIEW_PLAN?.reviewers?.map((reviewer) => reviewer.model) ??
879883
null,

src/services/ai-review.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,21 @@ export type AiReviewActualUsage = {
488488
costUsd?: number | undefined;
489489
};
490490

491+
/** Render diagnostics as compact `model#attempt:status[:error]` strings for Sentry capture context. Passing the
492+
* raw objects loses everything: Sentry's normalizeDepth flattens each nested entry to the literal string
493+
* "[Object]", which erased exactly the model/attempt/status/error detail these entries exist to carry (the
494+
* 2026-07-23 outage, LOOPOVER-2B, was diagnosable only from separate provider-failure events because of this).
495+
* Strings survive normalization verbatim. The `error` field is errorMessage() output, never raw provider text,
496+
* so including it here keeps the "withholds unsafe provider text" boundary intact. */
497+
export function formatReviewDiagnosticsForCapture(
498+
diagnostics: readonly AiReviewDiagnostic[],
499+
): string[] {
500+
return diagnostics.map(
501+
(diagnostic) =>
502+
`${diagnostic.model}#${diagnostic.attempt}:${diagnostic.status}${diagnostic.error ? `:${diagnostic.error}` : ""}`,
503+
);
504+
}
505+
491506
type ReviewerOpinionOutcome = {
492507
review: ModelReview | null;
493508
fallbackNote?: string | undefined;
@@ -1142,6 +1157,10 @@ async function runWorkersOpinion(
11421157
// Track the last provider error so we can fail-LOUD once ALL models × attempts are exhausted (below). Per-attempt
11431158
// logs are warn (noisy retries, skipped by the central Sentry forwarder); the exhausted summary is error (#26).
11441159
let lastError: unknown;
1160+
// ALSO track each model's own terminal error: `lastError` alone lets the fallback's failure MASK the primary's
1161+
// distinct one in the exhausted summary -- during the 2026-07-23 outage (LOOPOVER-2A) the fallback's
1162+
// circuit_open hid the primary's rate-limit 429, so the single Sentry event pointed at the wrong provider.
1163+
const errorsByModel: Record<string, string> = {};
11451164
let lastUnparseable:
11461165
| { model: string; attempt: number; responseChars: number; hasJsonObject: boolean; responseSnippet: string }
11471166
| undefined;
@@ -1257,6 +1276,7 @@ async function runWorkersOpinion(
12571276
}),
12581277
);
12591278
lastError = error;
1279+
errorsByModel[model] = errorMessage(error);
12601280
// A CLI timeout is not transient -- the same model retrying the same oversized/complex diff will almost
12611281
// certainly time out again. Stop retrying THIS model (the fallback below still gets its own full retry
12621282
// budget, since a different model/config may not share the same timeout) instead of burning up to 3x
@@ -1284,6 +1304,7 @@ async function runWorkersOpinion(
12841304
primary,
12851305
fallback,
12861306
error: errorMessage(lastError),
1307+
errorsByModel,
12871308
}),
12881309
);
12891310
}

test/unit/ai-review-advisory.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -456,8 +456,10 @@ describe("runAiReviewForAdvisory", () => {
456456
head_sha: "sha3",
457457
public_notes: true,
458458
reviewer_count: 1,
459+
// Compact strings, not objects -- raw diagnostic objects flatten to "[Object]" in Sentry context
460+
// (LOOPOVER-2B); formatReviewDiagnosticsForCapture renders model#attempt:status[:error].
459461
review_diagnostics: expect.arrayContaining([
460-
expect.objectContaining({ status: "unparseable_output" }),
462+
expect.stringContaining(":unparseable_output"),
461463
]),
462464
}),
463465
"ai_review_inconclusive",

test/unit/ai-review.test.ts

Lines changed: 42 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+
formatReviewDiagnosticsForCapture,
78
INCOHERENT_DIFF_ASSESSMENT,
89
isIncoherentDiffBail,
910
isStructuralProviderConfigError,
@@ -3366,6 +3367,47 @@ describe("pure helpers", () => {
33663367
warnSpy.mockRestore();
33673368
});
33683369

3370+
it("REGRESSION (LOOPOVER-2A): the exhausted log carries each model's OWN terminal error, so the fallback's failure cannot mask the primary's", async () => {
3371+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
3372+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
3373+
// The 2026-07-23 outage shape: the primary rate-limits (429 → no same-model retry), the fallback fails
3374+
// structurally (circuit_open). `error` alone reported only the fallback's message, hiding the 429.
3375+
const run = vi.fn(async (model: string) => {
3376+
throw new Error(model === "primary-model" ? "claude_code_error_429" : "circuit_open: provider down");
3377+
});
3378+
const env = createTestEnv({ AI: { run } as unknown as Ai });
3379+
const result = await runWorkersOpinion(env, "primary-model", "fallback-model", "sys", "user", 256);
3380+
expect(result).toEqual({ review: null });
3381+
const exhausted = logSpy.mock.calls
3382+
.map((c) => c[0])
3383+
.find((l) => typeof l === "string" && l.includes("ai_review_provider_exhausted"));
3384+
expect(exhausted).toBeDefined();
3385+
expect(JSON.parse(exhausted as string)).toMatchObject({
3386+
event: "ai_review_provider_exhausted",
3387+
// Still the last error overall (unchanged Sentry grouping)…
3388+
error: expect.stringContaining("circuit_open"),
3389+
// …but now ALSO each model's own terminal failure, keyed by model.
3390+
errorsByModel: {
3391+
"primary-model": "claude_code_error_429",
3392+
"fallback-model": "circuit_open: provider down",
3393+
},
3394+
});
3395+
logSpy.mockRestore();
3396+
warnSpy.mockRestore();
3397+
});
3398+
3399+
it("formatReviewDiagnosticsForCapture renders compact model#attempt:status[:error] strings (raw objects flatten to \"[Object]\" in Sentry context — LOOPOVER-2B)", () => {
3400+
const diagnostics: AiReviewDiagnostic[] = [
3401+
{ model: "claude-code", attempt: 0, status: "provider_error", error: "claude_code_error_429" },
3402+
{ model: "codex", attempt: 1, status: "unparseable_output", responseChars: 12, hasJsonObject: false },
3403+
];
3404+
expect(formatReviewDiagnosticsForCapture(diagnostics)).toEqual([
3405+
"claude-code#0:provider_error:claude_code_error_429",
3406+
"codex#1:unparseable_output",
3407+
]);
3408+
expect(formatReviewDiagnosticsForCapture([])).toEqual([]);
3409+
});
3410+
33693411
it("logs unparseable exhaustion separately when the model runs but returns unparseable output, including a response snippet for diagnosis (#observability-unparseable)", async () => {
33703412
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
33713413
const run = vi.fn(async () => ({ response: "not json at all" }));

0 commit comments

Comments
 (0)