From d27ce23d12935b346049a09206346058f0fd9f8b Mon Sep 17 00:00:00 2001 From: Andriy Polanski Date: Fri, 24 Jul 2026 12:56:09 +0000 Subject: [PATCH 1/3] feat(selfhost): allow per-repo AI CLI timeout overrides in review.ai_model (#8364) Mirror the existing model/effort config-as-code path for the four CLI timeout knobs so a shared self-host instance is not stuck on one global env-var budget per repo. Co-authored-by: Cursor --- .../loopover-engine/src/focus-manifest.ts | 61 +++++++++++++--- src/queue/ai-review-orchestration.ts | 10 ++- src/review/ai-review-cache-input.ts | 15 ++-- src/selfhost/ai.ts | 69 +++++++++++++++---- src/services/ai-review.ts | 47 +++++++++---- test/unit/ai-review-cache-input.test.ts | 2 +- test/unit/ai-review.test.ts | 30 +++++++- test/unit/focus-manifest.test.ts | 36 +++++++++- test/unit/queue-2.test.ts | 2 +- test/unit/queue-4.test.ts | 4 +- test/unit/queue.test.ts | 18 ++--- test/unit/selfhost-ai.test.ts | 22 ++++++ 12 files changed, 256 insertions(+), 60 deletions(-) diff --git a/packages/loopover-engine/src/focus-manifest.ts b/packages/loopover-engine/src/focus-manifest.ts index 676ae101c8..c35364b13e 100644 --- a/packages/loopover-engine/src/focus-manifest.ts +++ b/packages/loopover-engine/src/focus-manifest.ts @@ -999,11 +999,12 @@ export const EMPTY_AUTO_REVIEW_CONFIG: AutoReviewConfig = { autoPauseAfterReviewedCommits: null, }; -/** Per-repo self-host reviewer model/effort overrides under `review.ai_model`. Each field independently overrides - * the matching global env var (CLAUDE_AI_MODEL / CLAUDE_AI_EFFORT / CODEX_AI_MODEL / CODEX_AI_EFFORT) for THIS - * repo only — it never widens what the operator's own env already permits, only narrows/redirects it, so a - * compromised repo config can change which model reviews it but not grant itself a new credential or provider. - * (#selfhost-ai-model-override) */ +/** Per-repo self-host reviewer model/effort/timeout overrides under `review.ai_model`. Each field independently + * overrides the matching global env var (CLAUDE_AI_MODEL / CLAUDE_AI_EFFORT / CODEX_AI_MODEL / CODEX_AI_EFFORT / + * CLAUDE_AI_TIMEOUT_MS / CODEX_AI_TIMEOUT_MS / CLAUDE_AI_FIRST_OUTPUT_TIMEOUT_MS / CODEX_AI_FIRST_OUTPUT_TIMEOUT_MS) + * for THIS repo only — it never widens what the operator's own env already permits, only narrows/redirects it, + * so a compromised repo config can change which model reviews it but not grant itself a new credential or + * provider. (#selfhost-ai-model-override, #8364) */ export type SelfHostAiModelConfig = { /** `review.ai_model.claude_model`: overrides CLAUDE_AI_MODEL for this repo's claude-code reviewer. null (default) ⇒ the operator's global env var, then the provider's own default. */ claudeModel: string | null; @@ -1013,6 +1014,14 @@ export type SelfHostAiModelConfig = { codexModel: string | null; /** `review.ai_model.codex_effort`: overrides CODEX_AI_EFFORT for this repo's codex reviewer. null (default) ⇒ the operator's global env var, then "medium". */ codexEffort: string | null; + /** `review.ai_model.claude_timeout_ms` (#8364): overrides CLAUDE_AI_TIMEOUT_MS for this repo's claude-code reviewer. null (default) ⇒ the operator's global env var, then the effort-based ladder. */ + claudeTimeoutMs: number | null; + /** `review.ai_model.codex_timeout_ms` (#8364): overrides CODEX_AI_TIMEOUT_MS for this repo's codex reviewer. null (default) ⇒ the operator's global env var, then the effort-based ladder. */ + codexTimeoutMs: number | null; + /** `review.ai_model.claude_first_output_timeout_ms` (#8364): overrides CLAUDE_AI_FIRST_OUTPUT_TIMEOUT_MS for this repo's claude-code reviewer. null (default) ⇒ the operator's global env var, then the provider default. */ + claudeFirstOutputTimeoutMs: number | null; + /** `review.ai_model.codex_first_output_timeout_ms` (#8364): overrides CODEX_AI_FIRST_OUTPUT_TIMEOUT_MS for this repo's codex reviewer. null (default) ⇒ the operator's global env var, then the provider default. */ + codexFirstOutputTimeoutMs: number | null; /** `review.ai_model.ollama_model` (#3902): overrides OLLAMA_AI_MODEL for this repo's ollama reviewer. null (default) ⇒ the operator's global env var, then the provider's own default. */ ollamaModel: string | null; /** `review.ai_model.openai_model` (#3902): overrides OPENAI_AI_MODEL for this repo's openai reviewer. null (default) ⇒ the operator's global env var, then the provider's own default. */ @@ -1028,6 +1037,10 @@ export const EMPTY_SELF_HOST_AI_MODEL_CONFIG: SelfHostAiModelConfig = { claudeEffort: null, codexModel: null, codexEffort: null, + claudeTimeoutMs: null, + codexTimeoutMs: null, + claudeFirstOutputTimeoutMs: null, + codexFirstOutputTimeoutMs: null, ollamaModel: null, openaiModel: null, openaiCompatibleModel: null, @@ -3194,6 +3207,10 @@ function overlaySelfHostAiModelConfig(base: SelfHostAiModelConfig, override: Sel claudeEffort: pickOverlayNullable(override.claudeEffort, base.claudeEffort), codexModel: pickOverlayNullable(override.codexModel, base.codexModel), codexEffort: pickOverlayNullable(override.codexEffort, base.codexEffort), + claudeTimeoutMs: pickOverlayNullable(override.claudeTimeoutMs, base.claudeTimeoutMs), + codexTimeoutMs: pickOverlayNullable(override.codexTimeoutMs, base.codexTimeoutMs), + claudeFirstOutputTimeoutMs: pickOverlayNullable(override.claudeFirstOutputTimeoutMs, base.claudeFirstOutputTimeoutMs), + codexFirstOutputTimeoutMs: pickOverlayNullable(override.codexFirstOutputTimeoutMs, base.codexFirstOutputTimeoutMs), ollamaModel: pickOverlayNullable(override.ollamaModel, base.ollamaModel), openaiModel: pickOverlayNullable(override.openaiModel, base.openaiModel), openaiCompatibleModel: pickOverlayNullable(override.openaiCompatibleModel, base.openaiCompatibleModel), @@ -3383,6 +3400,10 @@ function selfHostAiModelPresent(config: SelfHostAiModelConfig): boolean { config.claudeEffort !== null || config.codexModel !== null || config.codexEffort !== null || + config.claudeTimeoutMs !== null || + config.codexTimeoutMs !== null || + config.claudeFirstOutputTimeoutMs !== null || + config.codexFirstOutputTimeoutMs !== null || config.ollamaModel !== null || config.openaiModel !== null || config.openaiCompatibleModel !== null || @@ -3390,11 +3411,11 @@ function selfHostAiModelPresent(config: SelfHostAiModelConfig): boolean { ); } -/** Parse `review.ai_model` — per-repo self-host reviewer model/effort overrides. Values are opaque, bounded, - * public-safe strings (like `review.tone`) — never validated against a fixed model/effort enum here, so this - * parser never drifts from the provider's own effort allowlist (`src/selfhost/ai.ts`); an invalid effort value - * degrades the SAME way an invalid env-sourced one already does (falls back to "medium" at resolve time). - * (#selfhost-ai-model-override) */ +/** Parse `review.ai_model` — per-repo self-host reviewer model/effort/timeout overrides. Model/effort values are + * opaque, bounded, public-safe strings (like `review.tone`) — never validated against a fixed model/effort enum + * here, so this parser never drifts from the provider's own effort allowlist (`src/selfhost/ai.ts`); an invalid + * effort value degrades the SAME way an invalid env-sourced one already does (falls back to "medium" at resolve + * time). Timeout fields are positive whole-number milliseconds (#8364). (#selfhost-ai-model-override) */ function parseSelfHostAiModelConfig(value: JsonValue | undefined, warnings: string[]): SelfHostAiModelConfig { if (value === undefined || value === null) return { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }; if (typeof value !== "object" || Array.isArray(value)) { @@ -3407,6 +3428,18 @@ function parseSelfHostAiModelConfig(value: JsonValue | undefined, warnings: stri claudeEffort: parsePublicSafeText(record.claude_effort, "review.ai_model.claude_effort", warnings), codexModel: parsePublicSafeText(record.codex_model, "review.ai_model.codex_model", warnings), codexEffort: parsePublicSafeText(record.codex_effort, "review.ai_model.codex_effort", warnings), + claudeTimeoutMs: normalizeOptionalPositiveInteger(record.claude_timeout_ms, "review.ai_model.claude_timeout_ms", warnings), + codexTimeoutMs: normalizeOptionalPositiveInteger(record.codex_timeout_ms, "review.ai_model.codex_timeout_ms", warnings), + claudeFirstOutputTimeoutMs: normalizeOptionalPositiveInteger( + record.claude_first_output_timeout_ms, + "review.ai_model.claude_first_output_timeout_ms", + warnings, + ), + codexFirstOutputTimeoutMs: normalizeOptionalPositiveInteger( + record.codex_first_output_timeout_ms, + "review.ai_model.codex_first_output_timeout_ms", + warnings, + ), ollamaModel: parsePublicSafeText(record.ollama_model, "review.ai_model.ollama_model", warnings), openaiModel: parsePublicSafeText(record.openai_model, "review.ai_model.openai_model", warnings), openaiCompatibleModel: parsePublicSafeText(record.openai_compatible_model, "review.ai_model.openai_compatible_model", warnings), @@ -3916,6 +3949,14 @@ export function reviewConfigToJson(review: FocusManifestReviewConfig): JsonValue if (review.aiModel.claudeEffort !== null) aiModel.claude_effort = review.aiModel.claudeEffort; if (review.aiModel.codexModel !== null) aiModel.codex_model = review.aiModel.codexModel; if (review.aiModel.codexEffort !== null) aiModel.codex_effort = review.aiModel.codexEffort; + if (review.aiModel.claudeTimeoutMs !== null) aiModel.claude_timeout_ms = review.aiModel.claudeTimeoutMs; + if (review.aiModel.codexTimeoutMs !== null) aiModel.codex_timeout_ms = review.aiModel.codexTimeoutMs; + if (review.aiModel.claudeFirstOutputTimeoutMs !== null) { + aiModel.claude_first_output_timeout_ms = review.aiModel.claudeFirstOutputTimeoutMs; + } + if (review.aiModel.codexFirstOutputTimeoutMs !== null) { + aiModel.codex_first_output_timeout_ms = review.aiModel.codexFirstOutputTimeoutMs; + } if (review.aiModel.ollamaModel !== null) aiModel.ollama_model = review.aiModel.ollamaModel; if (review.aiModel.openaiModel !== null) aiModel.openai_model = review.aiModel.openaiModel; if (review.aiModel.openaiCompatibleModel !== null) aiModel.openai_compatible_model = review.aiModel.openaiCompatibleModel; diff --git a/src/queue/ai-review-orchestration.ts b/src/queue/ai-review-orchestration.ts index ad0c4ac201..22f2c09bc9 100644 --- a/src/queue/ai-review-orchestration.ts +++ b/src/queue/ai-review-orchestration.ts @@ -690,13 +690,17 @@ export async function runAiReviewForAdvisory( onMerge: args.settings.aiReviewOnMerge ?? undefined, reviewers: args.settings.aiReviewReviewers ?? undefined, securityFocus: args.reviewSecurityFocus === true, - // Self-host per-repo model/effort override (#selfhost-ai-model-override): absent/null fields fall through - // runLoopOverAiReview -> runWorkersOpinion -> the self-host provider's own global-env/hardcoded default, - // exactly as if review.ai_model had never been set. + // Self-host per-repo model/effort/timeout override (#selfhost-ai-model-override, #8364): absent/null + // fields fall through runLoopOverAiReview -> runWorkersOpinion -> the self-host provider's own + // global-env/hardcoded default, exactly as if review.ai_model had never been set. claudeModel: args.reviewSelfHostAiModel?.claudeModel ?? null, claudeEffort: args.reviewSelfHostAiModel?.claudeEffort ?? null, codexModel: args.reviewSelfHostAiModel?.codexModel ?? null, codexEffort: args.reviewSelfHostAiModel?.codexEffort ?? null, + claudeTimeoutMs: args.reviewSelfHostAiModel?.claudeTimeoutMs ?? null, + codexTimeoutMs: args.reviewSelfHostAiModel?.codexTimeoutMs ?? null, + claudeFirstOutputTimeoutMs: args.reviewSelfHostAiModel?.claudeFirstOutputTimeoutMs ?? null, + codexFirstOutputTimeoutMs: args.reviewSelfHostAiModel?.codexFirstOutputTimeoutMs ?? null, ollamaModel: args.reviewSelfHostAiModel?.ollamaModel ?? null, openaiModel: args.reviewSelfHostAiModel?.openaiModel ?? null, openaiCompatibleModel: args.reviewSelfHostAiModel?.openaiCompatibleModel ?? null, diff --git a/src/review/ai-review-cache-input.ts b/src/review/ai-review-cache-input.ts index bc12fb23ae..153f25bbdd 100644 --- a/src/review/ai-review-cache-input.ts +++ b/src/review/ai-review-cache-input.ts @@ -9,11 +9,12 @@ import { sha256Hex } from "../utils/crypto"; // gained an `impactMap` member. Bumped v3→v4 (#3902): `selfHostAiModelOverride` gained ollamaModel/openaiModel/ // openaiCompatibleModel/anthropicModel members. Bumped v4→v5: added a top-level `body` member (the PR // description is threaded into the reviewer prompt exactly like `title`, but was never fingerprinted -- an -// edited-description webhook with an unchanged head SHA silently replayed the pre-edit review). Every prior -// cached review's fingerprint was computed without that key, so bumping the version guarantees a clean cache -// miss on the first review after upgrade rather than silently reusing a hash computed under a different payload -// shape. -export const AI_REVIEW_CACHE_INPUT_VERSION = "ai-review-input:v5"; +// edited-description webhook with an unchanged head SHA silently replayed the pre-edit review). Bumped v5→v6 +// (#8364): `selfHostAiModelOverride` gained claudeTimeoutMs/codexTimeoutMs/claudeFirstOutputTimeoutMs/ +// codexFirstOutputTimeoutMs members. Every prior cached review's fingerprint was computed without those keys, +// so bumping the version guarantees a clean cache miss on the first review after upgrade rather than silently +// reusing a hash computed under a different payload shape. +export const AI_REVIEW_CACHE_INPUT_VERSION = "ai-review-input:v6"; // #regate-churn (root cause, confirmed in production): this fingerprint USED to also hash the PR's live // `baseSha`, on the theory that a rebase/retarget can change the diff GitHub reports for an otherwise-unchanged @@ -181,6 +182,10 @@ export async function aiReviewCacheInputFingerprint(input: AiReviewCacheInput): claudeEffort: input.selfHostAiModelOverride.claudeEffort ?? null, codexModel: input.selfHostAiModelOverride.codexModel ?? null, codexEffort: input.selfHostAiModelOverride.codexEffort ?? null, + claudeTimeoutMs: input.selfHostAiModelOverride.claudeTimeoutMs ?? null, + codexTimeoutMs: input.selfHostAiModelOverride.codexTimeoutMs ?? null, + claudeFirstOutputTimeoutMs: input.selfHostAiModelOverride.claudeFirstOutputTimeoutMs ?? null, + codexFirstOutputTimeoutMs: input.selfHostAiModelOverride.codexFirstOutputTimeoutMs ?? null, ollamaModel: input.selfHostAiModelOverride.ollamaModel ?? null, openaiModel: input.selfHostAiModelOverride.openaiModel ?? null, openaiCompatibleModel: input.selfHostAiModelOverride.openaiCompatibleModel ?? null, diff --git a/src/selfhost/ai.ts b/src/selfhost/ai.ts index 9157c8254a..16ab184d75 100644 --- a/src/selfhost/ai.ts +++ b/src/selfhost/ai.ts @@ -50,16 +50,20 @@ interface AiRunOptions { // Callers with no retry loop of their own (a single ai.run() call) leave this unset, so their one attempt IS // final and stays loud, unchanged from before this field existed. finalAttempt?: boolean; - // `.loopover.yml` `review.ai_model` (#selfhost-ai-model-override): per-repo override for the subscription + // `.loopover.yml` `review.ai_model` (#selfhost-ai-model-override, #8364): per-repo override for the subscription // CLI providers, resolved by the caller from the repo's manifest and forwarded here so this file makes no // manifest fetch of its own. Each field is read ONLY by its matching provider's `.run()` (claude-code reads - // the claude* pair, codex reads the codex* pair) and takes priority over that provider's global env var, which + // the claude* fields, codex reads the codex* fields) and takes priority over that provider's global env var, which // in turn still wins over this file's own hardcoded default. Absent ⇒ byte-identical to today (global env var, // then default, exactly as before this override existed). claudeModel?: string; claudeEffort?: string; codexModel?: string; codexEffort?: string; + claudeTimeoutMs?: number; + codexTimeoutMs?: number; + claudeFirstOutputTimeoutMs?: number; + codexFirstOutputTimeoutMs?: number; // Same override mechanism, extended to the HTTP-API providers (#3902) -- ollama/openai/openai-compatible/ // anthropic previously had no way to see a per-repo override at all (their model was resolved ONCE from the // global env var at buildProvider() construction time, before any repo was known). Read per-call, same @@ -207,12 +211,37 @@ function resolveCliTimeoutFrom(configured: string | undefined, effort: string, e return effortTimeoutMs[effort]!; } -export function resolveClaudeCliTimeoutMs(env: Record): number { - return resolveCliTimeoutFrom(firstConfigured(env.CLAUDE_AI_TIMEOUT_MS), resolveEffort(firstConfigured(env.CLAUDE_AI_EFFORT)), CLAUDE_EFFORT_TIMEOUT_MS); +/** Prefer a positive finite per-repo override (#8364) over the env-var string; absent/invalid ⇒ env path. */ +function configuredTimeoutMsString( + repoOverrideMs: number | null | undefined, + envValue: string | undefined, +): string | undefined { + if (typeof repoOverrideMs === "number" && Number.isFinite(repoOverrideMs) && repoOverrideMs > 0) { + return String(repoOverrideMs); + } + return firstConfigured(envValue); } -export function resolveCodexCliTimeoutMs(env: Record): number { - return resolveCliTimeoutFrom(firstConfigured(env.CODEX_AI_TIMEOUT_MS), resolveCodexEffort(firstConfigured(env.CODEX_AI_EFFORT)), CODEX_EFFORT_TIMEOUT_MS); +export function resolveClaudeCliTimeoutMs( + env: Record, + repoOverrideMs?: number | null | undefined, +): number { + return resolveCliTimeoutFrom( + configuredTimeoutMsString(repoOverrideMs, env.CLAUDE_AI_TIMEOUT_MS), + resolveEffort(firstConfigured(env.CLAUDE_AI_EFFORT)), + CLAUDE_EFFORT_TIMEOUT_MS, + ); +} + +export function resolveCodexCliTimeoutMs( + env: Record, + repoOverrideMs?: number | null | undefined, +): number { + return resolveCliTimeoutFrom( + configuredTimeoutMsString(repoOverrideMs, env.CODEX_AI_TIMEOUT_MS), + resolveCodexEffort(firstConfigured(env.CODEX_AI_EFFORT)), + CODEX_EFFORT_TIMEOUT_MS, + ); } // Fast-fail deadline for Codex's "Reading prompt from stdin..." hang (GITTENSORY-K/GITTENSORY-M): observed in prod @@ -231,8 +260,11 @@ export function resolveCodexCliTimeoutMs(env: Record // effort makes a COMPLETION take longer, it does not make the CLI slower to print its FIRST stdout byte, so this // must not scale with effort the way the full timeout does. Bounds mirror resolveCliTimeoutFrom's floor but cap // well under the shortest full timeout (120_000ms) so this can never itself become the effective timeout. -export function resolveCodexFirstOutputTimeoutMs(env: Record): number { - const raw = Number(firstConfigured(env.CODEX_AI_FIRST_OUTPUT_TIMEOUT_MS)); +export function resolveCodexFirstOutputTimeoutMs( + env: Record, + repoOverrideMs?: number | null | undefined, +): number { + const raw = Number(configuredTimeoutMsString(repoOverrideMs, env.CODEX_AI_FIRST_OUTPUT_TIMEOUT_MS)); if (Number.isFinite(raw) && raw > 0) return Math.min(120_000, Math.max(1_000, raw)); return 30_000; } @@ -251,8 +283,11 @@ export function resolveCodexFirstOutputTimeoutMs(env: Record): number { - const raw = Number(firstConfigured(env.CLAUDE_AI_FIRST_OUTPUT_TIMEOUT_MS)); +export function resolveClaudeFirstOutputTimeoutMs( + env: Record, + repoOverrideMs?: number | null | undefined, +): number { + const raw = Number(configuredTimeoutMsString(repoOverrideMs, env.CLAUDE_AI_FIRST_OUTPUT_TIMEOUT_MS)); if (Number.isFinite(raw) && raw > 0) return Math.min(1_800_000, Math.max(1_000, raw)); return 1_800_000; } @@ -930,11 +965,14 @@ export function createClaudeCodeAi(parentEnv: Record const token = parentEnv.CLAUDE_CODE_OAUTH_TOKEN; const claudeModel = resolveModel(configuredClaudeModel(parentEnv, options.claudeModel), model, "claude-sonnet-5"); const effort = resolveEffort(firstConfigured(options.claudeEffort, parentEnv.CLAUDE_AI_EFFORT)); - const timeoutMs = resolveClaudeCliTimeoutMs(parentEnv); + const timeoutMs = resolveClaudeCliTimeoutMs(parentEnv, options.claudeTimeoutMs); // #4994: same clamp reasoning as createCodexAi's identical line — keeps the fast-fail deadline strictly // below the full timeout even if a low CLAUDE_AI_TIMEOUT_MS override (floor 30_000ms) would otherwise let // them collide, which would make the "outer" timeout unreachable and defeat having two distinct signals. - const firstOutputTimeoutMs = Math.min(resolveClaudeFirstOutputTimeoutMs(parentEnv), Math.max(1, timeoutMs - 1)); + const firstOutputTimeoutMs = Math.min( + resolveClaudeFirstOutputTimeoutMs(parentEnv, options.claudeFirstOutputTimeoutMs), + Math.max(1, timeoutMs - 1), + ); let attempted = false; let stdoutForMetrics = ""; try { @@ -1049,11 +1087,14 @@ export function createCodexAi( // configured: otherwise Codex selects the account default. const codexModel = resolveModel(configuredCodexModel(parentEnv, options.codexModel), model, ""); const effort = resolveCodexEffort(firstConfigured(options.codexEffort, parentEnv.CODEX_AI_EFFORT)); - const timeoutMs = resolveCodexCliTimeoutMs(parentEnv); + const timeoutMs = resolveCodexCliTimeoutMs(parentEnv, options.codexTimeoutMs); // Clamp below timeoutMs so a misconfigured/low CODEX_AI_TIMEOUT_MS (its own floor is 30_000ms, the same as // this deadline's default) can never make the fast-fail deadline equal or exceed the outer safety net — // that would make the "outer" timeout unreachable and defeat the point of having two distinct signals. - const firstOutputTimeoutMs = Math.min(resolveCodexFirstOutputTimeoutMs(parentEnv), Math.max(1, timeoutMs - 1)); + const firstOutputTimeoutMs = Math.min( + resolveCodexFirstOutputTimeoutMs(parentEnv, options.codexFirstOutputTimeoutMs), + Math.max(1, timeoutMs - 1), + ); let attempted = false; let stdoutForMetrics = ""; try { diff --git a/src/services/ai-review.ts b/src/services/ai-review.ts index 1ea7c357c9..fd2ea13015 100644 --- a/src/services/ai-review.ts +++ b/src/services/ai-review.ts @@ -273,16 +273,21 @@ export type LoopOverAiReviewInput = { */ securityFocus?: boolean | undefined; /** - * `.loopover.yml` `review.ai_model` (#selfhost-ai-model-override), resolved by the caller from the - * (already-cached) manifest. Self-host only — overrides that repo's claude-code/codex model+effort for THIS - * review, taking priority over the operator's global CLAUDE_AI_MODEL/CLAUDE_AI_EFFORT/CODEX_AI_MODEL/ - * CODEX_AI_EFFORT env vars. A hosted (Workers-AI) `env.AI` ignores these fields entirely. Absent/null ⇒ - * byte-identical to today (global env var, then the provider's own default). + * `.loopover.yml` `review.ai_model` (#selfhost-ai-model-override, #8364), resolved by the caller from the + * (already-cached) manifest. Self-host only — overrides that repo's claude-code/codex model+effort+timeout for + * THIS review, taking priority over the operator's global CLAUDE_AI_MODEL/CLAUDE_AI_EFFORT/CODEX_AI_MODEL/ + * CODEX_AI_EFFORT/CLAUDE_AI_TIMEOUT_MS/CODEX_AI_TIMEOUT_MS/CLAUDE_AI_FIRST_OUTPUT_TIMEOUT_MS/ + * CODEX_AI_FIRST_OUTPUT_TIMEOUT_MS env vars. A hosted (Workers-AI) `env.AI` ignores these fields entirely. + * Absent/null ⇒ byte-identical to today (global env var, then the provider's own default). */ claudeModel?: string | null | undefined; claudeEffort?: string | null | undefined; codexModel?: string | null | undefined; codexEffort?: string | null | undefined; + claudeTimeoutMs?: number | null | undefined; + codexTimeoutMs?: number | null | undefined; + claudeFirstOutputTimeoutMs?: number | null | undefined; + codexFirstOutputTimeoutMs?: number | null | undefined; /** * Same override mechanism, extended to the HTTP-API self-host providers (#3902): overrides * OLLAMA_AI_MODEL/OPENAI_AI_MODEL/OPENAI_COMPATIBLE_AI_MODEL/ANTHROPIC_AI_MODEL for THIS repo. A hosted @@ -1052,11 +1057,12 @@ function buildScreenshotEvidenceSystemAppend(screenshotEvidenceSummary: string | /** Correlation + per-repo override context forwarded to `env.AI.run`'s options. `jobId`/`repoFullName`/ * `pullNumber` (#codex-timeout-fields) are purely observational — a self-host provider-failure log, never read - * by any provider's own request logic. `claudeModel`/`claudeEffort`/`codexModel`/`codexEffort` and - * `ollamaModel`/`openaiModel`/`openaiCompatibleModel`/`anthropicModel` (#selfhost-ai-model-override, #3902) are - * the exception: the matching self-host provider DOES read its own field to pick the model (+ effort, for the - * CLI providers) for THIS repo, taking priority over that provider's global env var. All self-host-only; a - * hosted (Workers-AI) `env.AI` ignores every field here. */ + * by any provider's own request logic. `claudeModel`/`claudeEffort`/`codexModel`/`codexEffort`/ + * `claudeTimeoutMs`/`codexTimeoutMs`/`claudeFirstOutputTimeoutMs`/`codexFirstOutputTimeoutMs` and + * `ollamaModel`/`openaiModel`/`openaiCompatibleModel`/`anthropicModel` (#selfhost-ai-model-override, #3902, + * #8364) are the exception: the matching self-host provider DOES read its own fields to pick the model (+ + * effort/timeout, for the CLI providers) for THIS repo, taking priority over that provider's global env var. + * All self-host-only; a hosted (Workers-AI) `env.AI` ignores every field here. */ type AiRunCorrelation = { jobId?: string | undefined; repoFullName?: string | undefined; @@ -1065,6 +1071,10 @@ type AiRunCorrelation = { claudeEffort?: string | undefined; codexModel?: string | undefined; codexEffort?: string | undefined; + claudeTimeoutMs?: number | undefined; + codexTimeoutMs?: number | undefined; + claudeFirstOutputTimeoutMs?: number | undefined; + codexFirstOutputTimeoutMs?: number | undefined; ollamaModel?: string | undefined; openaiModel?: string | undefined; openaiCompatibleModel?: string | undefined; @@ -1178,6 +1188,14 @@ async function runWorkersOpinion( ...(correlation?.claudeEffort !== undefined ? { claudeEffort: correlation.claudeEffort } : {}), ...(correlation?.codexModel !== undefined ? { codexModel: correlation.codexModel } : {}), ...(correlation?.codexEffort !== undefined ? { codexEffort: correlation.codexEffort } : {}), + ...(correlation?.claudeTimeoutMs !== undefined ? { claudeTimeoutMs: correlation.claudeTimeoutMs } : {}), + ...(correlation?.codexTimeoutMs !== undefined ? { codexTimeoutMs: correlation.codexTimeoutMs } : {}), + ...(correlation?.claudeFirstOutputTimeoutMs !== undefined + ? { claudeFirstOutputTimeoutMs: correlation.claudeFirstOutputTimeoutMs } + : {}), + ...(correlation?.codexFirstOutputTimeoutMs !== undefined + ? { codexFirstOutputTimeoutMs: correlation.codexFirstOutputTimeoutMs } + : {}), ...(correlation?.ollamaModel !== undefined ? { ollamaModel: correlation.ollamaModel } : {}), ...(correlation?.openaiModel !== undefined ? { openaiModel: correlation.openaiModel } : {}), ...(correlation?.openaiCompatibleModel !== undefined ? { openaiCompatibleModel: correlation.openaiCompatibleModel } : {}), @@ -2314,8 +2332,9 @@ export async function runLoopOverAiReview( const reviewDiagnostics: AiReviewDiagnostic[] = []; const fallbackNotes: string[] = []; // jobId/repoFullName/pullNumber: forwarded to a self-host provider's failure log (#codex-timeout-fields) — - // never anything BYOK-billed reads. claudeModel/claudeEffort/codexModel/codexEffort (#selfhost-ai-model- - // override): the per-repo manifest override, read by the matching self-host provider's own request logic. + // never anything BYOK-billed reads. claudeModel/claudeEffort/codexModel/codexEffort/claudeTimeoutMs/ + // codexTimeoutMs/claudeFirstOutputTimeoutMs/codexFirstOutputTimeoutMs (#selfhost-ai-model-override, #8364): + // the per-repo manifest override, read by the matching self-host provider's own request logic. const aiRunCorrelation: AiRunCorrelation = { jobId: input.jobId, repoFullName: input.repoFullName, @@ -2324,6 +2343,10 @@ export async function runLoopOverAiReview( claudeEffort: input.claudeEffort ?? undefined, codexModel: input.codexModel ?? undefined, codexEffort: input.codexEffort ?? undefined, + claudeTimeoutMs: input.claudeTimeoutMs ?? undefined, + codexTimeoutMs: input.codexTimeoutMs ?? undefined, + claudeFirstOutputTimeoutMs: input.claudeFirstOutputTimeoutMs ?? undefined, + codexFirstOutputTimeoutMs: input.codexFirstOutputTimeoutMs ?? undefined, ollamaModel: input.ollamaModel ?? undefined, openaiModel: input.openaiModel ?? undefined, openaiCompatibleModel: input.openaiCompatibleModel ?? undefined, diff --git a/test/unit/ai-review-cache-input.test.ts b/test/unit/ai-review-cache-input.test.ts index 3e849bb095..9328fb8687 100644 --- a/test/unit/ai-review-cache-input.test.ts +++ b/test/unit/ai-review-cache-input.test.ts @@ -246,7 +246,7 @@ describe("aiReviewCacheInputFingerprint", () => { for (const key of ["ollamaModel", "openaiModel", "openaiCompatibleModel", "anthropicModel"] as const) { const changed = await aiReviewCacheInputFingerprint({ ...baseInput(), - selfHostAiModelOverride: { claudeModel: null, claudeEffort: null, codexModel: null, codexEffort: null, ollamaModel: null, openaiModel: null, openaiCompatibleModel: null, anthropicModel: null, [key]: "repo-override-model" }, + selfHostAiModelOverride: { claudeModel: null, claudeEffort: null, codexModel: null, codexEffort: null, claudeTimeoutMs: null, codexTimeoutMs: null, claudeFirstOutputTimeoutMs: null, codexFirstOutputTimeoutMs: null, ollamaModel: null, openaiModel: null, openaiCompatibleModel: null, anthropicModel: null, [key]: "repo-override-model" }, }); expect(changed, key).not.toBe(original); } diff --git a/test/unit/ai-review.test.ts b/test/unit/ai-review.test.ts index 9e582cc859..93365b9dae 100644 --- a/test/unit/ai-review.test.ts +++ b/test/unit/ai-review.test.ts @@ -425,17 +425,43 @@ describe("review.profile shapes the reviewer system prompt (#review-profile)", ( claudeEffort: "low", codexModel: "gpt-5.4-mini", codexEffort: "high", + claudeTimeoutMs: 240_000, + codexTimeoutMs: 180_000, + claudeFirstOutputTimeoutMs: 60_000, + codexFirstOutputTimeoutMs: 15_000, }); expect(options).toMatchObject({ claudeModel: "claude-haiku-4-5", claudeEffort: "low", codexModel: "gpt-5.4-mini", codexEffort: "high", + claudeTimeoutMs: 240_000, + codexTimeoutMs: 180_000, + claudeFirstOutputTimeoutMs: 60_000, + codexFirstOutputTimeoutMs: 15_000, }); // Absent/null override fields are OMITTED, not present-as-undefined — byte-identical to before this knob existed. - const withNull = await runWithOverride({ claudeModel: null, claudeEffort: null, codexModel: null, codexEffort: null }); + const withNull = await runWithOverride({ + claudeModel: null, + claudeEffort: null, + codexModel: null, + codexEffort: null, + claudeTimeoutMs: null, + codexTimeoutMs: null, + claudeFirstOutputTimeoutMs: null, + codexFirstOutputTimeoutMs: null, + }); const withAbsent = await runWithOverride({}); - for (const key of ["claudeModel", "claudeEffort", "codexModel", "codexEffort"]) { + for (const key of [ + "claudeModel", + "claudeEffort", + "codexModel", + "codexEffort", + "claudeTimeoutMs", + "codexTimeoutMs", + "claudeFirstOutputTimeoutMs", + "codexFirstOutputTimeoutMs", + ]) { expect(withNull).not.toHaveProperty(key); expect(withAbsent).not.toHaveProperty(key); } diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index 6ef8c3fd71..d7fb0043d0 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -4885,7 +4885,7 @@ describe("review.auto_review (#1954 / #2038–#2041)", () => { }); describe("review.ai_model (#selfhost-ai-model-override)", () => { - it("parses all eight knobs, marks present, and round-trips", () => { + it("parses all twelve knobs, marks present, and round-trips", () => { const m = parseFocusManifest({ review: { ai_model: { @@ -4893,6 +4893,10 @@ describe("review.ai_model (#selfhost-ai-model-override)", () => { claude_effort: "high", codex_model: "gpt-5.5-pro", codex_effort: "xhigh", + claude_timeout_ms: 240_000, + codex_timeout_ms: 300_000, + claude_first_output_timeout_ms: 60_000, + codex_first_output_timeout_ms: 15_000, ollama_model: "llama3.3", openai_model: "gpt-5.5", openai_compatible_model: "qwen2.5-coder", @@ -4905,6 +4909,10 @@ describe("review.ai_model (#selfhost-ai-model-override)", () => { claudeEffort: "high", codexModel: "gpt-5.5-pro", codexEffort: "xhigh", + claudeTimeoutMs: 240_000, + codexTimeoutMs: 300_000, + claudeFirstOutputTimeoutMs: 60_000, + codexFirstOutputTimeoutMs: 15_000, ollamaModel: "llama3.3", openaiModel: "gpt-5.5", openaiCompatibleModel: "qwen2.5-coder", @@ -4914,6 +4922,32 @@ describe("review.ai_model (#selfhost-ai-model-override)", () => { expect(parseFocusManifest({ review: reviewConfigToJson(m.review) }).review.aiModel).toEqual(m.review.aiModel); }); + it("parses each of the four CLI timeout knobs independently (#8364)", () => { + for (const [key, camelKey, value] of [ + ["claude_timeout_ms", "claudeTimeoutMs", 120_000], + ["codex_timeout_ms", "codexTimeoutMs", 90_000], + ["claude_first_output_timeout_ms", "claudeFirstOutputTimeoutMs", 45_000], + ["codex_first_output_timeout_ms", "codexFirstOutputTimeoutMs", 12_000], + ] as const) { + const m = parseFocusManifest({ review: { ai_model: { [key]: value } } }); + expect(m.review.aiModel).toEqual({ ...EMPTY_SELF_HOST_AI_MODEL_CONFIG, [camelKey]: value }); + expect(m.review.present).toBe(true); + expect(reviewConfigToJson(m.review)).toEqual({ ai_model: { [key]: value } }); + } + }); + + it("ignores a non-positive timeout_ms with a warning (#8364)", () => { + const m = parseFocusManifest({ + review: { ai_model: { claude_timeout_ms: 0, codex_timeout_ms: -1, claude_first_output_timeout_ms: 1.5 } }, + }); + expect(m.review.aiModel.claudeTimeoutMs).toBeNull(); + expect(m.review.aiModel.codexTimeoutMs).toBeNull(); + expect(m.review.aiModel.claudeFirstOutputTimeoutMs).toBeNull(); + expect(m.warnings.some((w) => /claude_timeout_ms.*positive whole number/.test(w))).toBe(true); + expect(m.warnings.some((w) => /codex_timeout_ms.*positive whole number/.test(w))).toBe(true); + expect(m.warnings.some((w) => /claude_first_output_timeout_ms.*positive whole number/.test(w))).toBe(true); + }); + it("parses each of the four HTTP-API provider knobs independently (#3902)", () => { for (const [key, camelKey] of [ ["ollama_model", "ollamaModel"], diff --git a/test/unit/queue-2.test.ts b/test/unit/queue-2.test.ts index 66523f5f02..04669ca4bf 100644 --- a/test/unit/queue-2.test.ts +++ b/test/unit/queue-2.test.ts @@ -256,7 +256,7 @@ describe("queue processors", () => { gatePack: "oss-anti-slop", reviewerPlan: undefined, selfHostProviderConfig: null, - selfHostAiModelOverride: { claudeModel: null, claudeEffort: null, codexModel: null, codexEffort: null, ollamaModel: null, openaiModel: null, openaiCompatibleModel: null, anthropicModel: null }, + selfHostAiModelOverride: { claudeModel: null, claudeEffort: null, codexModel: null, codexEffort: null, claudeTimeoutMs: null, codexTimeoutMs: null, claudeFirstOutputTimeoutMs: null, codexFirstOutputTimeoutMs: null, ollamaModel: null, openaiModel: null, openaiCompatibleModel: null, anthropicModel: null }, reviewFiles: [{ path: "src/a.ts", status: "modified", patch: "@@\n+export const ok = value.length;", additions: 1, deletions: 0 }], profile: null, securityFocus: false, diff --git a/test/unit/queue-4.test.ts b/test/unit/queue-4.test.ts index 3ef503f022..0587d1ba3b 100644 --- a/test/unit/queue-4.test.ts +++ b/test/unit/queue-4.test.ts @@ -1299,10 +1299,10 @@ describe("queue processors", () => { // comment) -- this assertion only cares that a real, current-shape fingerprint was computed and threaded // through, not the exact version number, so it is updated to the new version rather than left pinned to // the pre-fix one. - expect(cacheReadSpy.mock.calls[0]?.[5]).toMatch(/^ai-review-input:v5:/); + expect(cacheReadSpy.mock.calls[0]?.[5]).toMatch(/^ai-review-input:v6:/); expect(cacheWriteSpy).toHaveBeenCalled(); expect(cacheWriteSpy.mock.calls[0]?.[5]).toMatchObject({ - metadata: { inputFingerprint: expect.stringMatching(/^ai-review-input:v5:/) }, + metadata: { inputFingerprint: expect.stringMatching(/^ai-review-input:v6:/) }, }); cacheReadSpy.mockRestore(); cacheWriteSpy.mockRestore(); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 7d823bf2f9..1a31d6bd4e 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -4899,7 +4899,7 @@ describe("queue processors", () => { metadata: { inputFingerprint: await aiReviewCacheInputFingerprint({ title: "Clean PR", body: "Closes #1", mode: "block", byok: false, provider: null, model: null, aiReviewAllAuthors: false, - aiReviewCloseConfidence: undefined, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, gatePack: "oss-anti-slop", reviewerPlan: env.AI_REVIEW_PLAN, selfHostProviderConfig: null, selfHostAiModelOverride: { claudeModel: null, claudeEffort: null, codexModel: null, codexEffort: null, ollamaModel: null, openaiModel: null, openaiCompatibleModel: null, anthropicModel: null }, + aiReviewCloseConfidence: undefined, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, gatePack: "oss-anti-slop", reviewerPlan: env.AI_REVIEW_PLAN, selfHostProviderConfig: null, selfHostAiModelOverride: { claudeModel: null, claudeEffort: null, codexModel: null, codexEffort: null, claudeTimeoutMs: null, codexTimeoutMs: null, claudeFirstOutputTimeoutMs: null, codexFirstOutputTimeoutMs: null, ollamaModel: null, openaiModel: null, openaiCompatibleModel: null, anthropicModel: null }, reviewFiles: [{ path: "src/a.ts", status: "modified", patch: "@@\n+export const ok = true;", additions: 1, deletions: 0 }], profile: null, securityFocus: false, inlineComments: false, pathInstructions: [], pathGuidance: "", repoInstructions: null, excludePaths: [], pathFilters: [], changedPaths: ["src/a.ts"], features: { grounding: false, rag: false, enrichment: false, reputation: false, cultureProfile: false, impactMap: false }, @@ -5604,7 +5604,7 @@ describe("queue processors", () => { aiReviewReviewers: null, gatePack: "oss-anti-slop", reviewerPlan: env.AI_REVIEW_PLAN, - selfHostProviderConfig: null, selfHostAiModelOverride: { claudeModel: null, claudeEffort: null, codexModel: null, codexEffort: null, ollamaModel: null, openaiModel: null, openaiCompatibleModel: null, anthropicModel: null }, + selfHostProviderConfig: null, selfHostAiModelOverride: { claudeModel: null, claudeEffort: null, codexModel: null, codexEffort: null, claudeTimeoutMs: null, codexTimeoutMs: null, claudeFirstOutputTimeoutMs: null, codexFirstOutputTimeoutMs: null, ollamaModel: null, openaiModel: null, openaiCompatibleModel: null, anthropicModel: null }, reviewFiles: [{ path: "src/a.ts", status: "modified", patch: "@@\n+export const ok = true;", additions: 1, deletions: 0 }], profile: null, securityFocus: false, @@ -5685,7 +5685,7 @@ describe("queue processors", () => { metadata: { inputFingerprint: await aiReviewCacheInputFingerprint({ title: "Current PR", body: "Closes #1", mode: "block", byok: false, provider: null, model: null, aiReviewAllAuthors: false, - aiReviewCloseConfidence: undefined, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, gatePack: "oss-anti-slop", reviewerPlan: env.AI_REVIEW_PLAN, selfHostProviderConfig: null, selfHostAiModelOverride: { claudeModel: null, claudeEffort: null, codexModel: null, codexEffort: null, ollamaModel: null, openaiModel: null, openaiCompatibleModel: null, anthropicModel: null }, + aiReviewCloseConfidence: undefined, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, gatePack: "oss-anti-slop", reviewerPlan: env.AI_REVIEW_PLAN, selfHostProviderConfig: null, selfHostAiModelOverride: { claudeModel: null, claudeEffort: null, codexModel: null, codexEffort: null, claudeTimeoutMs: null, codexTimeoutMs: null, claudeFirstOutputTimeoutMs: null, codexFirstOutputTimeoutMs: null, ollamaModel: null, openaiModel: null, openaiCompatibleModel: null, anthropicModel: null }, reviewFiles: [{ path: "src/a.ts", status: "modified", patch: "@@\n+export const ok = true;", additions: 1, deletions: 0 }], profile: null, securityFocus: false, inlineComments: false, pathInstructions: [], pathGuidance: "", repoInstructions: null, excludePaths: [], pathFilters: [], changedPaths: ["src/a.ts"], features: { grounding: false, rag: false, enrichment: false, reputation: false, cultureProfile: false, impactMap: false }, @@ -5742,7 +5742,7 @@ describe("queue processors", () => { metadata: { inputFingerprint: await aiReviewCacheInputFingerprint({ title: "Current PR", body: "Closes #1", mode: "block", byok: false, provider: null, model: null, aiReviewAllAuthors: false, - aiReviewCloseConfidence: undefined, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, gatePack: "oss-anti-slop", reviewerPlan: env.AI_REVIEW_PLAN, selfHostProviderConfig: null, selfHostAiModelOverride: { claudeModel: null, claudeEffort: null, codexModel: null, codexEffort: null, ollamaModel: null, openaiModel: null, openaiCompatibleModel: null, anthropicModel: null }, + aiReviewCloseConfidence: undefined, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, gatePack: "oss-anti-slop", reviewerPlan: env.AI_REVIEW_PLAN, selfHostProviderConfig: null, selfHostAiModelOverride: { claudeModel: null, claudeEffort: null, codexModel: null, codexEffort: null, claudeTimeoutMs: null, codexTimeoutMs: null, claudeFirstOutputTimeoutMs: null, codexFirstOutputTimeoutMs: null, ollamaModel: null, openaiModel: null, openaiCompatibleModel: null, anthropicModel: null }, reviewFiles: [{ path: "src/a.ts", status: "modified", patch: "@@\n+export const ok = true;", additions: 1, deletions: 0 }], profile: null, securityFocus: false, inlineComments: false, pathInstructions: [], pathGuidance: "", repoInstructions: null, excludePaths: [], pathFilters: [], changedPaths: ["src/a.ts"], features: { grounding: false, rag: false, enrichment: false, reputation: false, cultureProfile: false, impactMap: false }, @@ -5798,7 +5798,7 @@ describe("queue processors", () => { metadata: { inputFingerprint: await aiReviewCacheInputFingerprint({ title: "Current PR", body: "Closes #1", mode: "block", byok: false, provider: null, model: null, aiReviewAllAuthors: false, - aiReviewCloseConfidence: undefined, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, gatePack: "oss-anti-slop", reviewerPlan: env.AI_REVIEW_PLAN, selfHostProviderConfig: null, selfHostAiModelOverride: { claudeModel: null, claudeEffort: null, codexModel: null, codexEffort: null, ollamaModel: null, openaiModel: null, openaiCompatibleModel: null, anthropicModel: null }, + aiReviewCloseConfidence: undefined, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, gatePack: "oss-anti-slop", reviewerPlan: env.AI_REVIEW_PLAN, selfHostProviderConfig: null, selfHostAiModelOverride: { claudeModel: null, claudeEffort: null, codexModel: null, codexEffort: null, claudeTimeoutMs: null, codexTimeoutMs: null, claudeFirstOutputTimeoutMs: null, codexFirstOutputTimeoutMs: null, ollamaModel: null, openaiModel: null, openaiCompatibleModel: null, anthropicModel: null }, reviewFiles: [{ path: "src/a.ts", status: "modified", patch: "@@\n+export const ok = true;", additions: 1, deletions: 0 }], profile: null, securityFocus: false, inlineComments: false, pathInstructions: [], pathGuidance: "", repoInstructions: null, excludePaths: [], pathFilters: [], changedPaths: ["src/a.ts"], features: { grounding: false, rag: false, enrichment: false, reputation: false, cultureProfile: false, impactMap: false }, @@ -5851,7 +5851,7 @@ describe("queue processors", () => { metadata: { inputFingerprint: await aiReviewCacheInputFingerprint({ title: "Current PR", body: "Closes #1", mode: "block", byok: false, provider: null, model: null, aiReviewAllAuthors: false, - aiReviewCloseConfidence: undefined, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, gatePack: "oss-anti-slop", reviewerPlan: env.AI_REVIEW_PLAN, selfHostProviderConfig: null, selfHostAiModelOverride: { claudeModel: null, claudeEffort: null, codexModel: null, codexEffort: null, ollamaModel: null, openaiModel: null, openaiCompatibleModel: null, anthropicModel: null }, + aiReviewCloseConfidence: undefined, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, gatePack: "oss-anti-slop", reviewerPlan: env.AI_REVIEW_PLAN, selfHostProviderConfig: null, selfHostAiModelOverride: { claudeModel: null, claudeEffort: null, codexModel: null, codexEffort: null, claudeTimeoutMs: null, codexTimeoutMs: null, claudeFirstOutputTimeoutMs: null, codexFirstOutputTimeoutMs: null, ollamaModel: null, openaiModel: null, openaiCompatibleModel: null, anthropicModel: null }, reviewFiles: [{ path: "src/a.ts", status: "modified", patch: "@@\n+export const ok = true;", additions: 1, deletions: 0 }], profile: null, securityFocus: false, inlineComments: false, pathInstructions: [], pathGuidance: "", repoInstructions: null, excludePaths: [], pathFilters: [], changedPaths: ["src/a.ts"], features: { grounding: false, rag: false, enrichment: false, reputation: false, cultureProfile: false, impactMap: false }, @@ -5900,7 +5900,7 @@ describe("queue processors", () => { metadata: { inputFingerprint: await aiReviewCacheInputFingerprint({ title: "Partially published PR", body: "Closes #1", mode: "block", byok: false, provider: null, model: null, aiReviewAllAuthors: false, - aiReviewCloseConfidence: undefined, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, gatePack: "oss-anti-slop", reviewerPlan: env.AI_REVIEW_PLAN, selfHostProviderConfig: null, selfHostAiModelOverride: { claudeModel: null, claudeEffort: null, codexModel: null, codexEffort: null, ollamaModel: null, openaiModel: null, openaiCompatibleModel: null, anthropicModel: null }, + aiReviewCloseConfidence: undefined, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, gatePack: "oss-anti-slop", reviewerPlan: env.AI_REVIEW_PLAN, selfHostProviderConfig: null, selfHostAiModelOverride: { claudeModel: null, claudeEffort: null, codexModel: null, codexEffort: null, claudeTimeoutMs: null, codexTimeoutMs: null, claudeFirstOutputTimeoutMs: null, codexFirstOutputTimeoutMs: null, ollamaModel: null, openaiModel: null, openaiCompatibleModel: null, anthropicModel: null }, reviewFiles: [{ path: "src/a.ts", status: "modified", patch: "@@\n+export const ok = true;", additions: 1, deletions: 0 }], profile: null, securityFocus: false, inlineComments: false, pathInstructions: [], pathGuidance: "", repoInstructions: null, excludePaths: [], pathFilters: [], changedPaths: ["src/a.ts"], features: { grounding: false, rag: false, enrichment: false, reputation: false, cultureProfile: false, impactMap: false }, @@ -5952,7 +5952,7 @@ describe("queue processors", () => { metadata: { inputFingerprint: await aiReviewCacheInputFingerprint({ title: "Current PR", body: "Closes #1", mode: "block", byok: false, provider: null, model: null, aiReviewAllAuthors: false, - aiReviewCloseConfidence: undefined, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, gatePack: "oss-anti-slop", reviewerPlan: env.AI_REVIEW_PLAN, selfHostProviderConfig: null, selfHostAiModelOverride: { claudeModel: null, claudeEffort: null, codexModel: null, codexEffort: null, ollamaModel: null, openaiModel: null, openaiCompatibleModel: null, anthropicModel: null }, + aiReviewCloseConfidence: undefined, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, gatePack: "oss-anti-slop", reviewerPlan: env.AI_REVIEW_PLAN, selfHostProviderConfig: null, selfHostAiModelOverride: { claudeModel: null, claudeEffort: null, codexModel: null, codexEffort: null, claudeTimeoutMs: null, codexTimeoutMs: null, claudeFirstOutputTimeoutMs: null, codexFirstOutputTimeoutMs: null, ollamaModel: null, openaiModel: null, openaiCompatibleModel: null, anthropicModel: null }, reviewFiles: [{ path: "src/a.ts", status: "modified", patch: "@@\n+export const ok = true;", additions: 1, deletions: 0 }], profile: null, securityFocus: false, inlineComments: false, pathInstructions: [], pathGuidance: "", repoInstructions: null, excludePaths: [], pathFilters: [], changedPaths: ["src/a.ts"], features: { grounding: false, rag: false, enrichment: false, reputation: false, cultureProfile: false, impactMap: false }, @@ -8110,7 +8110,7 @@ describe("queue processors", () => { aiReviewReviewers: null, gatePack: "oss-anti-slop", reviewerPlan: env.AI_REVIEW_PLAN, - selfHostProviderConfig: null, selfHostAiModelOverride: { claudeModel: null, claudeEffort: null, codexModel: null, codexEffort: null, ollamaModel: null, openaiModel: null, openaiCompatibleModel: null, anthropicModel: null }, + selfHostProviderConfig: null, selfHostAiModelOverride: { claudeModel: null, claudeEffort: null, codexModel: null, codexEffort: null, claudeTimeoutMs: null, codexTimeoutMs: null, claudeFirstOutputTimeoutMs: null, codexFirstOutputTimeoutMs: null, ollamaModel: null, openaiModel: null, openaiCompatibleModel: null, anthropicModel: null }, reviewFiles: [{ path: "src/a.ts", status: "modified", patch: "@@\n+export const ok = value.length;", additions: 1, deletions: 0 }], profile: null, securityFocus: false, diff --git a/test/unit/selfhost-ai.test.ts b/test/unit/selfhost-ai.test.ts index 4f592e2596..664952633a 100644 --- a/test/unit/selfhost-ai.test.ts +++ b/test/unit/selfhost-ai.test.ts @@ -62,6 +62,18 @@ describe("provider-specific CLI timeouts (#selfhost — no shared timeout ambigu expect(resolveCodexCliTimeoutMs({ CODEX_AI_TIMEOUT_MS: "1000" })).toBe(30_000); expect(resolveCodexCliTimeoutMs({ CODEX_AI_TIMEOUT_MS: "9999999" })).toBe(1_800_000); }); + it("prefers a per-repo timeout override over the env-var/effort default (#8364)", () => { + expect(resolveClaudeCliTimeoutMs({ CLAUDE_AI_TIMEOUT_MS: "300000", CLAUDE_AI_EFFORT: "low" }, 90_000)).toBe(90_000); + expect(resolveCodexCliTimeoutMs({ CODEX_AI_TIMEOUT_MS: "300000", CODEX_AI_EFFORT: "low" }, 75_000)).toBe(75_000); + // Override still clamped to the same floor/ceiling as the env path. + expect(resolveClaudeCliTimeoutMs({}, 1_000)).toBe(30_000); + expect(resolveCodexCliTimeoutMs({}, 9_999_999)).toBe(1_800_000); + // Absent/null/non-positive override falls through to env/effort resolution. + expect(resolveClaudeCliTimeoutMs({ CLAUDE_AI_EFFORT: "high" }, null)).toBe(240_000); + expect(resolveCodexCliTimeoutMs({ CODEX_AI_EFFORT: "high" }, undefined)).toBe(240_000); + expect(resolveClaudeCliTimeoutMs({ CLAUDE_AI_TIMEOUT_MS: "120000" }, 0)).toBe(120_000); + expect(resolveCodexCliTimeoutMs({ CODEX_AI_TIMEOUT_MS: "120000" }, Number.NaN)).toBe(120_000); + }); it("resolveCodexFirstOutputTimeoutMs defaults to 30s, is independent of effort, and honors + clamps CODEX_AI_FIRST_OUTPUT_TIMEOUT_MS", () => { // absent → the 30s default (?? right side) expect(resolveCodexFirstOutputTimeoutMs({})).toBe(30_000); @@ -78,6 +90,16 @@ describe("provider-specific CLI timeouts (#selfhost — no shared timeout ambigu // zero/negative also falls back (raw > 0 false branch) expect(resolveCodexFirstOutputTimeoutMs({ CODEX_AI_FIRST_OUTPUT_TIMEOUT_MS: "0" })).toBe(30_000); }); + it("prefers a per-repo first-output timeout override over the env-var default (#8364)", () => { + expect(resolveCodexFirstOutputTimeoutMs({ CODEX_AI_FIRST_OUTPUT_TIMEOUT_MS: "15000" }, 8_000)).toBe(8_000); + expect(resolveClaudeFirstOutputTimeoutMs({ CLAUDE_AI_FIRST_OUTPUT_TIMEOUT_MS: "15000" }, 9_000)).toBe(9_000); + expect(resolveCodexFirstOutputTimeoutMs({}, 500)).toBe(1_000); // floor + expect(resolveClaudeFirstOutputTimeoutMs({}, 9_999_999)).toBe(1_800_000); // ceiling + expect(resolveCodexFirstOutputTimeoutMs({ CODEX_AI_FIRST_OUTPUT_TIMEOUT_MS: "15000" }, null)).toBe(15_000); + expect(resolveClaudeFirstOutputTimeoutMs({ CLAUDE_AI_FIRST_OUTPUT_TIMEOUT_MS: "15000" }, undefined)).toBe(15_000); + expect(resolveCodexFirstOutputTimeoutMs({}, 0)).toBe(30_000); + expect(resolveClaudeFirstOutputTimeoutMs({}, Number.NaN)).toBe(1_800_000); + }); it("REGRESSION (#5053): resolveClaudeFirstOutputTimeoutMs defaults to a 30-minute ceiling (effectively 'no separate fast-fail window' -- the call site's own Math.min(this, timeoutMs - 1) makes it equal the REAL timeout), unlike Codex's genuinely-streaming 30s default", () => { // absent → the 1_800_000ms default -- `claude --output-format json` is a buffered "single result" (per // `claude --help`), not streamed, so a short fast-fail window cannot distinguish a genuine hang from a From c4909b6a2a503c1a52ca07289dd47332cceee206 Mon Sep 17 00:00:00 2001 From: Andriy Polanski Date: Fri, 24 Jul 2026 13:13:50 +0000 Subject: [PATCH 2/3] npm run docs:drift-check --- .loopover.yml.example | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.loopover.yml.example b/.loopover.yml.example index 5358bb6861..9c30a7aec1 100644 --- a/.loopover.yml.example +++ b/.loopover.yml.example @@ -678,6 +678,10 @@ review: # claude_effort: null # Overrides CLAUDE_AI_EFFORT for this repo. String or null. Default (env unset): medium. # codex_model: null # Overrides CODEX_AI_MODEL for this repo. String or null. # codex_effort: null # Overrides CODEX_AI_EFFORT for this repo. String or null. Default (env unset): medium. +# claude_timeout_ms: null # Overrides CLAUDE_AI_TIMEOUT_MS for this repo. Positive integer (ms) or null. (#8364) +# codex_timeout_ms: null # Overrides CODEX_AI_TIMEOUT_MS for this repo. Positive integer (ms) or null. (#8364) +# claude_first_output_timeout_ms: null # Overrides CLAUDE_AI_FIRST_OUTPUT_TIMEOUT_MS for this repo. Positive integer (ms) or null. (#8364) +# codex_first_output_timeout_ms: null # Overrides CODEX_AI_FIRST_OUTPUT_TIMEOUT_MS for this repo. Positive integer (ms) or null. (#8364) # ollama_model: null # Overrides OLLAMA_AI_MODEL for this repo's ollama reviewer. String or null. (#3902) # openai_model: null # Overrides OPENAI_AI_MODEL for this repo's openai reviewer. String or null. (#3902) # openai_compatible_model: null # Overrides OPENAI_COMPATIBLE_AI_MODEL for this repo. String or null. (#3902) From ba73ecdcf652fcf79c502af61391d2daa7ed6d23 Mon Sep 17 00:00:00 2001 From: Andriy Polanski Date: Fri, 24 Jul 2026 13:42:55 +0000 Subject: [PATCH 3/3] npm run test:coverage --- config/examples/loopover.full.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/config/examples/loopover.full.yml b/config/examples/loopover.full.yml index a7cfe2ea61..0730f2eb6b 100644 --- a/config/examples/loopover.full.yml +++ b/config/examples/loopover.full.yml @@ -692,6 +692,10 @@ review: # claude_effort: null # Overrides CLAUDE_AI_EFFORT for this repo. String or null. Default (env unset): medium. # codex_model: null # Overrides CODEX_AI_MODEL for this repo. String or null. # codex_effort: null # Overrides CODEX_AI_EFFORT for this repo. String or null. Default (env unset): medium. +# claude_timeout_ms: null # Overrides CLAUDE_AI_TIMEOUT_MS for this repo. Positive integer (ms) or null. (#8364) +# codex_timeout_ms: null # Overrides CODEX_AI_TIMEOUT_MS for this repo. Positive integer (ms) or null. (#8364) +# claude_first_output_timeout_ms: null # Overrides CLAUDE_AI_FIRST_OUTPUT_TIMEOUT_MS for this repo. Positive integer (ms) or null. (#8364) +# codex_first_output_timeout_ms: null # Overrides CODEX_AI_FIRST_OUTPUT_TIMEOUT_MS for this repo. Positive integer (ms) or null. (#8364) # ollama_model: null # Overrides OLLAMA_AI_MODEL for this repo's ollama reviewer. String or null. (#3902) # openai_model: null # Overrides OPENAI_AI_MODEL for this repo's openai reviewer. String or null. (#3902) # openai_compatible_model: null # Overrides OPENAI_COMPATIBLE_AI_MODEL for this repo. String or null. (#3902)