diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs index 9f5b91221..cb7d67b89 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs @@ -17,6 +17,18 @@ const CLAUDE_NODE_ENV: &str = "GENARRATIVE_CLAUDE_NODE"; const CLAUDE_CODE_OUTPUT_MAX_BYTES: usize = 8 * 1024 * 1024; const CLAUDE_CODE_PROMPT_MAX_BYTES: usize = 4 * 1024 * 1024; const CLAUDE_CODE_MODEL_MAX_CHARS: usize = 200; +/// 超时收口时给 sidecar stderr 的排空窗口。 +/// +/// sidecar 自己还活着时 stderr 永远等不到 EOF(Claude Code CLI 正在网关侧重试), +/// 没有上限就会让超时分支自己挂住:这一轮既写不出终态,也留不下任何诊断 +/// (2026-10-02 实测:回合 4 分钟不结束、应用日志一行不写)。 +const CLAUDE_CODE_STDERR_DRAIN_TIMEOUT: Duration = Duration::from_secs(5); +/// sidecar stdout 静默多久就写一条"还没收到事件"的应用日志。 +/// +/// Claude Code CLI 在网关侧失败时会自己退避重试,这段时间**一行事件都没有**; +/// 不留痕的话用户与离线诊断只能看到一个卡住的回合(真实案例:ECONNRESET 重试, +/// 每轮 ~60 秒,共 10 轮)。 +const CLAUDE_CODE_EVENT_IDLE_LOG_TIMEOUT: Duration = Duration::from_secs(30); struct ActiveClaudeCodeTurn { client_turn_id: String, @@ -216,11 +228,26 @@ async fn run_sidecar_turn( let mut lines = BufReader::new(stdout).lines(); let mut events = Vec::new(); let mut terminal = None; - while let Some(line) = lines - .next_line() - .await - .map_err(|error| format!("读取 Claude Agent SDK sidecar 输出失败:{error}"))? - { + let mut idle_rounds = 0_u64; + loop { + let line = + match tokio::time::timeout(CLAUDE_CODE_EVENT_IDLE_LOG_TIMEOUT, lines.next_line()) + .await + { + Ok(Ok(Some(line))) => line, + Ok(Ok(None)) => break, + Ok(Err(error)) => { + return Err(format!("读取 Claude Agent SDK sidecar 输出失败:{error}")); + } + Err(_) => { + idle_rounds += 1; + app_log!( + "agent.direct_codex.claude_sidecar_idle idleSeconds={}", + CLAUDE_CODE_EVENT_IDLE_LOG_TIMEOUT.as_secs() * idle_rounds + ); + continue; + } + }; if line.trim().is_empty() { continue; } @@ -237,6 +264,7 @@ async fn run_sidecar_turn( break; } Some("error") => { + kill_claude_code_process_tree(pid); return Err(value .get("message") .and_then(serde_json::Value::as_str) @@ -260,13 +288,19 @@ async fn run_sidecar_turn( { Ok(result) => result?, Err(_) => { - let detail = stderr_task - .await - .ok() - .and_then(Result::ok) - .and_then(|bytes| String::from_utf8(bytes).ok()) + // 先杀进程树:不杀的话 sidecar(连同它守护的 Claude Code CLI)会一直活着, + // stderr 等不到 EOF,这一轮就永远出不了终态。 + kill_claude_code_process_tree(pid); + let stderr_detail = + match tokio::time::timeout(CLAUDE_CODE_STDERR_DRAIN_TIMEOUT, stderr_task).await { + Ok(Ok(Ok(bytes))) => String::from_utf8(bytes).ok(), + _ => None, + } .map(|value| value.trim().chars().take(512).collect::()) .filter(|value| !value.is_empty()) + // 断开 stdout 之后 sidecar 自己会抛 EPIPE;那是收尾噪声,不是失败原因。 + .filter(|value| !value.contains("EPIPE")); + let detail = stderr_detail .map(|value| format!(":{value}")) .unwrap_or_default(); return Err(format!("Claude Agent SDK sidecar 回合超时{detail}"));