Skip to content

Commit a72bd1a

Browse files
authored
Merge branch 'main' into feat/miner-ci-poller-2323
2 parents 8191a69 + 6f46056 commit a72bd1a

11 files changed

Lines changed: 394 additions & 26 deletions

File tree

prometheus/rules/alerts.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,3 +369,36 @@ groups:
369369
summary: "gittensory p95 request latency above 1s"
370370
description: "p95 HTTP request latency is {{ $value | printf \"%.2f\" }}s over the last 5m (sustained 10m), breaching the 1s SLO."
371371
runbook: "Check whether slowness is queue/DB/AI-bound: correlate with gittensory_queue_pending and Qdrant/AI latency. A rising p95 with flat error rate usually means a saturated dependency, not a bug."
372+
373+
# ── AI review reliability (dual-AI combiner + per-provider circuit breaker, #2540) ─
374+
- name: gittensory-ai-review
375+
rules:
376+
- alert: GittensoryAiReviewInconclusiveSpike
377+
# `inconclusive` means the AI review pipeline could not produce a usable verdict (every reviewer
378+
# opinion missing/unparseable, or a required opinion never came back) -- the review still runs
379+
# deterministically, but dual-AI review is repeatedly failing to add value. Absolute-increase
380+
# threshold (matching GittensoryDeadLetterJobsGrowing above): there's no clean matching-cardinality
381+
# denominator (total review attempts aren't broken out per-mode the same way), so a ratio query
382+
# would need an unrelated series. > 5 in 30m tolerates the occasional one-off degrade.
383+
expr: increase(gittensory_ai_review_inconclusive_total[30m]) > 5
384+
for: 10m
385+
labels:
386+
severity: warning
387+
annotations:
388+
summary: "gittensory AI review is repeatedly inconclusive"
389+
description: "{{ $value | printf \"%.0f\" }} AI review(s) came back inconclusive over the last 30m (sustained 10m). Dual-AI review is repeatedly failing to produce a usable verdict."
390+
runbook: "Check provider health and circuit-breaker state (gittensory_ai_provider_failures_total / gittensory_ai_provider_circuit_open_total) and verify AI_PROVIDER credentials are still valid for every configured reviewer."
391+
392+
- alert: GittensoryAiProviderCircuitOpen
393+
# A provider's circuit breaker opens after AI_PROVIDER_FAILURE_THRESHOLD consecutive failures and
394+
# short-circuits further attempts for a cooldown window -- ANY circuit-open event in 15m means that
395+
# provider has been failing repeatedly and calls are being skipped fast rather than retried at full
396+
# cost. Same absolute-increase style as GittensoryDeadLetterJobsGrowing: any occurrence is worth a look.
397+
expr: increase(gittensory_ai_provider_circuit_open_total[15m]) > 0
398+
for: 5m
399+
labels:
400+
severity: warning
401+
annotations:
402+
summary: "gittensory AI provider {{ $labels.provider }} circuit breaker is open"
403+
description: "Provider {{ $labels.provider }} has failed repeatedly and its circuit breaker is skipping calls fast during its cooldown (sustained 5m)."
404+
runbook: "Check that provider's credentials/reachability (CLI auth for claude-code/codex, or the configured API key/base URL for HTTP providers) via gittensory_ai_provider_failures_total{provider=\"...\"} and recent selfhost_ai_provider_failed logs."

src/github/backfill.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3216,6 +3216,12 @@ async function syncLabels(
32163216
}
32173217
}
32183218

