From 1199e64cef5b619312aff42fbd743e29179e7f43 Mon Sep 17 00:00:00 2001 From: Suprhimp Date: Fri, 31 Jul 2026 14:57:44 +0900 Subject: [PATCH] fix: bound MCP review latency --- .changeset/reliable-plan-artifact.md | 2 +- README.ko.md | 12 +++++++++++ README.md | 12 +++++++++++ src/index.ts | 3 +++ src/schemas/code-review.ts | 2 +- src/schemas/plan-review.ts | 2 +- src/services/diagnostics.ts | 22 +++++++++++++++++++ src/services/providers/anthropic.ts | 19 +++++++++++------ src/services/providers/google.ts | 19 +++++++++++------ src/services/providers/openai.ts | 20 ++++++++++------- src/services/providers/types.ts | 2 +- src/services/review-runtime.test.ts | 27 +++++++++++++++++++++++ src/services/review-runtime.ts | 32 ++++++++++++++++++++++++++++ src/services/reviewer.ts | 24 ++++++++++++++++++++- 14 files changed, 171 insertions(+), 27 deletions(-) create mode 100644 src/services/diagnostics.ts create mode 100644 src/services/review-runtime.test.ts create mode 100644 src/services/review-runtime.ts diff --git a/.changeset/reliable-plan-artifact.md b/.changeset/reliable-plan-artifact.md index 48a81b2..336267b 100644 --- a/.changeset/reliable-plan-artifact.md +++ b/.changeset/reliable-plan-artifact.md @@ -2,4 +2,4 @@ "@planningo/duul": patch --- -Improve plan-file workflow guidance so callers replace and verify each plan revision as one snapshot instead of retrying stale text edits. +Improve plan-file workflow guidance so callers replace and verify each plan revision as one snapshot instead of retrying stale text edits. Bound MCP review latency, return timeout fallbacks, and support opt-in local diagnostic logs. diff --git a/README.ko.md b/README.ko.md index 432c38f..62c1bea 100644 --- a/README.ko.md +++ b/README.ko.md @@ -206,6 +206,18 @@ DUUL은 `~/.codex/auth.json`을 읽고(`CODEX_HOME`으로 경로 변경 가능): 기본값은 **미설정(무제한)**입니다 — 초기 측정에서 200KB 기본 cap이 code_review의 약 1/3을 불필요한 REVISE로 몰아 라운드가 오히려 늘었습니다. cap을 쓰고 싶다면 명시적으로 설정하세요. 비용 민감한 사용자는 `200000`–`500000` 범위에서 시작해 리뷰 복잡도에 따라 조정하는 것을 권장합니다. +#### 리뷰 지연 시간 및 진단 로그 + +각 MCP 리뷰는 기본적으로 총 **75초 제한**과 최대 **3회 파일 탐색 라운드**를 가집니다. 제한에 도달하면 호출자를 계속 기다리게 하지 않고 `tool_exhaustion_reason: "timeout"`인 구조화된 `incomplete` 결과를 반환합니다. 더 긴 검토가 허용될 때만 오버라이드하세요. + +| 변수 | 기본값 | 범위 | 설명 | +|---|---:|---:|---| +| `DUUL_REVIEW_TIMEOUT_MS` | `75000` | `10000`–`600000` | MCP 리뷰 한 번의 총 시간 예산 | +| `DUUL_MAX_TOOL_ROUNDS` | `3` | `0`–`10` | 호출당 리뷰어 파일 탐색 라운드 수 | +| `DUUL_LOG_FILE` | _(미설정)_ | — | DUUL stderr 진단을 기록할 선택적 로컬 파일. 프로바이더 오류에 사용자 입력이 포함될 수 있음. | + +실시간 로그가 필요하면 `DUUL_LOG_FILE`을 `.duul/duul.log`처럼 gitignore된 워크스페이스 경로로 설정하고 MCP 세션을 재시작한 뒤 `tail -f .duul/duul.log`을 실행하세요. + #### 요청별 오버라이드 개별 리뷰 호출에서 `max_review_iterations` 입력 파라미터로 반복 제한을 오버라이드할 수 있습니다 (범위: 1–20). 환경 변수보다 우선합니다. diff --git a/README.md b/README.md index 4103210..f1eb7a6 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,18 @@ Opt-in cap on the total bytes the reviewer can pull from the workspace via its f Unset by default: early measurements showed a 200KB default tripped ~1/3 of code reviews into spurious REVISEs, which actually cost more rounds. If you want the cap, set it explicitly — `200000`–`500000` is a reasonable starting range for cost-conscious setups. Raise or lower based on how complex your typical review is. +#### Review Latency and Diagnostics + +Each MCP review has a **75-second total deadline** and at most **3 reviewer file-exploration rounds** by default. On expiry, DUUL returns a structured `incomplete` result with `tool_exhaustion_reason: "timeout"` rather than leaving the MCP caller waiting. Override only when a longer review is acceptable: + +| Variable | Default | Range | Description | +|---|---:|---:|---| +| `DUUL_REVIEW_TIMEOUT_MS` | `75000` | `10000`–`600000` | Total time budget for one MCP review call. | +| `DUUL_MAX_TOOL_ROUNDS` | `3` | `0`–`10` | Reviewer file-exploration rounds per call. | +| `DUUL_LOG_FILE` | _(unset)_ | — | Optional local file that receives DUUL stderr diagnostics. Logs may contain user-supplied provider errors. | + +For a live local log, set `DUUL_LOG_FILE` to a gitignored workspace path such as `.duul/duul.log`, restart the MCP session, then run `tail -f .duul/duul.log`. + #### Per-Request Override You can also override the iteration limit on individual review calls via the `max_review_iterations` input parameter (range: 1–20). This takes priority over the environment variable. diff --git a/src/index.ts b/src/index.ts index 10aa432..a317d7f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { registerPlanReviewTool } from './tools/plan-review.js'; import { registerCodeReviewTool } from './tools/code-review.js'; import { registerExecutionPartitionTool } from './tools/execution-partition.js'; +import { enableFileDiagnostics } from './services/diagnostics.js'; const SERVER_INSTRUCTIONS = ` DUUL — Dual-phase Upfront-plan & Unit-verify Loop. @@ -14,6 +15,8 @@ read it back, then submit plan_file. Do not chain exact-text edits. On an edit m fresh snapshot; only one agent may modify the plan artifact at a time. `.trim(); +enableFileDiagnostics(); + const server = new McpServer( { name: 'duul', version: '1.0.0' }, { instructions: SERVER_INSTRUCTIONS }, diff --git a/src/schemas/code-review.ts b/src/schemas/code-review.ts index 780091f..1a32398 100644 --- a/src/schemas/code-review.ts +++ b/src/schemas/code-review.ts @@ -217,7 +217,7 @@ export const CodeReviewOutputSchema = z.object({ missing_context: z.array(z.string()).nullable().describe('Files or context the reviewer could not access'), evidence_files: z.array(z.string()).nullable().describe('Files the reviewer examined as evidence'), used_tools: z.array(z.string()).nullable().describe('Tool calls made during review'), - tool_exhaustion_reason: z.enum(['budget', 'repeat', 'round_limit']).nullable().describe( + tool_exhaustion_reason: z.enum(['budget', 'repeat', 'round_limit', 'timeout']).nullable().describe( 'If review_status is incomplete, the reason why the tool loop was exhausted', ), user_original_request_echo: z.string().nullable().describe( diff --git a/src/schemas/plan-review.ts b/src/schemas/plan-review.ts index 44c0ffc..9d0af08 100644 --- a/src/schemas/plan-review.ts +++ b/src/schemas/plan-review.ts @@ -204,7 +204,7 @@ export const PlanReviewOutputSchema = z.object({ missing_context: z.array(z.string()).nullable().describe('Files or context the reviewer could not access'), evidence_files: z.array(z.string()).nullable().describe('Files the reviewer examined as evidence'), used_tools: z.array(z.string()).nullable().describe('Tool calls made during review'), - tool_exhaustion_reason: z.enum(['budget', 'repeat', 'round_limit']).nullable().describe( + tool_exhaustion_reason: z.enum(['budget', 'repeat', 'round_limit', 'timeout']).nullable().describe( 'If review_status is incomplete, the reason why the tool loop was exhausted', ), parallelization_hint: z.enum(['serial', 'parallel', 'hybrid']).nullable().describe( diff --git a/src/services/diagnostics.ts b/src/services/diagnostics.ts new file mode 100644 index 0000000..f5d75d6 --- /dev/null +++ b/src/services/diagnostics.ts @@ -0,0 +1,22 @@ +import { appendFile, mkdir } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { format } from 'node:util'; + +/** + * Mirror stderr diagnostics to an explicitly configured local file. + * Logging remains opt-in because provider errors may include user-supplied text. + */ +export function enableFileDiagnostics(): void { + const logFile = process.env.DUUL_LOG_FILE; + if (!logFile) return; + + const stderr = console.error.bind(console); + console.error = (...args: unknown[]) => { + stderr(...args); + void mkdir(dirname(logFile), { recursive: true }) + .then(() => appendFile(logFile, `${new Date().toISOString()} ${format(...args)}\n`)) + .catch(() => undefined); + }; + + console.error(`[duul] File diagnostics enabled: ${logFile}`); +} diff --git a/src/services/providers/anthropic.ts b/src/services/providers/anthropic.ts index d699aee..8fc2ec7 100644 --- a/src/services/providers/anthropic.ts +++ b/src/services/providers/anthropic.ts @@ -10,9 +10,9 @@ import type { TokenUsage, } from './types.js'; import { estimateCost } from '../pricing.js'; +import { getMaxToolRounds, remainingReviewMs, reviewDeadlineFromNow, ReviewTimeoutError } from '../review-runtime.js'; const MAX_INPUT_CHARS = 400_000; -const MAX_TOOL_ROUNDS = 10; const MAX_RETRIES = 3; const MAX_REPEAT_CALLS = 3; @@ -215,6 +215,7 @@ export class AnthropicProvider implements ReviewerProvider { async review( options: ReviewCallOptions, ): Promise>> { + const deadline = reviewDeadlineFromNow(); const { systemPrompt, userMessage, outputSchema, workspaceScope, conversationHistory } = options; const effectiveRoot = workspaceScope?.root ?? null; @@ -292,7 +293,7 @@ export class AnthropicProvider implements ReviewerProvider { { role: 'user' as const, content: userMessage }, ]; - let body = await this.apiCallWithRetry(systemBlocks, messages, tools); + let body = await this.apiCallWithRetry(systemBlocks, messages, tools, deadline); accumulateUsage(body); console.error(`[duul] response.id=${body.id} model=${this.model} provider=anthropic`); @@ -330,7 +331,7 @@ export class AnthropicProvider implements ReviewerProvider { const callCounts = new Map(); const byteBudget = createReviewerByteBudget(); - for (let round = 0; round < MAX_TOOL_ROUNDS; round++) { + for (let round = 0; round < getMaxToolRounds(); round++) { const toolUses = body.content.filter((b): b is ToolUseBlock => b.type === 'tool_use'); if (toolUses.length === 0 || body.stop_reason !== 'tool_use') break; @@ -377,7 +378,7 @@ export class AnthropicProvider implements ReviewerProvider { messages.push({ role: 'user', content: toolResults }); conversationTurns.push({ role: 'user' as const, content: toolResults }); - body = await this.apiCallWithRetry(systemBlocks, messages, tools); + body = await this.apiCallWithRetry(systemBlocks, messages, tools, deadline); accumulateUsage(body); conversationTurns.push({ role: 'assistant' as const, content: body.content }); console.error(`[duul] response.id=${body.id} (after tool round ${round + 1})`); @@ -393,7 +394,7 @@ export class AnthropicProvider implements ReviewerProvider { })); messages.push({ role: 'assistant', content: body.content }); messages.push({ role: 'user', content: stopResults }); - body = await this.apiCallWithRetry(systemBlocks, messages, tools); + body = await this.apiCallWithRetry(systemBlocks, messages, tools, deadline); accumulateUsage(body); conversationTurns.push({ role: 'user' as const, content: stopResults }); conversationTurns.push({ role: 'assistant' as const, content: body.content }); @@ -412,7 +413,7 @@ export class AnthropicProvider implements ReviewerProvider { })); messages.push({ role: 'assistant', content: body.content }); messages.push({ role: 'user', content: stopResults }); - body = await this.apiCallWithRetry(systemBlocks, messages, tools); + body = await this.apiCallWithRetry(systemBlocks, messages, tools, deadline); accumulateUsage(body); conversationTurns.push({ role: 'user' as const, content: stopResults }); conversationTurns.push({ role: 'assistant' as const, content: body.content }); @@ -448,10 +449,13 @@ export class AnthropicProvider implements ReviewerProvider { system: SystemBlock[], messages: AnthropicMessage[], tools?: AnthropicTool[], + deadline?: number, ): Promise { for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + const remaining = deadline === undefined ? 120_000 : remainingReviewMs(deadline); + if (remaining === 0) throw new ReviewTimeoutError(); const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 120_000); + const timeout = setTimeout(() => controller.abort(), Math.min(120_000, remaining)); try { const response = await fetch(`${this.baseUrl}/v1/messages`, { @@ -489,6 +493,7 @@ export class AnthropicProvider implements ReviewerProvider { return await response.json() as AnthropicResponse; } catch (error: unknown) { clearTimeout(timeout); + if (deadline !== undefined && remainingReviewMs(deadline) === 0) throw new ReviewTimeoutError(); if (attempt < MAX_RETRIES - 1 && error instanceof Error && error.name === 'AbortError') { const delay = 1000 * Math.pow(2, attempt); console.error(`[duul] Anthropic retry ${attempt + 1}/${MAX_RETRIES} after ${delay}ms (timeout)`); diff --git a/src/services/providers/google.ts b/src/services/providers/google.ts index 5fdbdcd..d1763c8 100644 --- a/src/services/providers/google.ts +++ b/src/services/providers/google.ts @@ -9,9 +9,9 @@ import type { TokenUsage, } from './types.js'; import { estimateCost } from '../pricing.js'; +import { getMaxToolRounds, remainingReviewMs, reviewDeadlineFromNow, ReviewTimeoutError } from '../review-runtime.js'; const MAX_INPUT_CHARS = 400_000; -const MAX_TOOL_ROUNDS = 10; const MAX_RETRIES = 3; const MAX_REPEAT_CALLS = 3; @@ -183,6 +183,7 @@ export class GoogleProvider implements ReviewerProvider { async review( options: ReviewCallOptions, ): Promise>> { + const deadline = reviewDeadlineFromNow(); const { systemPrompt, userMessage, outputSchema, workspaceScope } = options; const effectiveRoot = workspaceScope?.root ?? null; @@ -222,7 +223,7 @@ export class GoogleProvider implements ReviewerProvider { { role: 'user', parts: [{ text: userMessage }] }, ]; - let body = await this.apiCallWithRetry(enhancedSystem, contents, tools); + let body = await this.apiCallWithRetry(enhancedSystem, contents, tools, deadline); accumulateUsage(body); console.error(`[duul] Gemini response received, model=${this.model} provider=google`); @@ -257,7 +258,7 @@ export class GoogleProvider implements ReviewerProvider { const callCounts = new Map(); const byteBudget = createReviewerByteBudget(); - for (let round = 0; round < MAX_TOOL_ROUNDS; round++) { + for (let round = 0; round < getMaxToolRounds(); round++) { const parts = body.candidates?.[0]?.content?.parts ?? []; const functionCalls = parts.filter((p): p is FunctionCallPart => 'functionCall' in p); if (functionCalls.length === 0) break; @@ -306,7 +307,7 @@ export class GoogleProvider implements ReviewerProvider { contents.push({ role: 'user', parts: responseParts }); - body = await this.apiCallWithRetry(enhancedSystem, contents, tools); + body = await this.apiCallWithRetry(enhancedSystem, contents, tools, deadline); accumulateUsage(body); console.error(`[duul] Gemini response (after tool round ${round + 1})`); @@ -319,7 +320,7 @@ export class GoogleProvider implements ReviewerProvider { functionResponse: { name: p.functionCall.name, response: { output: 'No more file reads allowed. Produce your final verdict now.' } }, })); contents.push({ role: 'user', parts: stopParts }); - body = await this.apiCallWithRetry(enhancedSystem, contents, tools); + body = await this.apiCallWithRetry(enhancedSystem, contents, tools, deadline); accumulateUsage(body); } break; @@ -334,7 +335,7 @@ export class GoogleProvider implements ReviewerProvider { functionResponse: { name: p.functionCall.name, response: { output: 'Tool call limit reached. Produce your final verdict now.' } }, })); contents.push({ role: 'user', parts: stopParts }); - body = await this.apiCallWithRetry(enhancedSystem, contents, tools); + body = await this.apiCallWithRetry(enhancedSystem, contents, tools, deadline); accumulateUsage(body); } } @@ -366,10 +367,13 @@ export class GoogleProvider implements ReviewerProvider { system: string, contents: GeminiContent[], tools?: typeof GOOGLE_TOOLS, + deadline?: number, ): Promise { for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + const remaining = deadline === undefined ? 120_000 : remainingReviewMs(deadline); + if (remaining === 0) throw new ReviewTimeoutError(); const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 120_000); + const timeout = setTimeout(() => controller.abort(), Math.min(120_000, remaining)); try { const url = `${this.baseUrl}/v1beta/models/${this.model}:generateContent?key=${this.apiKey}`; @@ -407,6 +411,7 @@ export class GoogleProvider implements ReviewerProvider { return await response.json() as GeminiResponse; } catch (error: unknown) { clearTimeout(timeout); + if (deadline !== undefined && remainingReviewMs(deadline) === 0) throw new ReviewTimeoutError(); if (attempt < MAX_RETRIES - 1 && error instanceof Error && error.name === 'AbortError') { const delay = 1000 * Math.pow(2, attempt); console.error(`[duul] Google retry ${attempt + 1}/${MAX_RETRIES} after ${delay}ms (timeout)`); diff --git a/src/services/providers/openai.ts b/src/services/providers/openai.ts index 372fb7d..46d3c57 100644 --- a/src/services/providers/openai.ts +++ b/src/services/providers/openai.ts @@ -15,9 +15,9 @@ import type { ConversationTurn, } from './types.js'; import { estimateCost } from '../pricing.js'; +import { getMaxToolRounds, remainingReviewMs, reviewDeadlineFromNow, ReviewTimeoutError } from '../review-runtime.js'; const MAX_INPUT_CHARS = 400_000; -const MAX_TOOL_ROUNDS = 10; const MAX_RETRIES = 3; const MAX_REPEAT_CALLS = 3; @@ -265,6 +265,7 @@ export class OpenAIProvider implements ReviewerProvider { async review( options: ReviewCallOptions, ): Promise>> { + const deadline = reviewDeadlineFromNow(); const { systemPrompt, userMessage, schemaName, outputSchema, workspaceScope, previousReviewId, conversationHistory } = options; validateInputLength(systemPrompt, userMessage); @@ -334,12 +335,12 @@ export class OpenAIProvider implements ReviewerProvider { inputItems.push({ role: 'user' as const, content: [{ type: 'input_text' as const, text: userMessage }] }); let response = this.stateless - ? await this.apiCallWithRetry({ ...baseParams, input: inputItems }) + ? await this.apiCallWithRetry({ ...baseParams, input: inputItems }, deadline) : await this.apiCallWithRetry({ ...baseParams, input: inputItems, ...(previousReviewId ? { previous_response_id: previousReviewId } : {}), - }); + }, deadline); accumulateUsage(response); console.error(`[duul] response.id=${response.id} model=${this.model} provider=openai`); @@ -350,9 +351,9 @@ export class OpenAIProvider implements ReviewerProvider { const continueConversation = async (newItems: unknown[]): Promise => { if (this.stateless) { inputItems.push(...response.output, ...newItems); - return this.apiCallWithRetry({ ...baseParams, input: inputItems }); + return this.apiCallWithRetry({ ...baseParams, input: inputItems }, deadline); } - return this.apiCallWithRetry({ ...baseParams, previous_response_id: response.id, input: newItems }); + return this.apiCallWithRetry({ ...baseParams, previous_response_id: response.id, input: newItems }, deadline); }; // Agentic tool-calling loop @@ -389,7 +390,7 @@ export class OpenAIProvider implements ReviewerProvider { const callCounts = new Map(); const byteBudget = createReviewerByteBudget(); - for (let round = 0; round < MAX_TOOL_ROUNDS; round++) { + for (let round = 0; round < getMaxToolRounds(); round++) { const functionCalls = this.getFunctionCalls(response); if (functionCalls.length === 0) break; @@ -493,11 +494,13 @@ export class OpenAIProvider implements ReviewerProvider { throw new Error('Review failed: could not obtain structured verdict after tool loop.'); } - private async apiCallWithRetry(params: Record): Promise { + private async apiCallWithRetry(params: Record, deadline: number): Promise { let refreshedOnce = false; for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + const remaining = remainingReviewMs(deadline); + if (remaining === 0) throw new ReviewTimeoutError(); const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 120_000); + const timeout = setTimeout(() => controller.abort(), Math.min(120_000, remaining)); try { let response: OpenAI.Responses.Response; if (this.stateless) { @@ -518,6 +521,7 @@ export class OpenAIProvider implements ReviewerProvider { return response; } catch (error: unknown) { clearTimeout(timeout); + if (remainingReviewMs(deadline) === 0) throw new ReviewTimeoutError(); const status = error instanceof Error && 'status' in error ? (error as { status: number }).status : undefined; // ChatGPT token expired mid-review: refresh once and retry immediately. diff --git a/src/services/providers/types.ts b/src/services/providers/types.ts index 492295e..1b6255a 100644 --- a/src/services/providers/types.ts +++ b/src/services/providers/types.ts @@ -1,7 +1,7 @@ import type { z } from 'zod'; import type { WorkspaceScope } from '../filesystem.js'; -export type ExhaustionReason = 'budget' | 'repeat' | 'round_limit'; +export type ExhaustionReason = 'budget' | 'repeat' | 'round_limit' | 'timeout'; /** * Token usage from a single review call. diff --git a/src/services/review-runtime.test.ts b/src/services/review-runtime.test.ts new file mode 100644 index 0000000..a13a910 --- /dev/null +++ b/src/services/review-runtime.test.ts @@ -0,0 +1,27 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { getMaxToolRounds, getReviewDeadlineMs } from './review-runtime.js'; + +function withEnv(name: string, value: string | undefined, run: () => void): void { + const previous = process.env[name]; + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + try { + run(); + } finally { + if (previous === undefined) delete process.env[name]; + else process.env[name] = previous; + } +} + +test('review runtime defaults bound MCP latency', () => { + withEnv('DUUL_REVIEW_TIMEOUT_MS', undefined, () => assert.equal(getReviewDeadlineMs(), 75_000)); + withEnv('DUUL_MAX_TOOL_ROUNDS', undefined, () => assert.equal(getMaxToolRounds(), 3)); +}); + +test('review runtime accepts only bounded overrides', () => { + withEnv('DUUL_REVIEW_TIMEOUT_MS', '120000', () => assert.equal(getReviewDeadlineMs(), 120_000)); + withEnv('DUUL_REVIEW_TIMEOUT_MS', '999999', () => assert.equal(getReviewDeadlineMs(), 75_000)); + withEnv('DUUL_MAX_TOOL_ROUNDS', '0', () => assert.equal(getMaxToolRounds(), 0)); + withEnv('DUUL_MAX_TOOL_ROUNDS', '11', () => assert.equal(getMaxToolRounds(), 3)); +}); diff --git a/src/services/review-runtime.ts b/src/services/review-runtime.ts new file mode 100644 index 0000000..fedff11 --- /dev/null +++ b/src/services/review-runtime.ts @@ -0,0 +1,32 @@ +const DEFAULT_REVIEW_DEADLINE_MS = 75_000; +const DEFAULT_MAX_TOOL_ROUNDS = 3; + +function boundedEnvInt(name: string, fallback: number, minimum: number, maximum: number): number { + const value = Number.parseInt(process.env[name] ?? '', 10); + return Number.isInteger(value) && value >= minimum && value <= maximum ? value : fallback; +} + +/** Total time a single MCP review call may spend talking to its reviewer. */ +export function getReviewDeadlineMs(): number { + return boundedEnvInt('DUUL_REVIEW_TIMEOUT_MS', DEFAULT_REVIEW_DEADLINE_MS, 10_000, 600_000); +} + +/** Maximum reviewer file-exploration rounds within one MCP review call. */ +export function getMaxToolRounds(): number { + return boundedEnvInt('DUUL_MAX_TOOL_ROUNDS', DEFAULT_MAX_TOOL_ROUNDS, 0, 10); +} + +export function reviewDeadlineFromNow(): number { + return Date.now() + getReviewDeadlineMs(); +} + +export function remainingReviewMs(deadline: number): number { + return Math.max(0, deadline - Date.now()); +} + +export class ReviewTimeoutError extends Error { + constructor() { + super(`Review deadline exceeded after ${getReviewDeadlineMs()}ms.`); + this.name = 'ReviewTimeoutError'; + } +} diff --git a/src/services/reviewer.ts b/src/services/reviewer.ts index 91ec60d..687b016 100644 --- a/src/services/reviewer.ts +++ b/src/services/reviewer.ts @@ -11,6 +11,7 @@ import { OpenAIProvider, type ChatgptAuth } from './providers/openai.js'; import { AnthropicProvider } from './providers/anthropic.js'; import { GoogleProvider } from './providers/google.js'; import { resolveCodexCredential } from './providers/codex-auth.js'; +import { ReviewTimeoutError } from './review-runtime.js'; export type { ReviewerProvider, ReviewCallResult, ExhaustionReason, TokenUsage }; @@ -384,7 +385,28 @@ export async function callReview( } } - const result = await provider.review({ ...options, conversationHistory }); + let result: ReviewCallResult>; + try { + result = await provider.review({ ...options, conversationHistory }); + } catch (error) { + if (error instanceof ReviewTimeoutError && options.createFallback) { + console.error(`[duul] ${error.message}`); + return { + parsed: options.createFallback('timeout', [error.message]), + reviewId: '', + usage: { + input_tokens: 0, + output_tokens: 0, + total_tokens: 0, + api_calls: 0, + provider: provider.name, + model: 'unknown', + estimated_cost_usd: null, + }, + }; + } + throw error; + } // Store conversation turns for future rounds (replay-based providers only) if (result.conversationTurns?.length && provider.capabilities.conversationReplay) {