Skip to content

Add backup AI provider / failover (off by default)#78

Merged
mk7luke merged 10 commits into
mainfrom
feat/backup-ai-failover
Jul 10, 2026
Merged

Add backup AI provider / failover (off by default)#78
mk7luke merged 10 commits into
mainfrom
feat/backup-ai-failover

Conversation

@mk7luke

@mk7luke mk7luke commented Jul 9, 2026

Copy link
Copy Markdown
Owner

What & why

When the primary review model fails transiently or hangs, DiffSentry now falls over to a configured backup AI provider instead of dead-lettering the review. Motivated by PR #77, where grok-4.5 (OpenAI-compatible) intermittently stalled the large review call past the 60s timeout and all retries hit it, so no review was posted.

Off by default — unset BACKUP_AI_PROVIDER ⇒ behavior is byte-for-byte unchanged (plain primary provider, full AI_REQUEST_TIMEOUT_MS).

Complementary to the operational mitigation (raising AI_REQUEST_TIMEOUT_MS / scoping retries), not a replacement.

Design (agreed in brainstorm — see spec)

  • Trigger: fail over only on transient errors (AiTimeoutError, 5xx, 429, network). Never on 4xx (401/403/400) — a bad primary key surfaces loudly instead of being masked by backup traffic.
  • Short primary deadline: when a backup is configured the primary gets PRIMARY_AI_TIMEOUT_MS (default 20s, clamped ≤ AI_REQUEST_TIMEOUT_MS) so a slow-hang switches fast; the backup gets the full 60s.
  • Scope: all five AIProvider methods, per-call, sequential (primary awaited to rejection before backup) — which also makes double-posting impossible.
  • Backup role: peer-quality model, so existing parse + finding-verification hold; a backup failure falls through to today's retry/dead-letter path (no regression).
  • Config: BACKUP_AI_PROVIDER selects a type and reuses that provider's existing env, with optional BACKUP_* overrides.
  • Circuit breaker: best-effort in-memory guard — after ~N consecutive failed primary calls it routes straight to the backup for a cooldown, so a plainly-down primary doesn't cost every review its stall.
  • Transparency: a subtle footnote on the posted review body when the backup served it; failover + breaker transitions are logged; per-provider cost is attributed via existing recordAiUsage.

Spec: docs/superpowers/specs/2026-07-09-backup-ai-provider-failover-design.md
Plan: docs/superpowers/plans/2026-07-09-backup-ai-provider-failover.md

Changes

  • src/ai/transient.ts — extracted isTransientError (shared by the job runner and the new wrapper; jobs.ts re-exports it).
  • src/ai/failover.tsFailoverProvider implements AIProvider + circuit breaker (injectable clock for deterministic tests).
  • src/ai/provider-factory.tsbuildProvider(spec); unifies the anthropic/openai/openai-compatible construction switch.
  • src/config.ts / src/types.ts — backup env parsing/validation (fail-fast at boot), primaryAiTimeoutMs + breaker knobs, ReviewResult.servedBy.
  • src/reviewer.ts — build primary via the factory, wrap in FailoverProvider when a backup is configured, append the backup footnote.
  • .env.example / README.md — operator docs.

Testing

  • npx tsc --noEmit clean · npm test 328/328 · npm run lint clean · npm run build clean.
  • New unit tests: tests/unit/transient.test.ts, tests/unit/failover.test.ts (8 cases: success, transient failover, no-failover on 401, both-fail, breaker open/half-open/reset, all-five-method delegation), tests/unit/config-backup.test.ts (9 cases incl. reuse/override + validation branches).
  • No unit harness exists around reviewer.reviewPR; the failover wrapper is tested in isolation, and the unchanged 326-test baseline staying green proves the no-backup path is untouched.

