Skip to content

Commit ac06b43

Browse files
andriypolanskiandriy-polanskicursoragentloopover-orb[bot]
authored
feat(selfhost): allow per-repo AI CLI timeout overrides in review.ai_model (#8364) (#8458)
* 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 <cursoragent@cursor.com> * npm run docs:drift-check * npm run test:coverage --------- Co-authored-by: Andriy Polanski <andriy.polanski@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: loopover-orb[bot] <296761690+loopover-orb[bot]@users.noreply.github.com>
1 parent 08d093a commit ac06b43

14 files changed

Lines changed: 264 additions & 60 deletions

.loopover.yml.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,10 @@ review:
678678
# claude_effort: null # Overrides CLAUDE_AI_EFFORT for this repo. String or null. Default (env unset): medium.
679679
# codex_model: null # Overrides CODEX_AI_MODEL for this repo. String or null.
680680
# codex_effort: null # Overrides CODEX_AI_EFFORT for this repo. String or null. Default (env unset): medium.
681+
# claude_timeout_ms: null # Overrides CLAUDE_AI_TIMEOUT_MS for this repo. Positive integer (ms) or null. (#8364)
682+
# codex_timeout_ms: null # Overrides CODEX_AI_TIMEOUT_MS for this repo. Positive integer (ms) or null. (#8364)
683+
# claude_first_output_timeout_ms: null # Overrides CLAUDE_AI_FIRST_OUTPUT_TIMEOUT_MS for this repo. Positive integer (ms) or null. (#8364)
684+
# codex_first_output_timeout_ms: null # Overrides CODEX_AI_FIRST_OUTPUT_TIMEOUT_MS for this repo. Positive integer (ms) or null. (#8364)
681685
# ollama_model: null # Overrides OLLAMA_AI_MODEL for this repo's ollama reviewer. String or null. (#3902)
682686
# openai_model: null # Overrides OPENAI_AI_MODEL for this repo's openai reviewer. String or null. (#3902)
683687
# openai_compatible_model: null # Overrides OPENAI_COMPATIBLE_AI_MODEL for this repo. String or null. (#3902)

config/examples/loopover.full.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,6 +692,10 @@ review:
692692
# claude_effort: null # Overrides CLAUDE_AI_EFFORT for this repo. String or null. Default (env unset): medium.
693693
# codex_model: null # Overrides CODEX_AI_MODEL for this repo. String or null.
694694
# codex_effort: null # Overrides CODEX_AI_EFFORT for this repo. String or null. Default (env unset): medium.
695+
# claude_timeout_ms: null # Overrides CLAUDE_AI_TIMEOUT_MS for this repo. Positive integer (ms) or null. (#8364)
696+
# codex_timeout_ms: null # Overrides CODEX_AI_TIMEOUT_MS for this repo. Positive integer (ms) or null. (#8364)
697+
# claude_first_output_timeout_ms: null # Overrides CLAUDE_AI_FIRST_OUTPUT_TIMEOUT_MS for this repo. Positive integer (ms) or null. (#8364)
698+
# codex_first_output_timeout_ms: null # Overrides CODEX_AI_FIRST_OUTPUT_TIMEOUT_MS for this repo. Positive integer (ms) or null. (#8364)
695699
# ollama_model: null # Overrides OLLAMA_AI_MODEL for this repo's ollama reviewer. String or null. (#3902)
696700
# openai_model: null # Overrides OPENAI_AI_MODEL for this repo's openai reviewer. String or null. (#3902)
697701
# openai_compatible_model: null # Overrides OPENAI_COMPATIBLE_AI_MODEL for this repo. String or null. (#3902)

packages/loopover-engine/src/focus-manifest.ts

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -999,11 +999,12 @@ export const EMPTY_AUTO_REVIEW_CONFIG: AutoReviewConfig = {
999999
autoPauseAfterReviewedCommits: null,
10001000
};
10011001

1002-
/** Per-repo self-host reviewer model/effort overrides under `review.ai_model`. Each field independently overrides
1003-
* the matching global env var (CLAUDE_AI_MODEL / CLAUDE_AI_EFFORT / CODEX_AI_MODEL / CODEX_AI_EFFORT) for THIS
1004-
* repo only — it never widens what the operator's own env already permits, only narrows/redirects it, so a
1005-
* compromised repo config can change which model reviews it but not grant itself a new credential or provider.
1006-
* (#selfhost-ai-model-override) */
1002+
/** Per-repo self-host reviewer model/effort/timeout overrides under `review.ai_model`. Each field independently
1003+
* overrides the matching global env var (CLAUDE_AI_MODEL / CLAUDE_AI_EFFORT / CODEX_AI_MODEL / CODEX_AI_EFFORT /
1004+
* CLAUDE_AI_TIMEOUT_MS / CODEX_AI_TIMEOUT_MS / CLAUDE_AI_FIRST_OUTPUT_TIMEOUT_MS / CODEX_AI_FIRST_OUTPUT_TIMEOUT_MS)
1005+
* for THIS repo only — it never widens what the operator's own env already permits, only narrows/redirects it,
1006+
* so a compromised repo config can change which model reviews it but not grant itself a new credential or
1007+
* provider. (#selfhost-ai-model-override, #8364) */
10071008
export type SelfHostAiModelConfig = {
10081009
/** `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. */
10091010
claudeModel: string | null;
@@ -1013,6 +1014,14 @@ export type SelfHostAiModelConfig = {
10131014
codexModel: string | null;
10141015
/** `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". */
10151016
codexEffort: string | null;
1017+
/** `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. */
1018+
claudeTimeoutMs: number | null;
1019+
/** `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. */
1020+
codexTimeoutMs: number | null;
1021+
/** `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. */
1022+
claudeFirstOutputTimeoutMs: number | null;
1023+
/** `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. */
1024+
codexFirstOutputTimeoutMs: number | null;
10161025
/** `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. */
10171026
ollamaModel: string | null;
10181027
/** `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 = {
10281037
claudeEffort: null,
10291038
codexModel: null,
10301039
codexEffort: null,
1040+
claudeTimeoutMs: null,
1041+
codexTimeoutMs: null,
1042+
claudeFirstOutputTimeoutMs: null,
1043+
codexFirstOutputTimeoutMs: null,
10311044
ollamaModel: null,
10321045
openaiModel: null,
10331046
openaiCompatibleModel: null,
@@ -3194,6 +3207,10 @@ function overlaySelfHostAiModelConfig(base: SelfHostAiModelConfig, override: Sel
31943207
claudeEffort: pickOverlayNullable(override.claudeEffort, base.claudeEffort),
31953208
codexModel: pickOverlayNullable(override.codexModel, base.codexModel),
31963209
codexEffort: pickOverlayNullable(override.codexEffort, base.codexEffort),
3210+
claudeTimeoutMs: pickOverlayNullable(override.claudeTimeoutMs, base.claudeTimeoutMs),
3211+
codexTimeoutMs: pickOverlayNullable(override.codexTimeoutMs, base.codexTimeoutMs),
3212+
claudeFirstOutputTimeoutMs: pickOverlayNullable(override.claudeFirstOutputTimeoutMs, base.claudeFirstOutputTimeoutMs),
3213+
codexFirstOutputTimeoutMs: pickOverlayNullable(override.codexFirstOutputTimeoutMs, base.codexFirstOutputTimeoutMs),
31973214
ollamaModel: pickOverlayNullable(override.ollamaModel, base.ollamaModel),
31983215
openaiModel: pickOverlayNullable(override.openaiModel, base.openaiModel),
31993216
openaiCompatibleModel: pickOverlayNullable(override.openaiCompatibleModel, base.openaiCompatibleModel),
@@ -3383,18 +3400,22 @@ function selfHostAiModelPresent(config: SelfHostAiModelConfig): boolean {
33833400
config.claudeEffort !== null ||
33843401
config.codexModel !== null ||
33853402
config.codexEffort !== null ||
3403+
config.claudeTimeoutMs !== null ||
3404+
config.codexTimeoutMs !== null ||
3405+
config.claudeFirstOutputTimeoutMs !== null ||
3406+
config.codexFirstOutputTimeoutMs !== null ||
33863407
config.ollamaModel !== null ||
33873408
config.openaiModel !== null ||
33883409
config.openaiCompatibleModel !== null ||
33893410
config.anthropicModel !== null
33903411
);
33913412
}
33923413

3393-
/** Parse `review.ai_model` — per-repo self-host reviewer model/effort overrides. Values are opaque, bounded,
3394-
* public-safe strings (like `review.tone`) — never validated against a fixed model/effort enum here, so this
3395-
* parser never drifts from the provider's own effort allowlist (`src/selfhost/ai.ts`); an invalid effort value
3396-
* degrades the SAME way an invalid env-sourced one already does (falls back to "medium" at resolve time).
3397-
* (#selfhost-ai-model-override) */
3414+
/** Parse `review.ai_model` — per-repo self-host reviewer model/effort/timeout overrides. Model/effort values are
3415+
* opaque, bounded, public-safe strings (like `review.tone`) — never validated against a fixed model/effort enum
3416+
* here, so this parser never drifts from the provider's own effort allowlist (`src/selfhost/ai.ts`); an invalid
3417+
* effort value degrades the SAME way an invalid env-sourced one already does (falls back to "medium" at resolve
3418+
* time). Timeout fields are positive whole-number milliseconds (#8364). (#selfhost-ai-model-override) */
33983419
function parseSelfHostAiModelConfig(value: JsonValue | undefined, warnings: string[]): SelfHostAiModelConfig {
33993420
if (value === undefined || value === null) return { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG };
34003421
if (typeof value !== "object" || Array.isArray(value)) {
@@ -3407,6 +3428,18 @@ function parseSelfHostAiModelConfig(value: JsonValue | undefined, warnings: stri
34073428
claudeEffort: parsePublicSafeText(record.claude_effort, "review.ai_model.claude_effort", warnings),
34083429
codexModel: parsePublicSafeText(record.codex_model, "review.ai_model.codex_model", warnings),
34093430
codexEffort: parsePublicSafeText(record.codex_effort, "review.ai_model.codex_effort", warnings),
3431+
claudeTimeoutMs: normalizeOptionalPositiveInteger(record.claude_timeout_ms, "review.ai_model.claude_timeout_ms", warnings),
3432+
codexTimeoutMs: normalizeOptionalPositiveInteger(record.codex_timeout_ms, "review.ai_model.codex_timeout_ms", warnings),
3433+
claudeFirstOutputTimeoutMs: normalizeOptionalPositiveInteger(
3434+
record.claude_first_output_timeout_ms,
3435+
"review.ai_model.claude_first_output_timeout_ms",
3436+
warnings,
3437+
),
3438+
codexFirstOutputTimeoutMs: normalizeOptionalPositiveInteger(
3439+
record.codex_first_output_timeout_ms,
3440+
"review.ai_model.codex_first_output_timeout_ms",
3441+
warnings,
3442+
),
34103443
ollamaModel: parsePublicSafeText(record.ollama_model, "review.ai_model.ollama_model", warnings),
34113444
openaiModel: parsePublicSafeText(record.openai_model, "review.ai_model.openai_model", warnings),
34123445
openaiCompatibleModel: parsePublicSafeText(record.openai_compatible_model, "review.ai_model.openai_compatible_model", warnings),
@@ -3916,6 +3949,14 @@ export function reviewConfigToJson(review: FocusManifestReviewConfig): JsonValue
39163949
if (review.aiModel.claudeEffort !== null) aiModel.claude_effort = review.aiModel.claudeEffort;
39173950
if (review.aiModel.codexModel !== null) aiModel.codex_model = review.aiModel.codexModel;
39183951
if (review.aiModel.codexEffort !== null) aiModel.codex_effort = review.aiModel.codexEffort;
3952+
if (review.aiModel.claudeTimeoutMs !== null) aiModel.claude_timeout_ms = review.aiModel.claudeTimeoutMs;
3953+
if (review.aiModel.codexTimeoutMs !== null) aiModel.codex_timeout_ms = review.aiModel.codexTimeoutMs;
3954+
if (review.aiModel.claudeFirstOutputTimeoutMs !== null) {
3955+
aiModel.claude_first_output_timeout_ms = review.aiModel.claudeFirstOutputTimeoutMs;
3956+
}
3957+
if (review.aiModel.codexFirstOutputTimeoutMs !== null) {
3958+
aiModel.codex_first_output_timeout_ms = review.aiModel.codexFirstOutputTimeoutMs;
3959+
}
39193960
if (review.aiModel.ollamaModel !== null) aiModel.ollama_model = review.aiModel.ollamaModel;
39203961
if (review.aiModel.openaiModel !== null) aiModel.openai_model = review.aiModel.openaiModel;
39213962
if (review.aiModel.openaiCompatibleModel !== null) aiModel.openai_compatible_model = review.aiModel.openaiCompatibleModel;

src/queue/ai-review-orchestration.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -692,13 +692,17 @@ export async function runAiReviewForAdvisory(
692692
onMerge: args.settings.aiReviewOnMerge ?? undefined,
693693
reviewers: args.settings.aiReviewReviewers ?? undefined,
694694
securityFocus: args.reviewSecurityFocus === true,
695-
// Self-host per-repo model/effort override (#selfhost-ai-model-override): absent/null fields fall through
696-
// runLoopOverAiReview -> runWorkersOpinion -> the self-host provider's own global-env/hardcoded default,
697-
// exactly as if review.ai_model had never been set.
695+
// Self-host per-repo model/effort/timeout override (#selfhost-ai-model-override, #8364): absent/null
696+
// fields fall through runLoopOverAiReview -> runWorkersOpinion -> the self-host provider's own
697+
// global-env/hardcoded default, exactly as if review.ai_model had never been set.
698698
claudeModel: args.reviewSelfHostAiModel?.claudeModel ?? null,
699699
claudeEffort: args.reviewSelfHostAiModel?.claudeEffort ?? null,
700700
codexModel: args.reviewSelfHostAiModel?.codexModel ?? null,
701701
codexEffort: args.reviewSelfHostAiModel?.codexEffort ?? null,
702+
claudeTimeoutMs: args.reviewSelfHostAiModel?.claudeTimeoutMs ?? null,
703+
codexTimeoutMs: args.reviewSelfHostAiModel?.codexTimeoutMs ?? null,
704+
claudeFirstOutputTimeoutMs: args.reviewSelfHostAiModel?.claudeFirstOutputTimeoutMs ?? null,
705+
codexFirstOutputTimeoutMs: args.reviewSelfHostAiModel?.codexFirstOutputTimeoutMs ?? null,
702706
ollamaModel: args.reviewSelfHostAiModel?.ollamaModel ?? null,
703707
openaiModel: args.reviewSelfHostAiModel?.openaiModel ?? null,
704708
openaiCompatibleModel: args.reviewSelfHostAiModel?.openaiCompatibleModel ?? null,

src/review/ai-review-cache-input.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@ import { sha256Hex } from "../utils/crypto";
99
// gained an `impactMap` member. Bumped v3→v4 (#3902): `selfHostAiModelOverride` gained ollamaModel/openaiModel/
1010
// openaiCompatibleModel/anthropicModel members. Bumped v4→v5: added a top-level `body` member (the PR
1111
// description is threaded into the reviewer prompt exactly like `title`, but was never fingerprinted -- an
12-
// edited-description webhook with an unchanged head SHA silently replayed the pre-edit review). Every prior
13-
// cached review's fingerprint was computed without that key, so bumping the version guarantees a clean cache
14-
// miss on the first review after upgrade rather than silently reusing a hash computed under a different payload
15-
// shape.
16-
export const AI_REVIEW_CACHE_INPUT_VERSION = "ai-review-input:v5";
12+
// edited-description webhook with an unchanged head SHA silently replayed the pre-edit review). Bumped v5→v6
13+
// (#8364): `selfHostAiModelOverride` gained claudeTimeoutMs/codexTimeoutMs/claudeFirstOutputTimeoutMs/
14+
// codexFirstOutputTimeoutMs members. Every prior cached review's fingerprint was computed without those keys,
15+
// so bumping the version guarantees a clean cache miss on the first review after upgrade rather than silently
16+
// reusing a hash computed under a different payload shape.
17+
export const AI_REVIEW_CACHE_INPUT_VERSION = "ai-review-input:v6";
1718

1819
// #regate-churn (root cause, confirmed in production): this fingerprint USED to also hash the PR's live
1920
// `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):
181182
claudeEffort: input.selfHostAiModelOverride.claudeEffort ?? null,
182183
codexModel: input.selfHostAiModelOverride.codexModel ?? null,
183184
codexEffort: input.selfHostAiModelOverride.codexEffort ?? null,
185+
claudeTimeoutMs: input.selfHostAiModelOverride.claudeTimeoutMs ?? null,
186+
codexTimeoutMs: input.selfHostAiModelOverride.codexTimeoutMs ?? null,
187+
claudeFirstOutputTimeoutMs: input.selfHostAiModelOverride.claudeFirstOutputTimeoutMs ?? null,
188+
codexFirstOutputTimeoutMs: input.selfHostAiModelOverride.codexFirstOutputTimeoutMs ?? null,
184189
ollamaModel: input.selfHostAiModelOverride.ollamaModel ?? null,
185190
openaiModel: input.selfHostAiModelOverride.openaiModel ?? null,
186191
openaiCompatibleModel: input.selfHostAiModelOverride.openaiCompatibleModel ?? null,

0 commit comments

Comments
 (0)