From 6b92909a288ebfde483825afce0e7069daa3dd96 Mon Sep 17 00:00:00 2001 From: "MaineCoon-GPT-5.5" Date: Thu, 11 Jun 2026 11:05:19 +0800 Subject: [PATCH] fix(acp): align Gemini prompt timeout with CLI timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why:\nGemini ACP prompt streaming had a local 900000ms activity budget, so Siamese/Gemini turns timed out after 15 minutes even though the shared CLI timeout default is 30 minutes. Using the shared timeout resolver keeps ACP behavior aligned with the rest of the runtime and keeps CLI_TIMEOUT_MS=0 from becoming an immediate timeout.\n\nAlso make the turn_budget_exceeded user-facing message derive its duration from AcpTimeoutError.timeoutMs, so future timeout config changes do not leave stale 15-minute copy behind.\n\nIssue: zts212653/clowder-ai#901\nReview: Opus 4.7 cross-cat review approved code direction before PR flow.\n\n[砚砚/gpt-5.5🐾] --- .../agents/providers/acp/AcpClient.ts | 18 ++++++++---- .../agents/providers/acp/GeminiAcpAdapter.ts | 28 +++++++++++++++---- packages/api/test/acp/acp-client.test.js | 9 +++++- .../api/test/acp/gemini-acp-adapter.test.js | 2 ++ 4 files changed, 44 insertions(+), 13 deletions(-) diff --git a/packages/api/src/domains/cats/services/agents/providers/acp/AcpClient.ts b/packages/api/src/domains/cats/services/agents/providers/acp/AcpClient.ts index d1cc03703f..330cdb5c20 100644 --- a/packages/api/src/domains/cats/services/agents/providers/acp/AcpClient.ts +++ b/packages/api/src/domains/cats/services/agents/providers/acp/AcpClient.ts @@ -18,6 +18,7 @@ import { createInterface, type Interface as ReadlineInterface } from 'node:readl import { createModuleLogger } from '../../../../../../infrastructure/logger.js'; import { resolveCliCommandOrBare } from '../../../../../../utils/cli-resolve.js'; import { resolveWindowsSpawnPlan } from '../../../../../../utils/cli-spawn-win.js'; +import { resolveCliTimeoutMs } from '../../../../../../utils/cli-timeout.js'; import type { AcpAgentRequest, AcpContentBlock, @@ -103,6 +104,10 @@ export interface AcpCapacitySignal { const CAPACITY_RE = /MODEL_CAPACITY_EXHAUSTED|No capacity available|status 429.*Retrying/i; +export function resolveAcpPromptTimeoutMs(overrideMs?: number, env: NodeJS.ProcessEnv = process.env): number { + return resolveCliTimeoutMs(overrideMs, env); +} + export class AcpClient { private child: ChildProcess | null = null; private rl: ReadlineInterface | null = null; @@ -260,12 +265,12 @@ export class AcpClient { // after timeoutMs of SILENCE (no events). Idle stall (90s) catches true hangs // faster; this is the wider safety net for slow-but-alive sessions. // sendRequest gets a hard ceiling (1h) as absolute last-resort guard. - const timeoutMs = options?.timeoutMs ?? 900_000; + const timeoutMs = resolveAcpPromptTimeoutMs(options?.timeoutMs); const idleWarningMs = options?.idleWarningMs ?? 20_000; // Idle stall catches true hangs. Gemini CLI doesn't emit tool_call for MCP // tools, so pendingTool never activates. 90s covers most MCP calls (10-30s). const idleStallMs = options?.idleStallMs ?? 90_000; - const HARD_CEILING_MS = 3_600_000; // 1h — absolute last-resort for sendRequest promise + const hardCeilingMs = Math.max(3_600_000, timeoutMs > 0 ? timeoutMs * 2 : 3_600_000); const queue: AcpSessionUpdate[] = []; let waitResolve: (() => void) | null = null; let done = false; @@ -284,7 +289,7 @@ export class AcpClient { * Called once at prompt start and again on every incoming event. */ const resetBudget = () => { if (budgetTimer) clearTimeout(budgetTimer); - if (done) return; + if (done || timeoutMs <= 0) return; budgetTimer = setTimeout(() => { if (done) return; log.error({ sessionId, eventCount, timeoutMs }, 'Turn budget exceeded — no activity for %dms', timeoutMs); @@ -416,12 +421,13 @@ export class AcpClient { }; this.capacityListeners.add(capacityInjector); - // Start activity-based budget timer — resets on each event from listener + // Start activity-based budget timer — resets on each event from listener. + // CLI_TIMEOUT_MS=0 disables the activity budget; the hard ceiling remains. resetBudget(); // Fire prompt request — don't await, we'll drain the queue concurrently. - // sendRequest uses hard ceiling (1h); actual budget is managed by resetBudget(). - this.sendRequest(ACP_METHODS.sessionPrompt, { sessionId, prompt: [{ type: 'text', text }] }, HARD_CEILING_MS) + // sendRequest uses a hard ceiling; actual budget is managed by resetBudget(). + this.sendRequest(ACP_METHODS.sessionPrompt, { sessionId, prompt: [{ type: 'text', text }] }, hardCeilingMs) .then((resp) => { const result = resp.result as unknown as AcpPromptResult; stopReason = result.stopReason; diff --git a/packages/api/src/domains/cats/services/agents/providers/acp/GeminiAcpAdapter.ts b/packages/api/src/domains/cats/services/agents/providers/acp/GeminiAcpAdapter.ts index 33e52311fe..aa50a4889c 100644 --- a/packages/api/src/domains/cats/services/agents/providers/acp/GeminiAcpAdapter.ts +++ b/packages/api/src/domains/cats/services/agents/providers/acp/GeminiAcpAdapter.ts @@ -269,12 +269,12 @@ export class GeminiAcpAdapter implements AgentService { log.info({ ...ctx }, 'ACP capacity warning yielded (catch path)'); yield makeCapacityWarning(this.catId, capacitySignal, metadata); } - const { errorCode, errorMsg } = classifyError(err, capacitySignal, client.recentCapacitySignal); + const { errorCode, errorMsg, timeoutMs } = classifyError(err, capacitySignal, client.recentCapacitySignal); log.error({ ...ctx, errorCode, err: errorMsg, sessionId, eventCount, waitedMs }, 'ACP prompt failure'); yield { type: 'error', catId: this.catId, - error: toUserFacingError(errorCode, errorMsg), + error: toUserFacingError(errorCode, errorMsg, timeoutMs), errorCode, metadata, timestamp: Date.now(), @@ -348,11 +348,17 @@ const RECENT_SIGNAL_MAX_AGE_MS = 10 * 60 * 1000; /** Pattern for stream idle stall errors thrown by AcpClient idle watchdog. */ const STREAM_IDLE_RE = /Stream idle|STREAM_IDLE_STALL/i; +interface ClassifiedError { + errorCode: string; + errorMsg: string; + timeoutMs?: number; +} + function classifyError( err: unknown, capacitySignal: AcpCapacitySignal | null | undefined, clientRecentSignal?: AcpCapacitySignal | null, -): { errorCode: string; errorMsg: string } { +): ClassifiedError { if (err instanceof AcpProtocolError) { if (err.code === -32000 || err.message.includes('capacity')) { return { errorCode: 'model_capacity', errorMsg: err.message }; @@ -378,7 +384,7 @@ function classifyError( errorMsg: `Provider capacity exhausted (upstream 429, evidence: recent_process_signal, ${ageS}s ago). ${clientRecentSignal.message}`, }; } - return { errorCode: 'turn_budget_exceeded', errorMsg: err.message }; + return { errorCode: 'turn_budget_exceeded', errorMsg: err.message, timeoutMs: err.timeoutMs }; } // F149: Stream idle stall — provider started responding then went silent const msg = err instanceof Error ? err.message : String(err); @@ -394,7 +400,17 @@ function classifyError( /** Map internal error codes to user-friendly messages that clarify the failure source. * Format: `{errorCode}: {errorMsg}\n{user-facing explanation}` * The errorCode prefix is preserved for machine grep-ability (tests + invoke-helpers). */ -function toUserFacingError(errorCode: string, errorMsg: string): string { +function formatTimeoutDuration(timeoutMs: number | undefined): string { + if (timeoutMs === undefined || !Number.isFinite(timeoutMs) || timeoutMs <= 0) { + return '当前配置'; + } + const seconds = Math.max(1, Math.round(timeoutMs / 1000)); + if (seconds < 60) return `${seconds}秒`; + const minutes = Math.max(1, Math.round(seconds / 60)); + return `${minutes}分钟`; +} + +function toUserFacingError(errorCode: string, errorMsg: string, timeoutMs?: number): string { const base = `${errorCode}: ${errorMsg}`; switch (errorCode) { case 'model_capacity': @@ -402,7 +418,7 @@ function toUserFacingError(errorCode: string, errorMsg: string): string { case 'stream_idle_stall': return `${base}\n⚠️ Gemini 服务端响应中断(Google 服务器可能繁忙或不稳定),非 Clowder AI 系统故障。`; case 'turn_budget_exceeded': - return `${base}\n⚠️ 本轮对话时间预算用完(${Math.round(900 / 60)}分钟),烁烁可能在执行复杂工具链。非故障,可重试。`; + return `${base}\n⚠️ 本轮对话时间预算用完(${formatTimeoutDuration(timeoutMs)}),烁烁可能在执行复杂工具链。非故障,可重试。`; case 'mcp_pollution': return `${base}\n⚠️ Gemini 工具调用异常(MCP 服务端错误)。`; case 'init_failure': diff --git a/packages/api/test/acp/acp-client.test.js b/packages/api/test/acp/acp-client.test.js index 9c3620e00e..f46600f0e8 100644 --- a/packages/api/test/acp/acp-client.test.js +++ b/packages/api/test/acp/acp-client.test.js @@ -7,7 +7,7 @@ import { EventEmitter } from 'node:events'; import { PassThrough } from 'node:stream'; import { afterEach, describe, it, mock } from 'node:test'; -const { AcpClient, AcpProtocolError } = await import( +const { AcpClient, AcpProtocolError, resolveAcpPromptTimeoutMs } = await import( '../../dist/domains/cats/services/agents/providers/acp/AcpClient.js' ); @@ -66,6 +66,13 @@ describe('AcpClient', () => { } }); + it('promptStream timeout follows shared CLI timeout configuration', () => { + assert.equal(resolveAcpPromptTimeoutMs(undefined, {}), 30 * 60 * 1000); + assert.equal(resolveAcpPromptTimeoutMs(undefined, { CLI_TIMEOUT_MS: '900000' }), 900000); + assert.equal(resolveAcpPromptTimeoutMs(undefined, { CLI_TIMEOUT_MS: '0' }), 0); + assert.equal(resolveAcpPromptTimeoutMs(120000, { CLI_TIMEOUT_MS: '1800000' }), 120000); + }); + it('initialize sends protocolVersion and parses response', async () => { const { child, clientStdin, agentStdout } = createMockChild(); diff --git a/packages/api/test/acp/gemini-acp-adapter.test.js b/packages/api/test/acp/gemini-acp-adapter.test.js index 7d74725a52..4657f83687 100644 --- a/packages/api/test/acp/gemini-acp-adapter.test.js +++ b/packages/api/test/acp/gemini-acp-adapter.test.js @@ -829,6 +829,8 @@ describe('GeminiAcpAdapter integration', () => { 'turn_budget_exceeded', `Expected turn_budget_exceeded when no capacity signal, got ${errorMsg.errorCode}`, ); + assert.match(errorMsg.error, /2分钟/, 'Timeout message should reflect the actual AcpTimeoutError duration'); + assert.doesNotMatch(errorMsg.error, /15分钟/, 'Timeout message must not be hard-coded to 15 minutes'); }); it('concurrent prompts on same client both capture provider-level capacity signal', async () => {