From e6933762d24942ee96860852d8da385c675b6308 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:40:40 +0800 Subject: [PATCH] =?UTF-8?q?cc=20=E5=9B=9E=E5=90=88=E9=9D=99=E9=BB=98?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E6=94=B9=E4=B8=BA=E5=AE=BF=E4=B8=BB=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claude_code_cli:一次用户回合最多尝试 3 次,只有"静默超时 + 本轮没请求过任何工具"才自动重放,硬上限与已请求工具的超时仍交回用户 claude_code_cli:抽出 claude_direct_turn_once / ClaudeDirectTurnFailure / claude_direct_failure_action,重试策略变成可单测的纯函数 claude_code_cli:每次重试前落一条 role=system 过程行(正在自动重试(第 N/M 次)),停下时也说明原因,避免"没有重试"又被读成静默兜底 claude_code_cli:失败详情尾部的重试次数标记由分类器还原成结构化 attempts 并摘除,用户可见的"已尝试 N 次"与实际尝试次数一致 claude_code_cli:新增三条单测覆盖重试策略矩阵、静默超时/工具已请求的判定、attempts 结构化与标记不外泄 docs/project-memory/shared-memory/pitfalls:记录现象、CLI 自身不重试的真实二进制证据、现行重试口径与验证方式 --- .../src-tauri/src/agent/claude_code_cli.rs | 378 +++++++++++++++++- docs/project-memory/shared-memory/pitfalls.md | 9 + 2 files changed, 371 insertions(+), 16 deletions(-) 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 069bfc3d1..3d4660094 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 @@ -47,6 +47,17 @@ const CLAUDE_CODE_EVENT_IDLE_LOG_TIMEOUT: Duration = Duration::from_secs(30); /// cc 回合的绝对上限。`requestTimeoutMs` 只算静默预算,事件流假活(一直有心跳但永远 /// 不出终态)靠这个兜底。 const CLAUDE_CODE_TURN_MAX_DURATION: Duration = Duration::from_secs(45 * 60); +/// cc 一轮用户回合最多尝试几次(含首次)。 +/// +/// 现场(2026-10-07,项目 `gameagent-f1893fc4`):平台网关偶发挂住,sidecar 收到 3 个事件 +/// 后连续 180000 ms 一个字节都没有,回合直接失败,用户只能手动点「重试」;而那一次手动重试 +/// 58 秒就成功了。这种挂起是可重放的,不该让用户自己点一次。 +const CLAUDE_DIRECT_TURN_MAX_ATTEMPTS: usize = 3; +/// 自动重试前的冷却,避免对同一个挂住的上游连续施压。 +const CLAUDE_DIRECT_RETRY_BACKOFF: Duration = Duration::from_secs(2); +/// 失败详情里的重试次数标记:`claude_code_failure_to_turn_error` 靠它把「已尝试 N 次」 +/// 还原成结构化 attempts,并把标记从用户可见详情里摘掉。 +const CLAUDE_DIRECT_RETRY_ATTEMPTS_MARKER: &str = ";本轮已自动重试 "; struct ActiveClaudeCodeTurn { client_turn_id: String, @@ -1079,7 +1090,9 @@ pub(crate) fn claude_code_failure_to_llm_error(detail: String) -> platform_llm:: }; } if detail.contains("Claude Agent SDK sidecar 回合超时") { - return platform_llm::LlmError::Timeout { attempts: 1 }; + return platform_llm::LlmError::Timeout { + attempts: claude_direct_retry_attempts(&detail).0, + }; } if detail.contains("Claude Code 缺少最终回复") { return platform_llm::LlmError::EmptyResponse; @@ -1099,14 +1112,14 @@ pub(crate) fn claude_code_failure_to_turn_error( ) -> super::codex_app_server::turn_error::TurnError { use super::codex_app_server::turn_error::{ModelCallFailed, ModelCallKind, TurnError}; + // 宿主自动重试的次数是结构化事实(`attempts`),不能留在给用户看的详情里。 + let (attempts, detail) = claude_direct_retry_attempts(&detail); let llm_error = claude_code_failure_to_llm_error(detail.clone()); match llm_error { - platform_llm::LlmError::Timeout { attempts } => { - TurnError::ModelCallFailed(ModelCallFailed { - kind: ModelCallKind::ResponseTimedOut { attempts }, - detail, - }) - } + platform_llm::LlmError::Timeout { .. } => TurnError::ModelCallFailed(ModelCallFailed { + kind: ModelCallKind::ResponseTimedOut { attempts }, + detail, + }), platform_llm::LlmError::EmptyResponse => TurnError::ModelCallFailed(ModelCallFailed { kind: ModelCallKind::EmptyResponse, detail, @@ -1982,6 +1995,20 @@ impl ClaudeCodeStreamState { None => original, } } + + /// 这一轮值不值得宿主自动重放。 + /// + /// 只有"侧车回合超时"且"从头到尾没有请求过任何工具"两个条件同时成立才是安全的:模型一旦 + /// 发过 tool_use,重跑就可能把付费生成、构建、试玩这些带副作用的调用再执行一遍(哪怕结果 + /// 还没回来,操作也可能已经在途)。其余失败属于凭据 / 额度 / 协议这类重试无意义的分类。 + /// + /// 只认**静默超时**(连续 N 毫秒一个事件都没有 = 上游挂住);45 分钟硬上限是失控回合, + /// 叠三次重试会把一次用户回合拖到两小时以上。 + fn is_auto_retryable_timeout(&self, error: &str) -> bool { + error.contains("Claude Agent SDK sidecar 回合超时:连续 ") + && error.contains("没有任何事件") + && self.tool_calls.is_empty() + } } /// DirectProject 使用 Claude Code 自己的 MCP 调用能力;宿主只暴露 loopback @@ -2035,18 +2062,214 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( mut observer: Option<&mut (dyn FnMut(TurnObservation) + Send)>, ) -> Result { direct_turn_trace("claude-executor-enter"); + let mut attempt = 1_usize; + loop { + let outcome = claude_direct_turn_once( + root, + llm, + &system_prompt, + &user_prompt, + client_turn_id, + &mut observer, + ) + .await; + let failure = match outcome { + Ok(text) => return Ok(text), + Err(failure) => failure, + }; + let retries = attempt.saturating_sub(1); + match claude_direct_failure_action(&failure, attempt) { + ClaudeDirectFailureAction::Retry => { + attempt += 1; + app_log!( + "agent.direct_codex.claude_turn_retry attempt={} max={}", + attempt, + CLAUDE_DIRECT_TURN_MAX_ATTEMPTS + ); + emit_claude_direct_process_notice( + root, + client_turn_id, + &format!( + "等待模型回执超时,正在自动重试(第 {attempt}/{} 次)", + CLAUDE_DIRECT_TURN_MAX_ATTEMPTS + ), + "retry", + attempt, + ); + tokio::time::sleep(CLAUDE_DIRECT_RETRY_BACKOFF).await; + continue; + } + ClaudeDirectFailureAction::Report => { + return Err(failure.message); + } + action => { + // 停下来时也要留一条可见过程行:别让"没有重试"看起来像宿主又悄悄兜底了。 + let notice = match action { + ClaudeDirectFailureAction::StopRetriesExhausted => { + format!("等待模型回执超时,已自动重试 {retries} 次仍未成功;请稍后重试") + } + ClaudeDirectFailureAction::StopToolsRequested => { + "等待模型回执超时,但本轮已经请求过工具,不会自动重放;请手动重试" + .to_string() + } + _ => "等待模型回执超时,已超过本轮硬上限,不再自动重试;请稍后重试".to_string(), + }; + emit_claude_direct_process_notice(root, client_turn_id, ¬ice, "stop", attempt); + return Err(with_claude_direct_retry_attempts(failure.message, retries)); + } + } + } +} + +/// 一次 cc 回合的失败事实。 +/// +/// `retryable` 只在"这一轮从头到尾没有请求过任何工具"时才成立:重跑一轮会重新触发付费生成、 +/// 构建、试玩这些带副作用的调用,所以只有完全没碰过工具的超时才允许宿主自动重放。 +struct ClaudeDirectTurnFailure { + message: String, + kind: ClaudeDirectFailureKind, + retryable: bool, + /// 本轮是否已经请求过工具(哪怕结果还没回来)。 + tools_requested: bool, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum ClaudeDirectFailureKind { + /// sidecar 回合超时(静默超时或硬上限)。 + Timeout, + /// 其它失败:sidecar 报错、非零退出、解析失败、启动失败等。 + Other, +} + +impl ClaudeDirectTurnFailure { + fn fatal(message: String) -> Self { + Self { + message, + kind: ClaudeDirectFailureKind::Other, + retryable: false, + tools_requested: false, + } + } +} + +/// 宿主对一次 cc 回合失败的处理决定。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ClaudeDirectFailureAction { + /// 自动重放这一轮。 + Retry, + /// 用满尝试次数后停下。 + StopRetriesExhausted, + /// 本轮请求过工具,重放会重新触发副作用,交回用户决定。 + StopToolsRequested, + /// 不可重放的超时(例如 45 分钟硬上限)。 + StopNotReplayable, + /// 非超时失败:原样上报,不改写既有分类。 + Report, +} + +/// 重试策略是纯函数:什么情况重放、什么情况停下都能被单测逐格钉住。 +fn claude_direct_failure_action( + failure: &ClaudeDirectTurnFailure, + attempt: usize, +) -> ClaudeDirectFailureAction { + if failure.kind != ClaudeDirectFailureKind::Timeout { + return ClaudeDirectFailureAction::Report; + } + if failure.retryable { + return if attempt < CLAUDE_DIRECT_TURN_MAX_ATTEMPTS { + ClaudeDirectFailureAction::Retry + } else { + ClaudeDirectFailureAction::StopRetriesExhausted + }; + } + if failure.tools_requested { + ClaudeDirectFailureAction::StopToolsRequested + } else { + ClaudeDirectFailureAction::StopNotReplayable + } +} + +/// 每次自动重试都留一条可见过程行:用户看到的是"在重试",而不是一个卡住或莫名失败的回合。 +fn emit_claude_direct_process_notice( + root: &Path, + client_turn_id: Option<&str>, + text: &str, + tag: &str, + attempt: usize, +) { + let at = crate::agent::now_ms(); + let item_id = match client_turn_id { + Some(client_turn_id) => { + format!("direct-codex:{client_turn_id}:claude-turn-{tag}:{attempt}") + } + None => format!( + "direct-codex:{}:claude-turn-{tag}:{attempt}", + uuid::Uuid::new_v4() + ), + }; + crate::agent::append_thread_event( + &crate::agent::thread_id_for_project(root), + ThreadEvent::item_completed( + ThreadItem::Message { + item_id, + role: "system".to_string(), + text: text.to_string(), + at, + }, + at, + ), + ); +} + +/// 把"自动重试了几次"写进失败详情,供上层还原成结构化的 `attempts`。 +fn with_claude_direct_retry_attempts(message: String, retries: usize) -> String { + if retries == 0 { + return message; + } + format!("{message}{CLAUDE_DIRECT_RETRY_ATTEMPTS_MARKER}{retries} 次") +} + +/// 解出 `(已尝试次数, 去掉标记的失败详情)`:标记是宿主自己写的,用户可见详情里不该出现。 +fn claude_direct_retry_attempts(detail: &str) -> (u32, String) { + match detail.split_once(CLAUDE_DIRECT_RETRY_ATTEMPTS_MARKER) { + Some((head, tail)) => { + let retries = tail + .split(' ') + .next() + .and_then(|digits| digits.parse::().ok()) + .unwrap_or(0); + (retries.saturating_add(1).max(1), head.to_string()) + } + None => (1, detail.to_string()), + } +} + +/// 单次 cc 回合:起 loopback MCP、跑 sidecar、投影事件、收口成回复文本。 +async fn claude_direct_turn_once( + root: &Path, + llm: &GameCreatorLlmConfig, + system_prompt: &str, + user_prompt: &str, + client_turn_id: Option<&str>, + observer: &mut Option<&mut (dyn FnMut(TurnObservation) + Send)>, +) -> Result { let (mcp_url, mcp_token) = - start_external_mcp_loopback_for_direct_turn(root, llm.web_search_enabled).await?; + start_external_mcp_loopback_for_direct_turn(root, llm.web_search_enabled) + .await + .map_err(ClaudeDirectTurnFailure::fatal)?; direct_turn_trace("claude-mcp-loopback-ready"); let _mcp_guard = ExternalMcpStopGuard { root: root.to_path_buf(), token: mcp_token.clone(), }; - let home = crate::project::resolve_local_project_path(root, ".agent/runtime/claude-code/home")?; + let home = crate::project::resolve_local_project_path(root, ".agent/runtime/claude-code/home") + .map_err(ClaudeDirectTurnFailure::fatal)?; std::fs::create_dir_all(&home) - .map_err(|error| format!("创建 cc Direct 隔离目录失败:{error}"))?; + .map_err(|error| format!("创建 cc Direct 隔离目录失败:{error}")) + .map_err(ClaudeDirectTurnFailure::fatal)?; let session_key = claude_project_key(root); - let (resume, resume_source) = resolve_claude_direct_resume(root, &home)?; + let (resume, resume_source) = + resolve_claude_direct_resume(root, &home).map_err(ClaudeDirectTurnFailure::fatal)?; app_log!( "agent.direct_codex.claude_resume source={} session={}", resume_source, @@ -2060,8 +2283,8 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( client_turn_id, &format!("agc-direct:{}", session_key.display()), resume, - &user_prompt, - &system_prompt, + user_prompt, + system_prompt, root, llm.model.trim(), CLAUDE_DIRECT_MAX_TURNS, @@ -2070,7 +2293,7 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( ); let mut stream_state = ClaudeCodeStreamState::default(); let mut on_event = |event: &serde_json::Value| { - stream_state.observe(root, event, &mut observer); + stream_state.observe(root, event, observer); }; let sidecar_result = run_sidecar_turn( root, @@ -2085,8 +2308,20 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( let sidecar = match sidecar_result { Ok(sidecar) => sidecar, Err(error) => { + let kind = if error.contains("Claude Agent SDK sidecar 回合超时") { + ClaudeDirectFailureKind::Timeout + } else { + ClaudeDirectFailureKind::Other + }; + let tools_requested = !stream_state.tool_calls.is_empty(); + let retryable = stream_state.is_auto_retryable_timeout(&error); stream_state.persist_partial_history(root); - return Err(stream_state.with_history_error(error)); + return Err(ClaudeDirectTurnFailure { + message: stream_state.with_history_error(error), + kind, + retryable, + tools_requested, + }); } }; direct_turn_trace("claude-sidecar-done"); @@ -2113,7 +2348,7 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( direct_turn_trace("claude-parse-error"); } } - parsed + parsed.map_err(ClaudeDirectTurnFailure::fatal) } fn persist_direct_claude_assistant_reply_at( @@ -2828,4 +3063,115 @@ mod tests { ); claude_direct_sessions().lock().unwrap().remove(&key); } + + #[test] + fn claude_direct_timeout_is_only_auto_replayed_without_tool_requests() { + let mut state = ClaudeCodeStreamState::default(); + let timeout = + "Claude Agent SDK sidecar 回合超时:连续 180000 ms 没有任何事件(已收到 3 个事件)"; + // 从没请求过工具的超时可以自动重放(现场:网关挂住 3 分钟,用户手动重试 58 秒就成功了)。 + assert!(state.is_auto_retryable_timeout(timeout)); + // 非超时失败不自动重放:凭据 / 额度 / 协议这类重试没有意义。 + assert!(!state.is_auto_retryable_timeout("Claude Agent SDK sidecar 非零退出;exitStatus=1")); + // 45 分钟硬上限是失控回合,不叠重试(否则一次用户回合能被拖到两小时以上)。 + assert!(!state + .is_auto_retryable_timeout("Claude Agent SDK sidecar 回合超时:超过 45 分钟硬上限")); + // 模型一旦请求过工具,重放就可能把付费生成 / 构建 / 试玩再执行一遍——哪怕结果还没回来, + // 操作也可能已经在途,所以只按"没碰过工具"这一条放行。 + state.tool_calls.insert( + "tool-1".to_string(), + ClaudeCodeToolState { + name: "agc_generate_image".to_string(), + arguments: String::new(), + started: true, + completed: false, + }, + ); + assert!(!state.is_auto_retryable_timeout(timeout)); + } + + #[test] + fn claude_direct_retry_policy_retries_only_safe_timeouts() { + let timeout = |retryable, tools_requested| ClaudeDirectTurnFailure { + message: "Claude Agent SDK sidecar 回合超时:连续 180000 ms 没有任何事件".to_string(), + kind: ClaudeDirectFailureKind::Timeout, + retryable, + tools_requested, + }; + // 静默超时、没碰过工具:第 1、2 次都继续重放,第 3 次(用满尝试次数)停下。 + let replayable = timeout(true, false); + assert_eq!( + claude_direct_failure_action(&replayable, 1), + ClaudeDirectFailureAction::Retry + ); + assert_eq!( + claude_direct_failure_action(&replayable, 2), + ClaudeDirectFailureAction::Retry + ); + assert_eq!( + claude_direct_failure_action(&replayable, CLAUDE_DIRECT_TURN_MAX_ATTEMPTS), + ClaudeDirectFailureAction::StopRetriesExhausted + ); + // 请求过工具 / 硬上限一类的不可重放超时:一次都不重放,并且各自的说明不同。 + assert_eq!( + claude_direct_failure_action(&timeout(false, true), 1), + ClaudeDirectFailureAction::StopToolsRequested + ); + assert_eq!( + claude_direct_failure_action(&timeout(false, false), 1), + ClaudeDirectFailureAction::StopNotReplayable + ); + // 非超时失败原样上报,不改写既有分类。 + let mut fatal = + ClaudeDirectTurnFailure::fatal("Claude Agent SDK sidecar 非零退出".to_string()); + fatal.kind = ClaudeDirectFailureKind::Other; + assert_eq!( + claude_direct_failure_action(&fatal, 1), + ClaudeDirectFailureAction::Report + ); + } + + #[test] + fn claude_direct_retry_attempts_reach_the_structured_error_without_leaking() { + use crate::agent::codex_app_server::turn_error::{ModelCallKind, TurnError}; + + let detail = + "Claude Agent SDK sidecar 回合超时:连续 180000 ms 没有任何事件(已收到 3 个事件)" + .to_string(); + // 没重试过时保持既有行为:1 次、详情原样。 + assert_eq!(claude_direct_retry_attempts(&detail), (1, detail.clone())); + match claude_code_failure_to_turn_error(detail.clone()) { + TurnError::ModelCallFailed(failure) => assert!(matches!( + failure.kind, + ModelCallKind::ResponseTimedOut { attempts: 1 } + )), + other => panic!("超时必须收口成 ModelCallFailed:{other:?}"), + } + + // 三次尝试(两次自动重试)后:attempts 是结构化事实,标记不能留在用户可见详情里。 + let with_retries = with_claude_direct_retry_attempts(detail.clone(), 2); + assert_eq!( + claude_direct_retry_attempts(&with_retries), + (3, detail.clone()) + ); + assert!(matches!( + claude_code_failure_to_llm_error(with_retries.clone()), + platform_llm::LlmError::Timeout { attempts: 3 } + )); + match claude_code_failure_to_turn_error(with_retries) { + TurnError::ModelCallFailed(failure) => { + assert!(matches!( + failure.kind, + ModelCallKind::ResponseTimedOut { attempts: 3 } + )); + assert!( + !failure.detail.contains("本轮已自动重试"), + "{}", + failure.detail + ); + assert!(failure.detail.contains("连续 180000 ms 没有任何事件")); + } + other => panic!("超时必须收口成 ModelCallFailed:{other:?}"), + } + } } diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index ff4f2ff82..b738c2294 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6578,3 +6578,12 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **现行口径**:恢复顺序固定为「进程内会话表 → 本项目目录下 mtime 最新的会话轨迹」。目录名按 Claude Code 自己的派生规则算(cwd 里非字母数字一律换成 `-`,实例 `C:\Users\...\projects\gameagent-0514673b` → `C--Users-...-projects-gameagent-0514673b`),并且只认本项目目录——SDK 的 `resume` 也只在那里查这份轨迹,从别的目录捞来的 id 会被判成「No conversation found」,比不恢复更糟。日志 `agent.direct_codex.claude_resume source=memory|disk|none` 记录本回合的恢复来源,用户再报「看不到历史对话」时可以一眼分辨是内存命中、磁盘回放,还是本项目确实没有历史会话。 - **验证**:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute --bin genarrative-ai-game-creator-shell claude_` 34 passed(新增 `claude_code_session_lookup_uses_the_project_directory_and_newest_trace`、`claude_direct_resume_falls_back_to_disk_and_prefers_the_process_map`)。真实环境用随包 `claude.exe`(2.1.285)复验:把项目隔离 home 的 `projects/` 拷进临时 `CLAUDE_CONFIG_DIR`、cwd 设为项目根,`--resume 00000000-…` 立刻回 `No conversation found with session ID`;`--resume f79dae28-…`(磁盘上最新那份)不报找不到,直接进入模型请求,落在本地抓包桩上的请求体里带着完整历史(`messages` 13 条,首条就是 14:23 的「做个废土风的扫雷…」原文)。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs`。 + +## 2026-10-07 cc 回合超时后宿主不重试,只能用户手动点「重试」 + +- **现象**:用户截图 `等待模型回执超时(已尝试 1 次),本轮未完成;请稍后重试:Claude Agent SDK sidecar 回合超时:连续 180000 ms 没有任何事件(已收到 3 个事件)`——一轮卡满 3 分钟就判失败,用户只能自己点「重试」,而那次手动重试 58 秒就成功了;同一项目当天 15:07 又原样复现一次(15:10 失败)。 +- **根因**:cc 执行器一轮只跑一次 sidecar,超时就直接判回合失败;只有 Codex 那条路径带重试意识(`ModelCallKind::ResponseTimedOut { attempts }` 的 attempts 在 cc 侧被硬编码成 `1`)。挂住的是平台网关(一个字节都不回),不是模型拒绝——重放本来是有意义的。 +- **边界证据(真实二进制)**:把随包 `claude.exe`(2.1.285)指向一个"收下请求就不回任何字节"的本地桩,默认配置和显式 `CLAUDE_ENABLE_BYTE_WATCHDOG=1` + `CLAUDE_BYTE_STREAM_IDLE_TIMEOUT_MS=60000` + `CLAUDE_STREAM_IDLE_TIMEOUT_MS=60000` + `CLAUDE_CODE_MAX_RETRIES=2` 两种配置下,300 秒(第二组 180 秒)内都只有 `system/init` 一条事件、桩上也没有第二次请求,**没有任何重试或报错**。所以"超时后重试"这件事只能由宿主做,不能指望 CLI 或那些 watchdog 环境变量自己兜。 +- **现行口径**:`direct_game_creator_claude_code_chat_at` 改成一次用户回合最多 `CLAUDE_DIRECT_TURN_MAX_ATTEMPTS = 3` 次尝试(第一版:2 次自动重试),**只有两个条件同时成立才自动重放**:①失败是"静默超时"(`连续 N ms 没有任何事件`;45 分钟硬上限不重放,否则一次回合能被拖到两小时以上);②这一轮从头到尾**没有请求过任何工具**——模型一旦发过 `tool_use`,重跑就可能把付费生成 / 构建 / 试玩再执行一遍(哪怕结果还没回来,操作也可能已经在途),这时改为落一条"本轮已经请求过工具,不会自动重放;请手动重试"的过程行。每次重试前先落一条 `role:"system"` 过程行(`等待模型回执超时,正在自动重试(第 2/3 次)`),重试同样走 `resume`,历史与上下文不丢。最终失败时的 `已尝试 N 次` 由宿主回填:详情尾部的 `;本轮已自动重试 N 次` 只是结构化传递标记,分类器会把它摘掉,不落到用户可见详情里。 +- **验证**:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute --bin genarrative-ai-game-creator-shell claude_` 37 passed。新增三条:`claude_direct_retry_policy_retries_only_safe_timeouts`(逐格钉住重试/停下的四种决定)、`claude_direct_timeout_is_only_auto_replayed_without_tool_requests`(硬上限不重放、请求过工具不重放)、`claude_direct_retry_attempts_reach_the_structured_error_without_leaking`(attempts 结构化,标记不落到用户可见详情)。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs`。