cc 回合静默超时改为宿主自动重试
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled

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 自身不重试的真实二进制证据、现行重试口径与验证方式
This commit is contained in:
kdletters
2026-10-07 15:40:40 +08:00
parent 93897b88ac
commit e6933762d2
2 changed files with 371 additions and 16 deletions
@@ -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<String, String> {
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, &notice, "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::<u32>().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<String, ClaudeDirectTurnFailure> {
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:?}"),
}
}
}
@@ -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`。