3219+
/**
3220+
* `limit` is a page-boundary threshold, not a strict maximum: page consumption is atomic (see the loop body),
3221+
* so the crawl always finishes the page it's on once `items.length` reaches `limit`, which can return up to
3222+
* `perPage - 1` more items than requested. Callers that need an exact cap must trim the returned `items`
3223+
* themselves; `fetchedCount`/`items.length` always reflect the true (possibly over-`limit`) count.
3224+
*/
32193225
async function githubPaged<T>(
32203226
env: Env,
32213227
repo: RepositoryRecord,
@@ -3258,18 +3264,17 @@ async function githubPaged<T>(
32583264
lastModified = result.lastModified ?? lastModified;
32593265
lastCursor = String(page);
32603266
pageCount += 1;
3261-
const remaining = limit - items.length;
3262-
// A page can only overrun `remaining` once we have already consumed at least one full page (so `page`
3263-
// has advanced past the crawl's start): resume from THIS page to pick up its unconsumed tail. On the
3264-
// first page `remaining >= perPage >= result.data.length`, so this is false and the cursor advances,
3265-
// which is why a small-`limit` (per_page = limit) crawl can never stall on its own start page.
3266-
const truncatedPage = result.data.length > remaining;
3267-
items.push(...result.data.slice(0, remaining));
3267+
// Resume cursors only have page precision, so keep page consumption atomic: once we request a
3268+
// page, process the whole response before advancing the cursor. Slicing a mid-page cap would make a
3269+
// later resume replay the already-consumed prefix of that same page and inflate fetched counts.
3270+
items.push(...result.data);
32683271
const hasNext = hasNextPage(result.link);
3269-
if (items.length >= limit && (hasNext || truncatedPage)) {
3270-
nextCursor = String(truncatedPage ? page : page + 1);
3272+
if (items.length >= limit && hasNext) {
3273+
nextCursor = String(page + 1);
32713274
status = "capped";
3272-
warnings.push(`GitHub sync reached local cap of ${limit} item(s) for ${path}; next page cursor is ${nextCursor}.`);
3275+
// `items.length` (not `limit`) is the actual count: page consumption is atomic, so a whole final page
3276+
// can overrun the requested `limit` — `limit` is a page-boundary threshold, not a strict maximum.
3277+
warnings.push(`GitHub sync reached local cap of ${limit} item(s) for ${path} (fetched ${items.length} after completing page ${page}); next page cursor is ${nextCursor}.`);
32733278
break;
32743279
}
32753280
if (result.data.length < perPage || !hasNext) break;

src/selfhost/ai.ts

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,20 @@ export function resetAiProviderHealthForTest(): void {
604604
aiConsecutiveFailures = 0;
605605
}
606606

607+
// Per-provider circuit breaker (#2540): a provider that is failing hard (bad credential, sustained outage)
608+
// otherwise pays the FULL cost of a fresh attempt (a real HTTP call, or a real CLI subprocess spawn) on
609+
// every single review during the outage. This is independent of `aiConsecutiveFailures` above -- that streak
610+
// tracks whole-CHAIN exhaustion for /ready; this tracks one PROVIDER's own reliability so a known-broken
611+
// provider can be skipped fast without affecting readiness semantics.
612+
const AI_PROVIDER_FAILURE_THRESHOLD = 3;
613+
const AI_PROVIDER_COOLDOWN_MS = 60_000;
614+
const aiProviderCircuits = new Map<string, { failures: number; cooldownUntil: number }>();
615+
616+
/** Test-only reset so circuit state from one test can't leak into the next (module-level map). */
617+
export function resetAiProviderCircuitBreakerForTest(): void {
618+
aiProviderCircuits.clear();
619+
}
620+
607621
/** Whether a missing-CLI boot check should force /ready unhealthy: only when EVERY configured provider is
608622
* among the missing-CLI set, i.e. the whole AI_PROVIDER chain has zero chance of working -- not just one
609623
* provider within a chain that has a working fallback (another present CLI, or an HTTP-based provider,
@@ -674,16 +688,39 @@ function requestKind(options: AiRunOptions): "embedding" | "review" {
674688
return Array.isArray(options.text) ? "embedding" : "review";
675689
}
676690

677-
function runProviderWithOtel(
691+
async function runProviderWithOtel(
678692
provider: { name: string; ai: SelfHostAi },
679693
model: string,
680694
options: AiRunOptions,
681695
): Promise<AiResult> {
682-
return withReviewSpan(
683-
"selfhost.ai.provider",
684-
{ "ai.provider": provider.name, "ai.model": model || "default", "ai.request_kind": requestKind(options) },
685-
() => provider.ai.run(model, options),
686-
);
696+
const circuit = aiProviderCircuits.get(provider.name);
697+
if (circuit && circuit.cooldownUntil > Date.now()) {
698+
incr("gittensory_ai_provider_circuit_open_total", { provider: provider.name });
699+
throw new Error(
700+
`circuit_open: provider "${provider.name}" is in cooldown after ${AI_PROVIDER_FAILURE_THRESHOLD} consecutive failures — skipping this attempt`,
701+
);
702+
}
703+
try {
704+
const result = await withReviewSpan(
705+
"selfhost.ai.provider",
706+
{ "ai.provider": provider.name, "ai.model": model || "default", "ai.request_kind": requestKind(options) },
707+
() => provider.ai.run(model, options),
708+
);
709+
aiProviderCircuits.delete(provider.name);
710+
return result;
711+
} catch (error) {
712+
incr("gittensory_ai_provider_failures_total", { provider: provider.name });
713+
// Re-read the map here rather than reusing the `circuit` captured above: that read happened BEFORE the
714+
// `await` on the real provider call, so under concurrent same-provider calls it can be stale by the time
715+
// this catch runs, and computing `failures` from it would clobber a sibling call's write (lost-update race)
716+
// instead of accumulating. No `await` between this read and the `.set()` below, so it's race-free.
717+
const failures = (aiProviderCircuits.get(provider.name)?.failures ?? 0) + 1;
718+
aiProviderCircuits.set(provider.name, {
719+
failures,
720+
cooldownUntil: failures >= AI_PROVIDER_FAILURE_THRESHOLD ? Date.now() + AI_PROVIDER_COOLDOWN_MS : 0,
721+
});
722+
throw error;
723+
}
687724
}
688725

689726
/** Build one provider adapter by name. Provider config stays explicit so dual-provider setups cannot accidentally
@@ -798,6 +835,16 @@ export function resolveAiReviewerPlan(
798835
const names = resolveProviderNames(env);
799836
if (names.length === 0) return undefined;
800837
if (names.length === 1) return { reviewers: [{ model: names[0] as string }], combine: "single", onMerge: undefined };
838+
// Fail loud when the two SLOTS the dual-review plan actually uses (the first two names) are the same
839+
// provider: routeProviders' `byName` map collapses duplicate provider names to one runtime instance, so
840+
// "dual review" would silently become "one provider called twice" -- no independent second opinion, and
841+
// that provider's outage takes down both slots. A THIRD+ duplicate further down the list is fine; only
842+
// the first two matter because resolveAiReviewerPlan below caps reviewers at names.slice(0, 2).
843+
if (names[0] === names[1]) {
844+
throw new Error(
845+
`ai_reviewer_providers_not_distinct: AI_PROVIDER lists "${names[0]}" for both dual-review reviewer slots — configure two distinct providers (e.g. AI_PROVIDER=claude-code,codex) for independent dual review, or a single provider (AI_PROVIDER=codex) for single-reviewer mode.`,
846+
);
847+
}
801848
const rawCombine = (env.AI_COMBINE ?? "").trim().toLowerCase() as CombineStrategy;
802849
const combine: CombineStrategy = COMBINE_STRATEGIES.has(rawCombine) ? rawCombine : "synthesis";
803850
const rawOnMerge = (env.AI_ON_MERGE ?? "").trim().toLowerCase() as OnMerge;

src/selfhost/pg-queue.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,27 @@ export function createPgQueue(
253253
return revived;
254254
}
255255

256+
/** Wraps reviveDeadLetterJobs() for the setInterval callback below, which has no rejection handler of its
257+
* own -- a transient pool/driver/metric failure here would otherwise surface as an unhandled promise
258+
* rejection and can terminate the process (fatal when SENTRY_DSN is unset, since server.ts only installs
259+
* the handler when Sentry is configured), exactly the failure mode pump()'s own try/catch above guards
260+
* against for the main poll loop. A failed revive tick just waits for the next interval, same as a failed
261+
* poll tick waits for the next poll. */
262+
async function reviveDeadLetterJobsSafely(): Promise<void> {
263+
try {
264+
await reviveDeadLetterJobs();
265+
} catch (error) {
266+
console.error(
267+
JSON.stringify({
268+
level: "error",
269+
event: "selfhost_queue_dead_letter_revive_crashed",
270+
error: errorMessageWithCause(error),
271+
}),
272+
);
273+
captureError(error, { kind: "queue_dead_letter_revive_crashed" });
274+
}
275+
}
276+
256277
async function spreadDueJobsOnStartup(): Promise<number> {
257278
const now = Date.now();
258279
const res = await pool.query(
@@ -666,7 +687,7 @@ export function createPgQueue(
666687
// Separate, much slower interval than the poll tick above -- reviving a dead job every second would
667688
// recreate the retry storm this feature exists to bound. The interval itself is the cooldown between
668689
// auto-retry rounds for any one job.
669-
deadLetterReviveTimer = setInterval(() => void reviveDeadLetterJobs(), queueDeadLetterReviveIntervalMs());
690+
deadLetterReviveTimer = setInterval(() => void reviveDeadLetterJobsSafely(), queueDeadLetterReviveIntervalMs());
670691
},
671692
async stop() {
672693
running = false;

src/selfhost/sqlite-queue.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,26 @@ export function createSqliteQueue(
187187
return revived;
188188
}
189189

190+
/** Wraps reviveDeadLetterJobs() for the setInterval callback below, which has no error handler of its own --
191+
* a transient driver/metric failure here would otherwise surface as an uncaught exception and can terminate
192+
* the process (fatal when SENTRY_DSN is unset, since server.ts only installs the handler when Sentry is
193+
* configured), exactly the failure mode pump()'s own try/catch above guards against for the main poll loop.
194+
* A failed revive tick just waits for the next interval, same as a failed poll tick waits for the next poll. */
195+
function reviveDeadLetterJobsSafely(): void {
196+
try {
197+
reviveDeadLetterJobs();
198+
} catch (error) {
199+
console.error(
200+
JSON.stringify({
201+
level: "error",
202+
event: "selfhost_queue_dead_letter_revive_crashed",
203+
error: errorMessageWithCause(error),
204+
}),
205+
);
206+
captureError(error, { kind: "queue_dead_letter_revive_crashed" });
207+
}
208+
}
209+
190210
function enqueue(message: JobMessage, delaySeconds: number): void {
191211
const now = Date.now();
192212
const payload = JSON.stringify(message);
@@ -576,7 +596,7 @@ export function createSqliteQueue(
576596
// Separate, much slower interval than the poll tick above -- reviving a dead job every second would
577597
// recreate the retry storm this feature exists to bound. The interval itself is the cooldown between
578598
// auto-retry rounds for any one job.
579-
deadLetterReviveTimer = setInterval(reviveDeadLetterJobs, queueDeadLetterReviveIntervalMs());
599+
deadLetterReviveTimer = setInterval(reviveDeadLetterJobsSafely, queueDeadLetterReviveIntervalMs());
580600
},
581601
async stop() {
582602
running = false;

src/services/ai-review.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { sanitizePublicComment } from "../queue-intelligence";
2525
import { defangReviewInput } from "../review/safety";
2626
import { convergedFeatureActive } from "../review/feature-activation";
2727
import { labelSelfHostReviewerModels, labelSelfHostReviewerNames, resolveConfiguredProviderNames } from "../selfhost/ai-config";
28+
import { incr } from "../selfhost/metrics";
2829
import { errorMessage } from "../utils/json";
2930
import type { ReviewProfile } from "../signals/focus-manifest";
3031

@@ -1228,6 +1229,11 @@ export async function runGittensoryAiReview(
12281229
reviewDiagnostics.some((diagnostic) => diagnostic.status === "unparseable_output"))
12291230
)
12301231
inconclusive = true;
1232+
// Observability (#2540): the single canonical point where `inconclusive` reaches its final value for this
1233+
// review call -- increment exactly once here, never at the downstream consumers in queue/processors.ts that
1234+
// push an `ai_review_inconclusive` advisory finding off this same already-computed result (incrementing there
1235+
// too would double/triple-count one review).
1236+
if (inconclusive) incr("gittensory_ai_review_inconclusive_total", { mode: input.mode });
12311237
const advisoryNotes =
12321238
reviewsForNotes.length > 0
12331239
? (composeAdvisoryNotes(reviewsForNotes) ?? composeFallbackAdvisoryNotes(fallbackNotes))

test/unit/ai-review.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
type GittensoryAiReviewInput,
77
} from "../../src/services/ai-review";
88
import { createTestEnv } from "../helpers/d1";
9+
import { renderMetrics, resetMetrics } from "../../src/selfhost/metrics";
910

1011
const {
1112
parseModelReview,
@@ -87,6 +88,7 @@ const baseInput: GittensoryAiReviewInput = {
8788

8889
afterEach(() => {
8990
vi.unstubAllGlobals();
91+
resetMetrics();
9092
});
9193

9294
describe("runGittensoryAiReview gating", () => {
@@ -474,6 +476,8 @@ describe("runGittensoryAiReview block mode (consensus)", () => {
474476
expect(result.consensusDefect).toBeNull();
475477
expect(result.inconclusive).toBe(true); // FAIL-CLOSED: a missing second opinion holds the PR, never passes it
476478
expect(result.advisoryNotes).not.toBeNull(); // notes still come from the one parseable opinion
479+
// Observability (#2540): the single canonical increment fires once for this inconclusive review.
480+
expect(await renderMetrics()).toContain('gittensory_ai_review_inconclusive_total{mode="block"} 1');
477481
});
478482

479483
it("a clean dual review is NOT inconclusive (both models parsed, neither blocks → passes)", async () => {
@@ -486,6 +490,8 @@ describe("runGittensoryAiReview block mode (consensus)", () => {
486490
});
487491
expect(result.status === "ok" && result.consensusDefect).toBeNull();
488492
expect(result.status === "ok" && result.inconclusive).toBe(false);
493+
// A non-inconclusive review must NOT increment the inconclusive counter.
494+
expect(await renderMetrics()).not.toContain("gittensory_ai_review_inconclusive_total");
489495
});
490496

491497
it("block mode with BYOK: provider writes the advisory, the free Workers-AI pair drives consensus", async () => {

0 commit comments

Comments
 (0)