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
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:
@@ -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, ¬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::<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`。
|
||||
|
||||
Reference in New Issue
Block a user