From 54783c1838be8ddb30b58375203681300e54749d Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:00:43 -0700 Subject: [PATCH 1/2] feat(selfhost): fail-LOUD boot preflight when RAG is enabled with no embed-capable provider (#8765) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With LOOPOVER_REVIEW_RAG=true, no AI_EMBED_BASE_URL, and a CLI-only provider chain (claude-code/codex), every embed call throws *_no_embed by design — the RAG index never populates and every review pays a cold-index no-op, surfaced only as per-batch runtime errors. The review-CLI path already gets a #1566 boot preflight; RAG's embed dependency now gets the same treatment: - shouldWarnRagEmbedUnavailable (pure, src/selfhost/ai.ts): RAG on + no dedicated embed endpoint + every provider CLI-subscription. Any OpenAI-compatible chain member silences it (the chain falls through *_no_embed to it); no-AI-at-all stays silent (already loud elsewhere). - server.ts shouts selfhost_rag_embed_unavailable once at boot, warn-only — runtime degrade behavior unchanged. - docker-compose.yml: the qdrant/ollama profile hints now name AI_EMBED_BASE_URL/AI_EMBED_MODEL as REQUIRED for RAG on CLI-only chains (previously only .env.example's deep reference explained this). --- docker-compose.yml | 5 +++++ src/selfhost/ai.ts | 16 ++++++++++++++++ src/server.ts | 17 +++++++++++++++++ test/unit/selfhost-ai.test.ts | 29 ++++++++++++++++++++++++++++- 4 files changed, 66 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 245afa2959..d8f071cd04 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -157,6 +157,11 @@ services: # Uncomment for Ollama AI (--profile ollama): # AI_PROVIDER: ollama # OLLAMA_AI_BASE_URL: http://ollama:11434/v1 + # RAG needs an EMBED-capable endpoint (#8765): with LOOPOVER_REVIEW_RAG=true and a CLI-only review + # chain (AI_PROVIDER=claude-code / codex), the two lines below are REQUIRED or the index never + # populates — CLI-subscription providers cannot embed. See .env.example's AI_EMBED section. + # AI_EMBED_BASE_URL: http://ollama:11434/v1 + # AI_EMBED_MODEL: bge-m3 # BROKERED mode — use the central LoopOver Orb App instead of creating your own GitHub App. Install the # Orb App on your repos + set ORB_ENROLLMENT_SECRET in .env (loaded above); the engine then brokers # short-lived GitHub tokens from the Orb on demand (no own App private key). See .env.example. diff --git a/src/selfhost/ai.ts b/src/selfhost/ai.ts index 427c2a82e6..8b21f1c9b6 100644 --- a/src/selfhost/ai.ts +++ b/src/selfhost/ai.ts @@ -1469,6 +1469,22 @@ export function resolveProviderNames(env: Record): s return resolveConfiguredProviderNames(env); } +/** + * #8765: true when RAG is enabled but NO configured provider can serve an embed request — AI_EMBED_BASE_URL + * is unset AND every AI_PROVIDER member is a CLI-subscription provider (claude-code/codex), which throw + * `*_no_embed` on every embed call by design. In that state the index never populates: every upsert embeds + * 0/N chunks forever, surfaced only as per-batch runtime ERROR logs. The review-CLI path already gets a + * fail-LOUD boot preflight (#1566); this is the same check for RAG's embed dependency. PURE — server.ts + * owns the actual boot-time shout. + */ +export function shouldWarnRagEmbedUnavailable(env: Record): boolean { + if (!/^(1|true|yes|on)$/i.test((env.LOOPOVER_REVIEW_RAG ?? "").trim())) return false; + if ((env.AI_EMBED_BASE_URL ?? "").trim() !== "") return false; + const providers = resolveProviderNames(env); + if (providers.length === 0) return false; // no AI at all — RAG degrades for a different, already-loud reason + return providers.every((provider) => provider === "claude-code" || provider === "codex"); +} + /** CLI-subscription providers need their binary present on PATH; keep boot preflight parsing identical to AI_PROVIDER. */ export function resolveRequiredCliProviders(env: Record): Array<{ provider: string; cli: string }> { const seen = new Set(); diff --git a/src/server.ts b/src/server.ts index 9711783acd..7cf2b697f6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -26,6 +26,7 @@ import { resolveRequiredCliProviders, resolveSubscriptionCliPath, shouldMarkAiProviderUnhealthyAtBoot, + shouldWarnRagEmbedUnavailable, subscriptionCliEnv, withAiGenerationCapture, } from "./selfhost/ai"; @@ -568,6 +569,22 @@ async function main(): Promise { if (shouldMarkAiProviderUnhealthyAtBoot(resolveProviderNames(process.env), [...missingCliProviders])) { markAiProviderUnhealthyAtBoot(); } + // Fail-LOUD preflight for RAG's embed dependency (#8765, mirrors #1566): with LOOPOVER_REVIEW_RAG=true, no + // AI_EMBED_BASE_URL, and a provider chain of only CLI-subscription providers, every embed call throws + // *_no_embed by design — the index never populates and every review pays a cold-index no-op, surfaced only + // as per-batch runtime errors. Shout once at boot instead. Warn-only: RAG's runtime degrade stays exactly + // as it was (this misconfig must not stop the server). + if (shouldWarnRagEmbedUnavailable(process.env)) { + console.error( + JSON.stringify({ + level: "error", + event: "selfhost_rag_embed_unavailable", + provider: process.env.AI_PROVIDER, + message: + "LOOPOVER_REVIEW_RAG is enabled but no configured provider can serve embeddings — AI_EMBED_BASE_URL is unset and every AI_PROVIDER member is a CLI-subscription provider (they never embed). The RAG index will stay empty. Set AI_EMBED_BASE_URL (e.g. http://ollama:11434/v1 with AI_EMBED_MODEL=bge-m3) or add an OpenAI-compatible provider to the chain.", + }), + ); + } // Dedicated RAG embed provider (keeps the review chain frontier-only): when AI_EMBED_BASE_URL is set, embeddings // route to a SEPARATE openai-compatible endpoint (e.g. ollama at http://ollama:11434/v1, model bge-m3) instead of // the review chain — so a Claude/Codex outage never falls reviews back to a weak local model. Unset ⇒ absent ⇒ diff --git a/test/unit/selfhost-ai.test.ts b/test/unit/selfhost-ai.test.ts index 38026ef5ea..5c27a4a337 100644 --- a/test/unit/selfhost-ai.test.ts +++ b/test/unit/selfhost-ai.test.ts @@ -18,7 +18,7 @@ const posthogMocks = vi.hoisted(() => { }); vi.mock("posthog-node", () => ({ PostHog: posthogMocks.PostHog })); -import { assertNoLegacySharedAiEnv, buildProvider, claudeErrorStatus, codexErrorFromStdout, createAnthropicAi, createChainAi, createClaudeCodeAi, createCodexAi, createOpenAiCompatibleAi, createSelfHostAi, extractCliText, extractCliUsage, isAiProviderHealthy, markAiProviderUnhealthyAtBoot, providerNameFromBaseUrl, resetAiProviderCircuitBreakerForTest, resetAiProviderHealthForTest, resolveAiReviewerPlan, resolveClaudeCliTimeoutMs, resolveClaudeFirstOutputTimeoutMs, resolveCodexAuthPath, resolveCodexCliTimeoutMs, resolveCodexEffort, resolveCodexFirstOutputTimeoutMs, resolveEffort, resolveModel, resolveProviderNames, resolveRequiredCliProviders, resolveSubscriptionCliPath, redactSecrets, routeProviders, shouldMarkAiProviderUnhealthyAtBoot, subscriptionCliEnv, withAdvisoryAiEnv, withAiGenerationCapture, __selfHostAiInternals } from "../../src/selfhost/ai"; +import { assertNoLegacySharedAiEnv, buildProvider, claudeErrorStatus, codexErrorFromStdout, createAnthropicAi, createChainAi, createClaudeCodeAi, createCodexAi, createOpenAiCompatibleAi, createSelfHostAi, extractCliText, extractCliUsage, isAiProviderHealthy, markAiProviderUnhealthyAtBoot, providerNameFromBaseUrl, resetAiProviderCircuitBreakerForTest, resetAiProviderHealthForTest, resolveAiReviewerPlan, resolveClaudeCliTimeoutMs, resolveClaudeFirstOutputTimeoutMs, resolveCodexAuthPath, resolveCodexCliTimeoutMs, resolveCodexEffort, resolveCodexFirstOutputTimeoutMs, resolveEffort, resolveModel, resolveProviderNames, resolveRequiredCliProviders, resolveSubscriptionCliPath, redactSecrets, routeProviders, shouldMarkAiProviderUnhealthyAtBoot, shouldWarnRagEmbedUnavailable, subscriptionCliEnv, withAdvisoryAiEnv, withAiGenerationCapture, __selfHostAiInternals } from "../../src/selfhost/ai"; import { labelSelfHostReviewerModel, labelSelfHostReviewerModels } from "../../src/selfhost/ai-config"; import { renderMetrics, resetMetrics } from "../../src/selfhost/metrics"; import { initPostHog, resetPostHogForTest } from "../../src/selfhost/posthog"; @@ -2378,3 +2378,30 @@ describe("withAdvisoryAiEnv (#4364 — per-capability local-inference routing)", expect(result.AI).toBe(frontierAi); }); }); + +describe("shouldWarnRagEmbedUnavailable (#8765 boot preflight)", () => { + it("warns for RAG-on + CLI-only chain + no dedicated embed endpoint — the index-never-populates misconfig", () => { + expect(shouldWarnRagEmbedUnavailable({ LOOPOVER_REVIEW_RAG: "true", AI_PROVIDER: "claude-code" })).toBe(true); + expect(shouldWarnRagEmbedUnavailable({ LOOPOVER_REVIEW_RAG: "1", AI_PROVIDER: "claude-code,codex" })).toBe(true); + }); + + it("stays silent when RAG is off (default), regardless of the chain", () => { + expect(shouldWarnRagEmbedUnavailable({ AI_PROVIDER: "claude-code" })).toBe(false); + expect(shouldWarnRagEmbedUnavailable({ LOOPOVER_REVIEW_RAG: "false", AI_PROVIDER: "codex" })).toBe(false); + }); + + it("stays silent when AI_EMBED_BASE_URL provides the dedicated embed endpoint", () => { + expect( + shouldWarnRagEmbedUnavailable({ LOOPOVER_REVIEW_RAG: "true", AI_PROVIDER: "claude-code", AI_EMBED_BASE_URL: "http://ollama:11434/v1" }), + ).toBe(false); + }); + + it("stays silent when the chain contains ANY embed-capable (non-CLI) provider — the chain falls through *_no_embed to it", () => { + expect(shouldWarnRagEmbedUnavailable({ LOOPOVER_REVIEW_RAG: "true", AI_PROVIDER: "claude-code,ollama" })).toBe(false); + expect(shouldWarnRagEmbedUnavailable({ LOOPOVER_REVIEW_RAG: "true", AI_PROVIDER: "ollama" })).toBe(false); + }); + + it("stays silent with no AI configured at all — that absence is already loud for its own reason", () => { + expect(shouldWarnRagEmbedUnavailable({ LOOPOVER_REVIEW_RAG: "true" })).toBe(false); + }); +}); From ccc2f37fdefe1dc608423357ea6352ff5e631c5b Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:05:34 -0700 Subject: [PATCH 2/2] chore(selfhost): regenerate env reference for the #8765 RAG-preflight env reads --- apps/loopover-ui/src/lib/selfhost-env-reference.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/loopover-ui/src/lib/selfhost-env-reference.ts b/apps/loopover-ui/src/lib/selfhost-env-reference.ts index 40b93278c4..3b0f31fc2c 100644 --- a/apps/loopover-ui/src/lib/selfhost-env-reference.ts +++ b/apps/loopover-ui/src/lib/selfhost-env-reference.ts @@ -47,7 +47,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ }, { name: "AI_EMBED_BASE_URL", - firstReference: "src/server.ts", + firstReference: "src/selfhost/ai.ts", }, { name: "AI_EMBED_MODEL", @@ -285,6 +285,10 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ name: "LOOPOVER_REVIEW_CONTINUOUS", firstReference: "src/queue/processors.ts", }, + { + name: "LOOPOVER_REVIEW_RAG", + firstReference: "src/selfhost/ai.ts", + }, { name: "LOOPOVER_VERSION", firstReference: "src/selfhost/otel.ts", @@ -636,7 +640,7 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [ "| `AI_DAILY_NEURON_BUDGET` | `src/services/ai-review.ts` |", "| `AI_DUAL_REVIEW` | `src/selfhost/ai.ts` |", "| `AI_EMBED_API_KEY` | `src/server.ts` |", - "| `AI_EMBED_BASE_URL` | `src/server.ts` |", + "| `AI_EMBED_BASE_URL` | `src/selfhost/ai.ts` |", "| `AI_EMBED_MODEL` | `src/selfhost/ai.ts` |", "| `AI_GATEWAY_ID` | `src/services/ai-review.ts` |", "| `AI_MAX_OUTPUT_TOKENS` | `src/services/ai-review.ts` |", @@ -696,6 +700,7 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [ "| `LOOPOVER_MCP_TOKEN` | `src/selfhost/preflight.ts` |", "| `LOOPOVER_REPO_CONFIG_DIR` | `src/server.ts` |", "| `LOOPOVER_REVIEW_CONTINUOUS` | `src/queue/processors.ts` |", + "| `LOOPOVER_REVIEW_RAG` | `src/selfhost/ai.ts` |", "| `LOOPOVER_VERSION` | `src/selfhost/otel.ts` |", "| `MAINTENANCE_ADMISSION_DEFER_MS` | `src/selfhost/maintenance-admission.ts` |", "| `MAINTENANCE_ADMISSION_DRAIN_AGE_MS` | `src/selfhost/maintenance-admission.ts` |",