Non-blocking follow-ups (from the final review)

  • Breaker counts primary calls, not reviews; a succeeding walkthrough call can reset the counter while the big review call fails (the exact Post an explanatory comment when a PR has only skippable files #77 mode). Failover per-call still works every time — only the breaker's cost-saving is best-effort. Docs now say so; a per-operation breaker could be a future refinement.
  • testAiProvider() / aiTarget() report the primary only; a probe could be silently backup-served.
  • No boot warning if the backup resolves byte-identical to the primary.

🤖 Generated with Claude Code


Summary

Added an opt-in backup AI provider failover so transient primary failures fall over to a configured secondary model instead of dead-lettering the review. Extracted shared provider construction, wired backup config with circuit-breaker knobs, and covered the behavior with unit tests while keeping the feature off by default.

Changes

File Changes
src/ai/provider-factory.ts Centralized anthropic/openai/openai-compatible construction into buildProvider(spec) so primary and backup share one factory path.
src/config.ts Parsed and validated BACKUP_* env vars with reuse-with-override resolution, primary timeout clamping, and circuit-breaker knobs.
tests/unit/config-backup.test.ts Covered backup config defaults, reuse/override resolution, validation failures, timeout clamping, and off-by-default knobs.
tests/unit/failover.test.ts Unit-tested success path, transient failover, no-failover on 401, both-fail, breaker open/half-open/reset, and all-method delegation.

mk7luke and others added 8 commits July 9, 2026 14:59
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…coverage

Soften circuit-breaker wording (it counts per-call, not per-review), add
PRIMARY_AI_TIMEOUT_MS guidance, drop an unused test import, and cover the
openai-compatible backup validation branch in config-backup.test.ts. No
runtime behavior changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@diffsentry

diffsentry Bot commented Jul 9, 2026

Copy link
Copy Markdown

DiffSentry has completed the review — Changes requested

@diffsentry

diffsentry Bot commented Jul 9, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Added an opt-in backup AI provider failover so transient primary failures fall over to a configured secondary model instead of dead-lettering the review. Extracted shared provider construction, wired backup config with circuit-breaker knobs, and covered the behavior with unit tests while keeping the feature off by default.

Changes

Cohort / File(s) Summary
Provider Factory
src/ai/provider-factory.ts
Introduced a shared ProviderSpec and buildProvider factory so primary and backup construction use one switch with fail-fast credential checks.
Backup Config Wiring
src/config.ts
Added BACKUP_* env parsing with reuse-with-override resolution, short primary timeout clamping, and circuit-breaker knobs that only apply when failover is enabled.
Failover Unit Tests
tests/unit/config-backup.test.ts, tests/unit/failover.test.ts
Added coverage for backup config validation and FailoverProvider behavior including transient failover, auth non-failover, and circuit-breaker transitions.

Sequence Diagram(s)

sequenceDiagram
    participant Reviewer
    participant FailoverProvider
    participant Primary
    participant Backup
    Reviewer->>FailoverProvider: review(ctx)
    FailoverProvider->>Primary: review(ctx) with short timeout
    Primary-->>FailoverProvider: transient error
    FailoverProvider->>Backup: review(ctx) with full timeout
    Backup-->>FailoverProvider: ReviewResult
    FailoverProvider-->>Reviewer: ReviewResult servedBy backup
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~30 minutes

Suggested Labels

enhancement

Risk Assessment

Score: 25/100 — 🟡 Moderate

Factor Weight Detail
Major findings +20 5 major issues flagged
Moderate change +5 471 lines changed across 4 files

Test Coverage Signal

🟢 153 prod / 318 test lines added.

Source files changed Test files changed Source lines + Test lines +
2 2 153 318

🧭 Description Drift

  • ℹ️ provider-factory label/cost-attribution caveat not mentioned in description
    • buildProvider in src/ai/provider-factory.ts documents that only OpenAICompatibleProvider accepts a label, so a same-provider and same-model anthropic/openai backup collapses into the primary in recordAiUsage cost/logs. The PR description's transparency section only says cost is attributed via recordAiUsage and does not surface this limitation.

Compares PR description claims to the actual diff. Update the description (or the code) so they tell the same story.

✍️ Commit Message Coach

1 of 10 commit messages could be stronger.

Commit Subject Issues
c1da271 🟡 Apply final-review fixes for backup AI failover: doc accuracy + test coverage Subject is 77 characters — keep under 72 to avoid truncation.

Tip: imperative mood (Add user lookup), under 72 chars, no trailing period. Conventional Commits like feat: / fix: are also fine.

📊 Confidence breakdown

10 findings — 🟢 5 high · 🟡 5 medium · 🔴 0 low. Treat the medium / low ones as hypotheses to verify.

Linked Issues

  • #77 — Post an explanatory comment when a PR has only skippable files 🔴
🚥 Pre-merge checks | ✅ 5 | ❌ 0
✅ Passed checks (5 passed)
Check name Status Explanation
PR Title ✅ Passed Title meets all requirements: starts with imperative verb "Add", 51 chars (< 72), no trailing period, no Conventional Commits prefix.
PR Description ✅ Passed Description has a clear WHAT/WHY sentence (failover on transient primary failures instead of dead-lettering) and links related PR #77.
Schema bump ✅ Passed src/storage/db.ts was not modified in this PR; no schema migration check required.
Provider parity ✅ Passed src/ai/anthropic.ts was not modified in this PR; no request/response contract changes to propagate to openai.ts or openai-compatible.ts. Failover is a wrapper + factory only.
Pattern test coverage ✅ Passed Neither src/safety-scanner.ts nor src/pattern-checks.ts were modified in this PR; no new rules to cover with e2e scenarios.

✏️ Tip: You can configure your own custom pre-merge checks in your .diffsentry.yaml.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
📝 Generate docstrings (beta)
  • Push docstring commit to this branch
🧹 Simplify (beta)
  • Push simplification commit to this branch
🪄 Autofix unresolved comments (beta)
  • Push autofix commit to this branch

Comment @diffsentry help to get the list of available commands and usage tips.

diffsentry[bot]
diffsentry Bot previously requested changes Jul 9, 2026

@diffsentry diffsentry Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Solid, well-scoped failover design: transient-only trigger, sequential primary→backup, short primary deadline only when a backup exists, and thorough unit coverage for the wrapper and config. One real correctness gap remains in the circuit-breaker half-open path—a failed probe does not re-arm the open window—so REQUEST_CHANGES until that is fixed. A few maintainability/test nits around non-positive PRIMARY_AI_TIMEOUT_MS and breaker half-open re-open coverage should also be cleaned up.

🔎 Issues not tied to a specific line (1)

These findings concern the change as a whole (for example, the diff versus the PR description) and can't be attached to a single changed line.

⚠️ Potential issue | 🟠 Major

Description claims .env.example was updated, but it is not in the diff

🤔 Medium confidence — verify against intent before acting.

The Changes section lists .env.example / README.md as operator docs, and the new README backup section tells operators to “See .env.example for the full list of BACKUP_ANTHROPIC_* / BACKUP_OPENAI_* / BACKUP_LOCAL_AI_* override vars.” No .env.example file appears among the changed files, so those override variables are not actually documented where the description and README claim.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

Reconcile the PR description with the actual diff. Description claims `.env.example` was updated, but it is not in the diff The Changes section lists `.env.example` / `README.md` as operator docs, and the new README backup section tells operators to “See `.env.example` for the full list of `BACKUP_ANTHROPIC_*` / `BACKUP_OPENAI_*` / `BACKUP_LOCAL_AI_*` override vars.” No `.env.example` file appears among the changed files, so those override variables are not actually documented where the description and README claim.
🧹 Nitpick comments (3)
src/config.ts (1)

Line 140: PRIMARY_AI_TIMEOUT_MS accepts non-positive values.

⚠️ Potential issue | 🟡 Minor

PRIMARY_AI_TIMEOUT_MS accepts non-positive values.

primaryAiTimeoutMs only clamps the upper bound to aiRequestTimeoutMs. A mis-set PRIMARY_AI_TIMEOUT_MS=0 or negative value is accepted and becomes the primary provider deadline when a backup is configured, which effectively forces every primary call to time out immediately and fail over.

Mirror the breaker-threshold style guard and fall back to 20_000 (then clamp) for non-positive parses so a bad env value cannot silently disable the primary.

🔧 Proposed fix
const parsedPrimaryTimeout = parseInt(process.env.PRIMARY_AI_TIMEOUT_MS || "", 10);
  let primaryAiTimeoutMs =
    Number.isFinite(parsedPrimaryTimeout) && parsedPrimaryTimeout > 0
      ? parsedPrimaryTimeout
      : 20_000;
  if (aiRequestTimeoutMs > 0 && primaryAiTimeoutMs > aiRequestTimeoutMs) {
    primaryAiTimeoutMs = aiRequestTimeoutMs;
  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In src/config.ts around line 140, reject non-positive PRIMARY_AI_TIMEOUT_MS values. Require Number.isFinite(parsedPrimaryTimeout) && parsedPrimaryTimeout > 0 before using the parsed value; otherwise default to 20_000, then keep the existing clamp against aiRequestTimeoutMs. Add a config-backup unit test covering PRIMARY_AI_TIMEOUT_MS=0 or a negative value falling back to 20000.
♻️ Suggested fix
const parsedPrimaryTimeout = parseInt(process.env.PRIMARY_AI_TIMEOUT_MS || "", 10);
  let primaryAiTimeoutMs =
    Number.isFinite(parsedPrimaryTimeout) && parsedPrimaryTimeout > 0
      ? parsedPrimaryTimeout
      : 20_000;
  if (aiRequestTimeoutMs > 0 && primaryAiTimeoutMs > aiRequestTimeoutMs) {
    primaryAiTimeoutMs = aiRequestTimeoutMs;
  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In src/config.ts around line 140, reject non-positive PRIMARY_AI_TIMEOUT_MS values. Require Number.isFinite(parsedPrimaryTimeout) && parsedPrimaryTimeout > 0 before using the parsed value; otherwise default to 20_000, then keep the existing clamp against aiRequestTimeoutMs. Add a config-backup unit test covering PRIMARY_AI_TIMEOUT_MS=0 or a negative value falling back to 20000.
tests/unit/failover.test.ts (1)

Line 110: Half-open re-open path is untested.

🛠️ Refactor suggestion | 🟡 Minor

Half-open re-open path is untested.

The suite covers open-after-threshold and half-open success, but never asserts the half-open failure path. Given the current recordPrimaryFailure bug, a test that advances the clock past cooldown, fails the primary probe, and expects the next call to skip primary would have caught the re-open gap immediately.

Add that case next to the existing half-open success test so the breaker contract stays locked down.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In tests/unit/failover.test.ts around the half-open success test near line 110, add a sibling case: trip the breaker with three transient primary failures, advance the injectable clock past cooldown, make the next primary call fail transiently, then assert that one more call still skips primary (call count unchanged) until another full cooldown elapses.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In tests/unit/failover.test.ts around the half-open success test near line 110, add a sibling case: trip the breaker with three transient primary failures, advance the injectable clock past cooldown, make the next primary call fail transiently, then assert that one more call still skips primary (call count unchanged) until another full cooldown elapses.
src/ai/provider-factory.ts (1)

Line 28: Factory relies on non-null assertions for required credentials.

🛠️ Refactor suggestion | 🟢 Trivial

Factory relies on non-null assertions for required credentials.

🤔 Medium confidence — verify against intent before acting.

buildProvider uses spec.anthropicApiKey!, spec.localAiBaseUrl!, and spec.openaiApiKey!. Boot-time config validation currently guarantees those for the configured provider, but the factory is now a shared construction path for both primary and backup. A future caller that builds a partial ProviderSpec will get a late runtime failure inside the provider constructor instead of a clear factory error.

Prefer an explicit throw with the missing field named, matching the fail-fast style in loadConfig.

🔧 Proposed fix
export function buildProvider(spec: ProviderSpec): AIProvider {
  if (spec.provider === "anthropic") {
    if (!spec.anthropicApiKey) {
      throw new Error("buildProvider(anthropic) requires anthropicApiKey");
    }
    return new AnthropicProvider(spec.anthropicApiKey, spec.anthropicModel, spec.anthropicBaseUrl, spec.timeoutMs);
  }
  if (spec.provider === "openai-compatible") {
    if (!spec.localAiBaseUrl) {
      throw new Error("buildProvider(openai-compatible) requires localAiBaseUrl");
    }
    return new OpenAICompatibleProvider({
      baseURL: spec.localAiBaseUrl,
      model: spec.localAiModel,
      apiKey: spec.localAiApiKey,
      jsonMode: spec.localAiJsonMode,
      timeoutMs: spec.timeoutMs,
      providerLabel: spec.label,
    });
  }
  if (!spec.openaiApiKey) {
    throw new Error("buildProvider(openai) requires openaiApiKey");
  }
  return new OpenAIProvider(spec.openaiApiKey, spec.openaiModel, spec.openaiBaseUrl, spec.timeoutMs);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In src/ai/provider-factory.ts around line 28, replace the non-null assertions on anthropicApiKey, localAiBaseUrl, and openaiApiKey with explicit missing-field checks that throw descriptive Error messages before constructing each provider.
♻️ Suggested fix
export function buildProvider(spec: ProviderSpec): AIProvider {
  if (spec.provider === "anthropic") {
    if (!spec.anthropicApiKey) {
      throw new Error("buildProvider(anthropic) requires anthropicApiKey");
    }
    return new AnthropicProvider(spec.anthropicApiKey, spec.anthropicModel, spec.anthropicBaseUrl, spec.timeoutMs);
  }
  if (spec.provider === "openai-compatible") {
    if (!spec.localAiBaseUrl) {
      throw new Error("buildProvider(openai-compatible) requires localAiBaseUrl");
    }
    return new OpenAICompatibleProvider({
      baseURL: spec.localAiBaseUrl,
      model: spec.localAiModel,
      apiKey: spec.localAiApiKey,
      jsonMode: spec.localAiJsonMode,
      timeoutMs: spec.timeoutMs,
      providerLabel: spec.label,
    });
  }
  if (!spec.openaiApiKey) {
    throw new Error("buildProvider(openai) requires openaiApiKey");
  }
  return new OpenAIProvider(spec.openaiApiKey, spec.openaiModel, spec.openaiBaseUrl, spec.timeoutMs);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In src/ai/provider-factory.ts around line 28, replace the non-null assertions on anthropicApiKey, localAiBaseUrl, and openaiApiKey with explicit missing-field checks that throw descriptive Error messages before constructing each provider.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

In `src/ai/failover.ts`:
- Line 75: In src/ai/failover.ts around line 75, fix recordPrimaryFailure so a failed half-open probe re-opens the breaker. When consecutiveFailures >= threshold, always set openedAt = this.now() even if openedAt was already non-null; optionally log a distinct warn for re-open vs first open. Then add a unit test in tests/unit/failover.test.ts that opens the breaker, advances the clock past cooldown, fails the primary probe, and asserts the next call still skips the primary until another full cooldown elapses.

In `src/config.ts`:
- Line 140: In src/config.ts around line 140, reject non-positive PRIMARY_AI_TIMEOUT_MS values. Require Number.isFinite(parsedPrimaryTimeout) && parsedPrimaryTimeout > 0 before using the parsed value; otherwise default to 20_000, then keep the existing clamp against aiRequestTimeoutMs. Add a config-backup unit test covering PRIMARY_AI_TIMEOUT_MS=0 or a negative value falling back to 20000.

In `tests/unit/failover.test.ts`:
- Line 110: In tests/unit/failover.test.ts around the half-open success test near line 110, add a sibling case: trip the breaker with three transient primary failures, advance the injectable clock past cooldown, make the next primary call fail transiently, then assert that one more call still skips primary (call count unchanged) until another full cooldown elapses.

In `src/ai/provider-factory.ts`:
- Line 28: In src/ai/provider-factory.ts around line 28, replace the non-null assertions on anthropicApiKey, localAiBaseUrl, and openaiApiKey with explicit missing-field checks that throw descriptive Error messages before constructing each provider.
🪄 Autofix (Beta)

Fix all unresolved DiffSentry comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: .diffsentry.yaml

Review profile: ASSERTIVE

Run ID: e5679bd6-183a-47fc-97da-d2736d3992a5

📥 Commits

Reviewing files at c1da271 (base SHA unavailable).

⛔ Files ignored due to path filters (1)
  • .env.example is excluded by !.env.example
📒 Files selected for processing (13)
  • README.md
  • docs/superpowers/plans/2026-07-09-backup-ai-provider-failover.md
  • docs/superpowers/specs/2026-07-09-backup-ai-provider-failover-design.md
  • src/ai/failover.ts
  • src/ai/provider-factory.ts
  • src/ai/transient.ts
  • src/config.ts
  • src/realtime/jobs.ts
  • src/reviewer.ts
  • src/types.ts
  • tests/unit/config-backup.test.ts
  • tests/unit/failover.test.ts
  • tests/unit/transient.test.ts
✂️ Files with diffs truncated to fit the review size budget (1)
  • docs/superpowers/plans/2026-07-09-backup-ai-provider-failover.md

Comment thread src/ai/failover.ts
Comment thread src/config.ts Outdated
Comment thread tests/unit/failover.test.ts
Comment thread src/ai/provider-factory.ts Outdated
@diffsentry

diffsentry Bot commented Jul 9, 2026

Copy link
Copy Markdown

📌 Status — last updated 9297926

🔴 Changes requested

Risk score 25/100 (Moderate) █▁▆
Unresolved threads 5
Failing checks 0
Pending checks 1
Files reviewed 4 (9 skipped)
Updated 2026-07-09 22:51Z

Live-updated by DiffSentry on every push. Use @diffsentry ship for a verdict, @diffsentry timeline for full history.

- Guard PRIMARY_AI_TIMEOUT_MS against non-positive values (would disable the
  primary deadline and defeat failover); fall back to 20000. (Minor)
- buildProvider: throw named errors for missing required credentials instead of
  non-null assertions, since it's now a shared primary/backup construction path. (Trivial)
- Add a half-open re-open coverage test for the circuit breaker. (Minor)

Refuted: the half-open re-open 'Major' finding — recordPrimaryFailure already
re-arms openedAt unconditionally; wasOpen gates only the log line. New test proves it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@diffsentry diffsentry Bot dismissed their stale review July 9, 2026 22:46

Superseded by new review.

diffsentry[bot]
diffsentry Bot previously requested changes Jul 9, 2026

@diffsentry diffsentry Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Solid, well-scoped opt-in failover design: transient-only sequential failover, short primary deadline, circuit breaker with injectable clock, reuse-with-override config, and strong unit coverage (328/328). The factory extraction and shared isTransientError keep the no-backup path clean. A few wiring/config edge cases and the acknowledged call-vs-review breaker limitation deserve attention before merge.

🔎 Issues not tied to a specific line (5)

These findings concern the change as a whole (for example, the diff versus the PR description) and can't be attached to a single changed line.

⚠️ Potential issue | 🟠 Major

Diff is incomplete relative to the PR description — core failover wiring is missing from the reviewable patch.

The description and DiffSentry summary claim changes to src/ai/failover.ts, src/ai/transient.ts, src/realtime/jobs.ts, src/reviewer.ts, src/types.ts, .env.example, README.md, and the design/plan docs, plus tests/unit/transient.test.ts. Only provider-factory.ts, the config parse block, and two test files appear in the provided diff.

Without FailoverProvider, isTransientError, the reviewer.ts wrap (short primary timeout, footnote, servedBy), and the Config/ReviewResult type extensions, this cannot be fully verified as matching the design (transient-only, sequential, breaker open/half-open, footnote). Please ensure those files are included in the PR/diff under review before treating the feature as merge-ready.

What is present (factory, config parse, failover/config unit tests) looks coherent with the stated design.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

Ensure the PR includes all files claimed in the description: src/ai/failover.ts, src/ai/transient.ts, src/realtime/jobs.ts (re-export), src/reviewer.ts (FailoverProvider wrap + footnote), src/types.ts (Config backup fields + ReviewResult.servedBy), .env.example, README.md, docs/superpowers specs/plans, and tests/unit/transient.test.ts. Re-open or amend the PR so the full wiring is reviewable.

📝 Documentation | 🟢 Trivial

Acknowledge call-level breaker vs review-call failure mode in operator docs (non-blocking).

The PR already calls out that the breaker counts primary calls, so a succeeding walkthrough can reset the counter while the large review call keeps timing out (the #77 pattern). Failover still happens every time; only the cost-saving skip is best-effort.

Keep that caveat visible in README / design doc (as noted in non-blocking follow-ups). A future per-operation breaker would be the right fix if this shows up in production metrics; no code change required for this PR if docs already say so.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In README.md and/or docs/superpowers/specs/2026-07-09-backup-ai-provider-failover-design.md, confirm there is an explicit note that the circuit breaker is per-call (not per-review) and that a succeeding short call can reset consecutive failures while the large review call still fails over every time. Add that sentence if missing.

🛠️ Refactor suggestion | 🟢 Trivial

Consider a boot-time warning when backup resolves identical to primary.

🤔 Medium confidence — verify against intent before acting.

Listed as a non-blocking follow-up: if BACKUP_AI_PROVIDER reuses the same provider type + endpoint + model + key as the primary, failover cannot help and will only double latency on transient errors. A single console.warn / logger warning at config load (or when building the FailoverProvider) would catch the common misconfig of setting BACKUP_AI_PROVIDER equal to AI_PROVIDER without overrides.

Optional for this PR; worth filing if not done here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

Optionally in src/config.ts after resolving backup fields (or in src/reviewer.ts when constructing FailoverProvider), detect when the resolved backup provider type, model, base URL, and key match the primary and emit a one-line warning that failover will not diversify the backend. Do not fail boot.

⚠️ Potential issue | 🟠 Major

.env.example claimed as updated but not present in the PR changes

🤔 Medium confidence — verify against intent before acting.

The Changes section says .env.example / README.md carry the operator docs, but the actual changed-files set includes README.md (plus the design/plan docs) and has no .env.example modification.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

Reconcile the PR description with the actual diff. .env.example claimed as updated but not present in the PR changes The Changes section says `.env.example` / `README.md` carry the operator docs, but the actual changed-files set includes `README.md` (plus the design/plan docs) and has no `.env.example` modification.

⚠️ Potential issue | 🟠 Major

Claimed test totals contradict the size of the new suites

🤔 Medium confidence — verify against intent before acting.

The description reports npm test 328/328 and an “unchanged 326-test baseline,” which implies only ~2 new tests, yet it also lists new suites tests/unit/transient.test.ts, tests/unit/failover.test.ts (many cases), and tests/unit/config-backup.test.ts (many cases) whose combined count far exceeds two tests.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

Reconcile the PR description with the actual diff. Claimed test totals contradict the size of the new suites The description reports `npm test` 328/328 and an “unchanged 326-test baseline,” which implies only ~2 new tests, yet it also lists new suites `tests/unit/transient.test.ts`, `tests/unit/failover.test.ts` (many cases), and `tests/unit/config-backup.test.ts` (many cases) whose combined count far exceeds two tests.
🧹 Nitpick comments (5)
src/config.ts (1)

Line 145: PRIMARY_AI_TIMEOUT_MS and breaker knobs are always parsed even when backup is off.

🛠️ Refactor suggestion | 🟡 Minor

PRIMARY_AI_TIMEOUT_MS and breaker knobs are always parsed even when backup is off.

primaryAiTimeoutMs, backupCircuitThreshold, and backupCircuitCooldownMs are computed and written onto Config regardless of whether BACKUP_AI_PROVIDER is set. When backup is off these knobs are dead weight, and a mis-set PRIMARY_AI_TIMEOUT_MS can still clamp/override expectations for operators who never intended failover.

Gate the entire primary-timeout + breaker block on if (backupRaw) (or if (backupAiProvider)) so the fields stay at their type-level optionality and only resolve when failover is actually enabled. That matches the PR's "off by default / byte-for-byte unchanged" claim more tightly.

🔧 Proposed fix
// Short primary deadline + breaker knobs only matter when a backup is configured.
  let primaryAiTimeoutMs = 20_000;
  let backupCircuitThreshold = 3;
  let backupCircuitCooldownMs = 60_000;
  if (backupAiProvider) {
    const parsedPrimaryTimeout = parseInt(process.env.PRIMARY_AI_TIMEOUT_MS || "", 10);
    primaryAiTimeoutMs =
      Number.isFinite(parsedPrimaryTimeout) && parsedPrimaryTimeout > 0 ? parsedPrimaryTimeout : 20_000;
    if (aiRequestTimeoutMs > 0 && primaryAiTimeoutMs > aiRequestTimeoutMs) {
      primaryAiTimeoutMs = aiRequestTimeoutMs;
    }

    const parsedThreshold = parseInt(process.env.BACKUP_CIRCUIT_THRESHOLD || "", 10);
    backupCircuitThreshold = Number.isFinite(parsedThreshold) && parsedThreshold >= 1 ? parsedThreshold : 3;

    const parsedCooldown = parseInt(process.env.BACKUP_CIRCUIT_COOLDOWN_MS || "", 10);
    backupCircuitCooldownMs = Number.isFinite(parsedCooldown) && parsedCooldown >= 0 ? parsedCooldown : 60_000;
  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In src/config.ts around the PRIMARY_AI_TIMEOUT_MS / BACKUP_CIRCUIT_* parsing block, gate computation of primaryAiTimeoutMs, backupCircuitThreshold, and backupCircuitCooldownMs so they only resolve when backupAiProvider is set. Keep sensible defaults for the Config fields when backup is off, or leave them undefined if types allow; do not let PRIMARY_AI_TIMEOUT_MS affect anything on the no-backup path.
♻️ Suggested fix
  // Short primary deadline + breaker knobs only matter when a backup is configured.
  let primaryAiTimeoutMs = 20_000;
  let backupCircuitThreshold = 3;
  let backupCircuitCooldownMs = 60_000;
  if (backupAiProvider) {
    const parsedPrimaryTimeout = parseInt(process.env.PRIMARY_AI_TIMEOUT_MS || "", 10);
    primaryAiTimeoutMs =
      Number.isFinite(parsedPrimaryTimeout) && parsedPrimaryTimeout > 0 ? parsedPrimaryTimeout : 20_000;
    if (aiRequestTimeoutMs > 0 && primaryAiTimeoutMs > aiRequestTimeoutMs) {
      primaryAiTimeoutMs = aiRequestTimeoutMs;
    }

    const parsedThreshold = parseInt(process.env.BACKUP_CIRCUIT_THRESHOLD || "", 10);
    backupCircuitThreshold = Number.isFinite(parsedThreshold) && parsedThreshold >= 1 ? parsedThreshold : 3;

    const parsedCooldown = parseInt(process.env.BACKUP_CIRCUIT_COOLDOWN_MS || "", 10);
    backupCircuitCooldownMs = Number.isFinite(parsedCooldown) && parsedCooldown >= 0 ? parsedCooldown : 60_000;
  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In src/config.ts around the PRIMARY_AI_TIMEOUT_MS / BACKUP_CIRCUIT_* parsing block, gate computation of primaryAiTimeoutMs, backupCircuitThreshold, and backupCircuitCooldownMs so they only resolve when backupAiProvider is set. Keep sensible defaults for the Config fields when backup is off, or leave them undefined if types allow; do not let PRIMARY_AI_TIMEOUT_MS affect anything on the no-backup path.
src/ai/provider-factory.ts (2)

Line 37: openai-compatible branch does not require a model string at the factory.

⚠️ Potential issue | 🟡 Minor

openai-compatible branch does not require a model string at the factory.

buildProvider validates localAiBaseUrl for openai-compatible but not localAiModel. Config already fails boot when both base URL and model are missing for a backup, and primary loadConfig similarly expects a model, but this factory is documented as the single construction path that should fail fast on a partial spec.

A future caller that builds a primary/backup with an empty model will only fail deep inside the OpenAI-compatible SDK/request. Mirror the base-URL guard and reject an empty/missing localAiModel here.

🔧 Proposed fix
if (spec.provider === "openai-compatible") {
    if (!spec.localAiBaseUrl) {
      throw new Error("buildProvider(openai-compatible) requires localAiBaseUrl");
    }
    if (!spec.localAiModel) {
      throw new Error("buildProvider(openai-compatible) requires localAiModel");
    }
    return new OpenAICompatibleProvider({
      baseURL: spec.localAiBaseUrl,
      model: spec.localAiModel,
      apiKey: spec.localAiApiKey,
      jsonMode: spec.localAiJsonMode,
      timeoutMs: spec.timeoutMs,
      providerLabel: spec.label,
    });
  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In src/ai/provider-factory.ts inside the openai-compatible branch of buildProvider, add a guard that throws when spec.localAiModel is missing or empty, matching the existing localAiBaseUrl check and the config-level validation for BACKUP_AI_PROVIDER=openai-compatible.
♻️ Suggested fix
  if (spec.provider === "openai-compatible") {
    if (!spec.localAiBaseUrl) {
      throw new Error("buildProvider(openai-compatible) requires localAiBaseUrl");
    }
    if (!spec.localAiModel) {
      throw new Error("buildProvider(openai-compatible) requires localAiModel");
    }
    return new OpenAICompatibleProvider({
      baseURL: spec.localAiBaseUrl,
      model: spec.localAiModel,
      apiKey: spec.localAiApiKey,
      jsonMode: spec.localAiJsonMode,
      timeoutMs: spec.timeoutMs,
      providerLabel: spec.label,
    });
  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In src/ai/provider-factory.ts inside the openai-compatible branch of buildProvider, add a guard that throws when spec.localAiModel is missing or empty, matching the existing localAiBaseUrl check and the config-level validation for BACKUP_AI_PROVIDER=openai-compatible.

Line 23: label override is only applied for openai-compatible providers.

🛠️ Refactor suggestion | 🟡 Minor

label override is only applied for openai-compatible providers.

🤔 Medium confidence — verify against intent before acting.

ProviderSpec.label is documented as overriding attribution "when a same-type backup would otherwise collide with the primary", but it is only forwarded as providerLabel into OpenAICompatibleProvider. Anthropic and OpenAI constructors take no label, so a same-type anthropic→anthropic or openai→openai backup will still share cost/log identity with the primary.

Either extend the anthropic/openai providers (or their usage-recording path) to accept an optional label, or document that same-type attribution disambiguation is openai-compatible-only so operators don't expect recordAiUsage to split costs for those pairs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In src/ai/provider-factory.ts and the AnthropicProvider/OpenAIProvider constructors (or their usage-attribution helpers), either plumb ProviderSpec.label through for anthropic/openai the same way openai-compatible gets providerLabel, or update the ProviderSpec.label docs and README to state that same-type cost/log disambiguation currently only works for openai-compatible backups.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In src/ai/provider-factory.ts and the AnthropicProvider/OpenAIProvider constructors (or their usage-attribution helpers), either plumb ProviderSpec.label through for anthropic/openai the same way openai-compatible gets providerLabel, or update the ProviderSpec.label docs and README to state that same-type cost/log disambiguation currently only works for openai-compatible backups.
tests/unit/failover.test.ts (1)

Line 78: Breaker open-path test does not assert servedBy on direct-to-backup calls.

🛠️ Refactor suggestion | 🟢 Trivial

Breaker open-path test does not assert servedBy on direct-to-backup calls.

When the breaker is open the 4th review skips primary entirely and should still tag servedBy: "backup" so the review footnote and any metrics stay accurate. The test only checks call counts.

Add an assertion on the open-path result's servedBy (and ideally one after the half-open re-open path) so a regression that drops the tag on the skip-primary branch is caught.

🔧 Proposed fix
// 4th call: breaker open → primary skipped entirely.
    const openRes = await p.review(ctx());
    expect(primaryReview).toHaveBeenCalledTimes(3);
    expect(backupReview).toHaveBeenCalledTimes(4);
    expect(openRes.servedBy).toBe("backup");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In tests/unit/failover.test.ts in the breaker-open test (and the re-open-after-failed-probe test), capture the result of the call that skips primary and assert res.servedBy === "backup" so direct-to-backup routing still tags the review result.
♻️ Suggested fix
    // 4th call: breaker open → primary skipped entirely.
    const openRes = await p.review(ctx());
    expect(primaryReview).toHaveBeenCalledTimes(3);
    expect(backupReview).toHaveBeenCalledTimes(4);
    expect(openRes.servedBy).toBe("backup");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In tests/unit/failover.test.ts in the breaker-open test (and the re-open-after-failed-probe test), capture the result of the call that skips primary and assert res.servedBy === "backup" so direct-to-backup routing still tags the review result.
tests/unit/config-backup.test.ts (1)

Line 60: Missing coverage for breaker threshold/cooldown parse edge cases.

🧹 Nitpick | 🟢 Trivial

Missing coverage for breaker threshold/cooldown parse edge cases.

🤔 Medium confidence — verify against intent before acting.

The suite covers defaults and primary-timeout clamp/non-positive fallback, but not:

  • BACKUP_CIRCUIT_THRESHOLD=0 / negative / non-numeric → should fall back to 3
  • BACKUP_CIRCUIT_COOLDOWN_MS non-numeric → fall back to 60000 (0 is intentionally valid)
  • primary timeout when AI_REQUEST_TIMEOUT_MS is unset/0 (no clamp path)

Those branches are small but operator-facing; one compact table-driven case would lock the fail-soft parsing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In tests/unit/config-backup.test.ts, add cases for invalid BACKUP_CIRCUIT_THRESHOLD (0/negative/NaN → 3), invalid BACKUP_CIRCUIT_COOLDOWN_MS (non-numeric → 60000), and confirm cooldown 0 is accepted. Optionally assert primaryAiTimeoutMs is not clamped when AI_REQUEST_TIMEOUT_MS is 0/unset.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In tests/unit/config-backup.test.ts, add cases for invalid BACKUP_CIRCUIT_THRESHOLD (0/negative/NaN → 3), invalid BACKUP_CIRCUIT_COOLDOWN_MS (non-numeric → 60000), and confirm cooldown 0 is accepted. Optionally assert primaryAiTimeoutMs is not clamped when AI_REQUEST_TIMEOUT_MS is 0/unset.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

In `src/config.ts`:
- Line 145: In src/config.ts around the PRIMARY_AI_TIMEOUT_MS / BACKUP_CIRCUIT_* parsing block, gate computation of primaryAiTimeoutMs, backupCircuitThreshold, and backupCircuitCooldownMs so they only resolve when backupAiProvider is set. Keep sensible defaults for the Config fields when backup is off, or leave them undefined if types allow; do not let PRIMARY_AI_TIMEOUT_MS affect anything on the no-backup path.

In `src/ai/provider-factory.ts`:
- Line 37: In src/ai/provider-factory.ts inside the openai-compatible branch of buildProvider, add a guard that throws when spec.localAiModel is missing or empty, matching the existing localAiBaseUrl check and the config-level validation for BACKUP_AI_PROVIDER=openai-compatible.
- Line 23: In src/ai/provider-factory.ts and the AnthropicProvider/OpenAIProvider constructors (or their usage-attribution helpers), either plumb ProviderSpec.label through for anthropic/openai the same way openai-compatible gets providerLabel, or update the ProviderSpec.label docs and README to state that same-type cost/log disambiguation currently only works for openai-compatible backups.

In `tests/unit/failover.test.ts`:
- Line 78: In tests/unit/failover.test.ts in the breaker-open test (and the re-open-after-failed-probe test), capture the result of the call that skips primary and assert res.servedBy === "backup" so direct-to-backup routing still tags the review result.

In `tests/unit/config-backup.test.ts`:
- Line 60: In tests/unit/config-backup.test.ts, add cases for invalid BACKUP_CIRCUIT_THRESHOLD (0/negative/NaN → 3), invalid BACKUP_CIRCUIT_COOLDOWN_MS (non-numeric → 60000), and confirm cooldown 0 is accepted. Optionally assert primaryAiTimeoutMs is not clamped when AI_REQUEST_TIMEOUT_MS is 0/unset.
🪄 Autofix (Beta)

Fix all unresolved DiffSentry comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: .diffsentry.yaml

Review profile: ASSERTIVE

Run ID: 8357ba25-4cf5-4eb7-9adc-2ac63110308e

📥 Commits

Reviewing files that changed from c1da271 to be6e847. Previously-reviewed commits are not re-reviewed.

⛔ Files ignored due to path filters (1)
  • .env.example is excluded by !.env.example
📒 Files selected for processing (4)
  • src/ai/provider-factory.ts
  • src/config.ts
  • tests/unit/config-backup.test.ts
  • tests/unit/failover.test.ts
🚧 Files skipped from review as they are similar to previous changes (9)
  • README.md
  • docs/superpowers/plans/2026-07-09-backup-ai-provider-failover.md
  • docs/superpowers/specs/2026-07-09-backup-ai-provider-failover-design.md
  • src/ai/failover.ts
  • src/ai/transient.ts
  • src/realtime/jobs.ts
  • src/reviewer.ts
  • src/types.ts
  • tests/unit/transient.test.ts

Comment thread src/config.ts Outdated
Comment thread src/ai/provider-factory.ts
Comment thread src/ai/provider-factory.ts
Comment thread tests/unit/failover.test.ts
Comment thread tests/unit/config-backup.test.ts
- config: gate primary-timeout + breaker-knob env parsing on backupAiProvider so
  the off-by-default path never reads/applies them. (Minor)
- buildProvider: also require localAiModel for the openai-compatible branch,
  matching the localAiBaseUrl guard. (Minor)
- Clarify ProviderSpec.label doc: same-type attribution disambiguation is
  openai-compatible-only; anthropic/openai rely on provider+model distinctness. (Minor)
- Tests: assert servedBy on the breaker-open (skip-primary) path; add breaker-knob
  fail-soft parse cases + a knobs-stay-default-when-off case. (Trivial)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@diffsentry diffsentry Bot dismissed their stale review July 9, 2026 22:51

Superseded by new review.

@diffsentry diffsentry Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Solid failover design: shared factory, fail-fast config, sequential primary→backup, and good unit coverage for the breaker. The off-by-default path is preserved. One major hole remains: non-review methods never set servedBy, so backup-served walkthrough/chat/complete traffic is invisible to the reviewer footnote and any downstream attribution. A few smaller maintainability cleanups would also help.

🔎 Issues not tied to a specific line (5)

These findings concern the change as a whole (for example, the diff versus the PR description) and can't be attached to a single changed line.

⚠️ Potential issue | 🟠 Major

servedBy is only defined on ReviewResult, so non-review backup traffic is invisible.

The PR description says failover covers all five AIProvider methods and that a subtle footnote is appended when the backup served the review. The unit tests confirm non-review methods fail over, but only review() can tag servedBy because that field lives on ReviewResult.

That leaves two gaps relative to the stated transparency goals:

  1. Backup-served walkthrough / chat / chatIssue / complete calls have no signal for operators or logs beyond whatever the provider itself emits.
  2. Any reviewer footnote logic keyed only on ReviewResult.servedBy will correctly mark backup reviews, but testAiProvider() / aiTarget() (already listed as a non-blocking follow-up) and any multi-call review pipeline cannot distinguish "primary walkthrough + backup review" from "fully backup-served".

If full transparency is in scope for this PR, plumb a small servedBy / last-used-provider side channel through the wrapper (e.g. a getter on FailoverProvider, or a shared call-result envelope) rather than overloading only ReviewResult. If review-body footnotes are intentionally review-only, document that non-review methods fail over silently so the design claim matches the code.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

Across src/ai/failover.ts, src/types.ts, and the reviewer wiring for this feature, decide how non-review methods report backup service. Either (a) add a FailoverProvider.lastServedBy getter (or similar) updated on every method and document that only review() gets a body footnote, or (b) extend the relevant return types / a shared envelope so walkthrough/chat/complete can also surface servedBy. Update tests/unit/failover.test.ts to assert the chosen signal on at least one non-review method.

⚠️ Potential issue | 🟠 Major

Diff is incomplete relative to the PR description — core failover wiring is not in the changed files.

🤔 Medium confidence — verify against intent before acting.

The PR description and DiffSentry summary claim changes to src/ai/failover.ts, src/ai/transient.ts, src/types.ts, src/reviewer.ts, .env.example, and README.md, plus extraction of isTransientError and the backup footnote. The provided changed-files list only includes provider-factory.ts, the config parse, and two unit test files.

Without those files in the review surface, the most important behaviors cannot be verified here: transient classification, sequential await-before-backup, breaker state machine, ReviewResult.servedBy typing, primary timeout wiring, or the posted-review footnote. Either the diff is truncated for this review, or those files are missing from the PR — please confirm they land together so the feature is reviewable end-to-end.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

Verify that src/ai/failover.ts, src/ai/transient.ts, src/types.ts, src/reviewer.ts, .env.example, and README.md are included in this PR with the behaviors described in the PR body (FailoverProvider, isTransientError extraction, ReviewResult.servedBy, backup footnote, env docs). If any are missing from the branch, add them before merge so the feature is complete and reviewable.

⚠️ Potential issue | 🟠 Major

Description claims core implementation files absent from the diff

🤔 Medium confidence — verify against intent before acting.

The PR description lists src/ai/failover.ts (FailoverProvider + circuit breaker), src/ai/transient.ts (isTransientError extraction), src/types.ts (ReviewResult.servedBy), and src/reviewer.ts (factory wiring + backup footnote) as changes, but none of those files appear in the actual changed-files set. Only provider-factory.ts, config.ts, and two test files are present.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

Reconcile the PR description with the actual diff. Description claims core implementation files absent from the diff The PR description lists src/ai/failover.ts (FailoverProvider + circuit breaker), src/ai/transient.ts (isTransientError extraction), src/types.ts (ReviewResult.servedBy), and src/reviewer.ts (factory wiring + backup footnote) as changes, but none of those files appear in the actual changed-files set. Only provider-factory.ts, config.ts, and two test files are present.

⚠️ Potential issue | 🟠 Major

Operator docs and jobs.ts re-export claimed but not in diff

🤔 Medium confidence — verify against intent before acting.

Description says .env.example and README.md were updated for operator docs, and that jobs.ts re-exports isTransientError from the new transient module. Neither .env.example, README.md, nor any jobs.ts change appears in the provided diffs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

Reconcile the PR description with the actual diff. Operator docs and jobs.ts re-export claimed but not in diff Description says .env.example and README.md were updated for operator docs, and that jobs.ts re-exports isTransientError from the new transient module. Neither .env.example, README.md, nor any jobs.ts change appears in the provided diffs.

⚠️ Potential issue | 🟠 Major

tests/unit/transient.test.ts claimed but missing from changes

🤔 Medium confidence — verify against intent before acting.

The Testing section states new unit coverage in tests/unit/transient.test.ts, but that file is not among the changed files. Only tests/unit/failover.test.ts and tests/unit/config-backup.test.ts are added.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

Reconcile the PR description with the actual diff. tests/unit/transient.test.ts claimed but missing from changes The Testing section states new unit coverage in tests/unit/transient.test.ts, but that file is not among the changed files. Only tests/unit/failover.test.ts and tests/unit/config-backup.test.ts are added.
🧹 Nitpick comments (5)
src/ai/provider-factory.ts (2)

Line 32: label is only applied for openai-compatible backups.

🛠️ Refactor suggestion | 🟡 Minor

label is only applied for openai-compatible backups.

ProviderSpec.label is documented as the cost/log disambiguator, but only OpenAICompatibleProvider receives it. An anthropic/openai backup that reuses the same provider type and model as the primary will collapse into the primary in recordAiUsage / logs.

That is called out as an unusual config and a future follow-up, but the current wiring makes the documented attribution guarantee incomplete for two of the three provider types. Either:

  • extend AnthropicProvider / OpenAIProvider constructors to accept an optional label now, or
  • fail boot (or warn loudly) when the resolved backup is same-provider + same-model as the primary so operators cannot silently lose attribution.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In src/ai/provider-factory.ts around line 32, address the incomplete label attribution for anthropic/openai backups. Either pass an optional providerLabel into AnthropicProvider and OpenAIProvider constructors (mirroring OpenAICompatibleProvider) so same-type backups stay distinct in recordAiUsage, or add a boot-time check in loadConfig/reviewer that warns or rejects when the resolved backup is same provider + same model as the primary.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In src/ai/provider-factory.ts around line 32, address the incomplete label attribution for anthropic/openai backups. Either pass an optional providerLabel into AnthropicProvider and OpenAIProvider constructors (mirroring OpenAICompatibleProvider) so same-type backups stay distinct in recordAiUsage, or add a boot-time check in loadConfig/reviewer that warns or rejects when the resolved backup is same provider + same model as the primary.

Line 56: OpenAI branch is the implicit default with a less precise error path.

🧹 Nitpick | 🟢 Trivial

OpenAI branch is the implicit default with a less precise error path.

After the anthropic and openai-compatible arms, any other spec.provider value falls into the OpenAI constructor. Today ProviderSpec.provider is a closed union so this is safe, but the error message only mentions openai, and an accidental widening of the union later would mis-construct rather than fail clearly.

Prefer an explicit if (spec.provider === "openai") + exhaustive never fallback so the factory stays aligned with the three-way switch style used above.

🔧 Proposed fix
if (spec.provider === "openai") {
    if (!spec.openaiApiKey) {
      throw new Error("buildProvider(openai) requires openaiApiKey");
    }
    return new OpenAIProvider(spec.openaiApiKey, spec.openaiModel, spec.openaiBaseUrl, spec.timeoutMs);
  }
  const _exhaustive: never = spec.provider;
  throw new Error(`buildProvider: unsupported provider ${String(_exhaustive)}`);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In src/ai/provider-factory.ts around line 56, replace the implicit openai fallback with an explicit if (spec.provider === "openai") branch and an exhaustive never default that throws, matching the explicit anthropic and openai-compatible arms above.
♻️ Suggested fix
if (spec.provider === "openai") {
    if (!spec.openaiApiKey) {
      throw new Error("buildProvider(openai) requires openaiApiKey");
    }
    return new OpenAIProvider(spec.openaiApiKey, spec.openaiModel, spec.openaiBaseUrl, spec.timeoutMs);
  }
  const _exhaustive: never = spec.provider;
  throw new Error(`buildProvider: unsupported provider ${String(_exhaustive)}`);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In src/ai/provider-factory.ts around line 56, replace the implicit openai fallback with an explicit if (spec.provider === "openai") branch and an exhaustive never default that throws, matching the explicit anthropic and openai-compatible arms above.
src/config.ts (1)

Line 147: primaryAiTimeoutMs and breaker knobs are always exported even when failover is off.

🛠️ Refactor suggestion | 🟡 Minor

primaryAiTimeoutMs and breaker knobs are always exported even when failover is off.

🤔 Medium confidence — verify against intent before acting.

When BACKUP_AI_PROVIDER is unset, primaryAiTimeoutMs / backupCircuitThreshold / backupCircuitCooldownMs still land on the Config object with defaults. That is fine for the "off by default = unchanged runtime" claim only if every call site gates on backupAiProvider before reading them.

If any future consumer applies primaryAiTimeoutMs unconditionally (e.g. wiring timeoutMs: config.primaryAiTimeoutMs for the primary), an operator who never opted into failover would silently get a 20s primary deadline instead of AI_REQUEST_TIMEOUT_MS.

Consider making these fields optional (number | undefined) when failover is off, so misuse becomes a type error rather than a silent behavioral change.

🔧 Proposed fix
let primaryAiTimeoutMs: number | undefined;
  let backupCircuitThreshold: number | undefined;
  let backupCircuitCooldownMs: number | undefined;
  if (backupAiProvider) {
    const parsedPrimaryTimeout = parseInt(process.env.PRIMARY_AI_TIMEOUT_MS || "", 10);
    primaryAiTimeoutMs =
      Number.isFinite(parsedPrimaryTimeout) && parsedPrimaryTimeout > 0 ? parsedPrimaryTimeout : 20_000;
    if (aiRequestTimeoutMs > 0 && primaryAiTimeoutMs > aiRequestTimeoutMs) {
      primaryAiTimeoutMs = aiRequestTimeoutMs;
    }

    const parsedThreshold = parseInt(process.env.BACKUP_CIRCUIT_THRESHOLD || "", 10);
    backupCircuitThreshold = Number.isFinite(parsedThreshold) && parsedThreshold >= 1 ? parsedThreshold : 3;

    const parsedCooldown = parseInt(process.env.BACKUP_CIRCUIT_COOLDOWN_MS || "", 10);
    backupCircuitCooldownMs = Number.isFinite(parsedCooldown) && parsedCooldown >= 0 ? parsedCooldown : 60_000;
  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In src/config.ts around line 147, change primaryAiTimeoutMs, backupCircuitThreshold, and backupCircuitCooldownMs to number | undefined and only assign them inside the if (backupAiProvider) branch. Update Config in src/types.ts accordingly so call sites that apply the short primary deadline must first prove a backup is configured.
♻️ Suggested fix
let primaryAiTimeoutMs: number | undefined;
  let backupCircuitThreshold: number | undefined;
  let backupCircuitCooldownMs: number | undefined;
  if (backupAiProvider) {
    const parsedPrimaryTimeout = parseInt(process.env.PRIMARY_AI_TIMEOUT_MS || "", 10);
    primaryAiTimeoutMs =
      Number.isFinite(parsedPrimaryTimeout) && parsedPrimaryTimeout > 0 ? parsedPrimaryTimeout : 20_000;
    if (aiRequestTimeoutMs > 0 && primaryAiTimeoutMs > aiRequestTimeoutMs) {
      primaryAiTimeoutMs = aiRequestTimeoutMs;
    }

    const parsedThreshold = parseInt(process.env.BACKUP_CIRCUIT_THRESHOLD || "", 10);
    backupCircuitThreshold = Number.isFinite(parsedThreshold) && parsedThreshold >= 1 ? parsedThreshold : 3;

    const parsedCooldown = parseInt(process.env.BACKUP_CIRCUIT_COOLDOWN_MS || "", 10);
    backupCircuitCooldownMs = Number.isFinite(parsedCooldown) && parsedCooldown >= 0 ? parsedCooldown : 60_000;
  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In src/config.ts around line 147, change primaryAiTimeoutMs, backupCircuitThreshold, and backupCircuitCooldownMs to number | undefined and only assign them inside the if (backupAiProvider) branch. Update Config in src/types.ts accordingly so call sites that apply the short primary deadline must first prove a backup is configured.
tests/unit/failover.test.ts (1)

Line 46: No assertion that a permanent non-401 error also skips failover.

🛠️ Refactor suggestion | 🟡 Minor

No assertion that a permanent non-401 error also skips failover.

The suite covers 401 (no failover) and timeout (failover), but not other permanent 4xx classes the design promises to never mask — e.g. 400 bad request, 403, or a plain non-status Error.

Add one case for a 400/403 (and optionally a generic non-transient Error) so a future change to isTransientError cannot silently start routing permanent primary failures to the backup.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In tests/unit/failover.test.ts around the existing 401 case near line 46, add a companion test that rejects with status 400 (or 403) and asserts the backup is never called and the original error is rethrown. Optionally also cover a plain Error without status to lock the non-transient path.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In tests/unit/failover.test.ts around the existing 401 case near line 46, add a companion test that rejects with status 400 (or 403) and asserts the backup is never called and the original error is rethrown. Optionally also cover a plain Error without status to lock the non-transient path.
tests/unit/config-backup.test.ts (1)

Line 32: Reuse path is only covered for anthropic credentials.

🧹 Nitpick | 🟢 Trivial

Reuse path is only covered for anthropic credentials.

reuses primary env when only BACKUP_AI_PROVIDER is set checks anthropic key reuse, and a later test covers openai-compatible overrides, but there is no symmetric case for:

  • BACKUP_AI_PROVIDER=openai reusing OPENAI_API_KEY / model / base URL
  • BACKUP_AI_PROVIDER=openai-compatible reusing primary LOCAL_AI_* without any BACKUP_LOCAL_AI_*

Given the fail-fast validation branches differ per provider, those two paths are worth a couple of cheap asserts so a future edit to the reuse chain cannot regress silently.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In tests/unit/config-backup.test.ts after the anthropic reuse test around line 32, add two small cases: one with BACKUP_AI_PROVIDER=openai that only sets OPENAI_API_KEY and asserts backupOpenaiApiKey is reused, and one with BACKUP_AI_PROVIDER=openai-compatible that relies on BASE LOCAL_AI_* and asserts backupLocalAiBaseUrl/backupLocalAiModel are populated without BACKUP_LOCAL_AI_* overrides.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In tests/unit/config-backup.test.ts after the anthropic reuse test around line 32, add two small cases: one with BACKUP_AI_PROVIDER=openai that only sets OPENAI_API_KEY and asserts backupOpenaiApiKey is reused, and one with BACKUP_AI_PROVIDER=openai-compatible that relies on BASE LOCAL_AI_* and asserts backupLocalAiBaseUrl/backupLocalAiModel are populated without BACKUP_LOCAL_AI_* overrides.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

In `src/ai/provider-factory.ts`:
- Line 32: In src/ai/provider-factory.ts around line 32, address the incomplete label attribution for anthropic/openai backups. Either pass an optional providerLabel into AnthropicProvider and OpenAIProvider constructors (mirroring OpenAICompatibleProvider) so same-type backups stay distinct in recordAiUsage, or add a boot-time check in loadConfig/reviewer that warns or rejects when the resolved backup is same provider + same model as the primary.
- Line 56: In src/ai/provider-factory.ts around line 56, replace the implicit openai fallback with an explicit if (spec.provider === "openai") branch and an exhaustive never default that throws, matching the explicit anthropic and openai-compatible arms above.

In `src/config.ts`:
- Line 147: In src/config.ts around line 147, change primaryAiTimeoutMs, backupCircuitThreshold, and backupCircuitCooldownMs to number | undefined and only assign them inside the if (backupAiProvider) branch. Update Config in src/types.ts accordingly so call sites that apply the short primary deadline must first prove a backup is configured.

In `tests/unit/failover.test.ts`:
- Line 46: In tests/unit/failover.test.ts around the existing 401 case near line 46, add a companion test that rejects with status 400 (or 403) and asserts the backup is never called and the original error is rethrown. Optionally also cover a plain Error without status to lock the non-transient path.

In `tests/unit/config-backup.test.ts`:
- Line 32: In tests/unit/config-backup.test.ts after the anthropic reuse test around line 32, add two small cases: one with BACKUP_AI_PROVIDER=openai that only sets OPENAI_API_KEY and asserts backupOpenaiApiKey is reused, and one with BACKUP_AI_PROVIDER=openai-compatible that relies on BASE LOCAL_AI_* and asserts backupLocalAiBaseUrl/backupLocalAiModel are populated without BACKUP_LOCAL_AI_* overrides.
🪄 Autofix (Beta)

Fix all unresolved DiffSentry comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: .diffsentry.yaml

Review profile: ASSERTIVE

Run ID: 5d4ef1e1-04b9-4950-8474-01a167a29dbd

📥 Commits

Reviewing files that changed from be6e847 to 9297926. Previously-reviewed commits are not re-reviewed.

⛔ Files ignored due to path filters (1)
  • .env.example is excluded by !.env.example
📒 Files selected for processing (4)
  • src/ai/provider-factory.ts
  • src/config.ts
  • tests/unit/config-backup.test.ts
  • tests/unit/failover.test.ts
🚧 Files skipped from review as they are similar to previous changes (9)
  • README.md
  • docs/superpowers/plans/2026-07-09-backup-ai-provider-failover.md
  • docs/superpowers/specs/2026-07-09-backup-ai-provider-failover-design.md
  • src/ai/failover.ts
  • src/ai/transient.ts
  • src/realtime/jobs.ts
  • src/reviewer.ts
  • src/types.ts
  • tests/unit/transient.test.ts

Comment thread src/ai/provider-factory.ts
Comment thread src/config.ts
Comment thread tests/unit/failover.test.ts
Comment thread tests/unit/config-backup.test.ts
Comment thread src/ai/provider-factory.ts
@diffsentry

diffsentry Bot commented Jul 10, 2026

Copy link
Copy Markdown

TL;DR

This PR adds an opt-in backup AI provider so that when the primary model fails transiently (timeouts, 5xx, 429, network blips), DiffSentry can fall over to a secondary and still post a review instead of dead-lettering it. It was motivated by PR #77, where grok-4.5 intermittently hung past the 60s timeout and every retry hit the same wall, so no review went out. Failover is sequential, covers all five AI methods, and never kicks in on auth/4xx errors so a bad primary key still surfaces loudly. The main caveat: it’s off unless you set BACKUP_AI_PROVIDER, and when enabled the primary gets a short deadline (default 20s) so a healthy-but-slow review can fail over and roughly double latency/cost if that timeout is set too aggressively.

Generated by DiffSentry on demand. Re-run with @diffsentry tldr.

@mk7luke

mk7luke commented Jul 10, 2026

Copy link
Copy Markdown
Owner Author

@diffsentry release-notes

@diffsentry

diffsentry Bot commented Jul 10, 2026

Copy link
Copy Markdown

📣 Release Notes

✨ What's new

  • Reviews still land when your main AI model is slow or unavailable. You can now configure a backup AI provider that DiffSentry switches to automatically if the primary model times out, rate-limits, or hits a temporary outage—so a flaky model no longer means a missing review.
  • Clear notice when a backup was used. If the backup provider wrote the review, a short note is added to the review body so the team knows what happened.
  • Optional and off by default. Leave it unset and DiffSentry behaves exactly as before; turn it on only when you want that safety net.

🛠 Improvements

  • Faster recovery from slow or hanging model calls: when a backup is configured, DiffSentry gives the primary model a shorter wait time so it can switch sooner instead of stalling for the full timeout.
  • During a clear primary outage, DiffSentry can briefly skip retrying the primary and go straight to the backup, cutting wasted wait time and cost while the main provider is down.
  • Backup credentials can reuse your existing provider settings, with optional overrides if you want a different model or key for failover.

Marketing-speak version of this PR. Re-run with @diffsentry release-notes.

@mk7luke mk7luke merged commit be24057 into main Jul 10, 2026
3 of 4 checks passed
@mk7luke mk7luke deleted the feat/backup-ai-failover branch July 10, 2026 18:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant