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 8029bb358..8e8acf84c 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 @@ -18,6 +18,17 @@ const CLAUDE_NODE_ENV: &str = "GENARRATIVE_CLAUDE_NODE"; /// DirectProject 的一次 CC 回合可能包含代码生成、试玩、修复和再次复核;8 次会在真实 /// 游戏链路的修复阶段提前收口成 `Reached maximum number of turns (8)`。 const CLAUDE_DIRECT_MAX_TURNS: u32 = 16; + +/// cc 侧 loopback MCP 工具调用的硬超时(毫秒)。 +/// +/// Claude Code 的 MCP 工具调用默认只有 60 秒硬墙,且"进度通知不会延长"它。AGC 的工具里 +/// 包含真实付费与长耗时操作:图片生成一次约 56~70 秒,浏览器试玩、构建、验证也都在分钟级。 +/// 现场证据(2026-10-06,项目 gameagent-cfd26650):模型在 13:05:30.760 发起两个 +/// `agc_generate_image`,13:06:30.767(正好 60.0 秒)MCP 客户端返回 +/// `{"content":"The operation timed out.","is_error":true}`,随后同轮的第二个调用被判 +/// `direct-execution-interrupted: 原执行未正常结算`,本轮账本落成 `phase=interrupted`。 +/// Codex 侧对同一套工具桥已设 `tool_timeout_sec=6600`(110 分钟),这里按同一口径对齐。 +const CLAUDE_CODE_MCP_TOOL_TIMEOUT_MS: u64 = 110 * 60 * 1_000; 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; @@ -185,6 +196,24 @@ struct SidecarTurnResult { result: serde_json::Value, } +/// 把 cc sidecar 的进程树退出证明写进当前回合账本。 +/// +/// 交付封口(`finish_sealing`)要求 `executor_stopped`,而这项证明过去只有 Codex app-server +/// 会写:cc 回合因此永远停在 sealing(`deliveryReviews` 烧到上限后落成 interrupted), +/// 下一轮的修改与付费调用再被 `direct-execution-interrupted` 拦住。这里让 cc 执行器按同一 +/// 口径上报"后台子树确实已退出",证明缺失时如实上报失败而不是缺席。 +fn record_claude_sidecar_exit_proof(root: &Path, proven: Option) { + let Some(proven) = proven else { + return; + }; + app_log!("agent.direct_codex.claude_sidecar_exit_proof proven={proven}"); + if let Ok(session) = super::direct_execution::current(root) { + if let Err(error) = session.record_process_exit_proof(proven) { + app_log!("agent.direct_codex.claude_sidecar_exit_proof_unrecorded detail={error}"); + } + } +} + /// sidecar 回合的失败分类:静默超时必须先杀进程树再收口,普通失败直接透传。 enum SidecarTurnFailure { Silent { silent_ms: u64, events: usize }, @@ -215,6 +244,18 @@ async fn run_sidecar_turn( let pid = child .id() .ok_or_else(|| "Claude Agent SDK sidecar 进程缺少 PID".to_string())?; + // 与 Codex app-server 同一套 Job Object 归属:只有把 sidecar 及其子进程树纳入受控 + // 归属,回合结束时才拿得到"子树已退出"的可信证明,交付封口才有条件完成。 + let owned_tree = match super::codex_app_server::process_tree::OwnedProcessTree::attach_with_role( + &child, + "ClaudeCode", + ) { + Ok(tree) => Some(tree), + Err(error) => { + app_log!("agent.direct_codex.claude_sidecar_tree_unowned detail={error}"); + None + } + }; let active_key = active_turn.map(|_| claude_project_key(root)); let active_alive = Arc::new(AtomicBool::new(true)); if let (Some(key), Some(client_turn_id)) = (active_key.clone(), active_turn) { @@ -376,6 +417,15 @@ async fn run_sidecar_turn( "Claude Agent SDK sidecar 非零退出;exitStatus={exit_status}" ))); } + let proven = match owned_tree.as_ref() { + Some(tree) => Some( + tree.shutdown(&mut child) + .await + .is_ok_and(|proof| proof.confirmed()), + ), + None => None, + }; + record_claude_sidecar_exit_proof(root, proven); Ok(SidecarTurnResult { result }) }; let outcome = match tokio::time::timeout(CLAUDE_CODE_TURN_MAX_DURATION, turn).await { @@ -401,7 +451,21 @@ async fn run_sidecar_turn( // 所有失败都走同一条收口:先杀进程树,再以有界窗口读取 stderr。之前只有 // 静默超时读取 stderr,cc 的非零退出 / stdout JSON 错误 / RPC error 会丢掉 // sidecar 给出的真正原因,最终只剩一条 transport 通用句。 - let kill_detail = kill_claude_code_process_tree(pid).err(); + // 失败路径同样要收口受控子树并留下退出证明:证明确认时不必再叠加 taskkill, + // 证明缺失时回退到原有的进程树强杀,保证不留后台执行器。 + let proven = match owned_tree.as_ref() { + Some(tree) => Some( + tree.shutdown(&mut child) + .await + .is_ok_and(|proof| proof.confirmed()), + ), + None => None, + }; + record_claude_sidecar_exit_proof(root, proven); + let kill_detail = match proven { + Some(true) => None, + _ => kill_claude_code_process_tree(pid).err(), + }; let stderr_detail = match tokio::time::timeout(CLAUDE_CODE_STDERR_DRAIN_TIMEOUT, stderr_task).await { Ok(Ok(Ok(bytes))) => match String::from_utf8(bytes) { @@ -690,7 +754,13 @@ fn configure_claude_code_environment( .env("DISABLE_AUTOUPDATER", "1") .env("CI", "1") .env("NO_COLOR", "1") - .env("TERM", "dumb"); + .env("TERM", "dumb") + // 不设这一项,CC 的 MCP 工具调用会回落到 60 秒硬超时,把分钟级的付费生成/试玩 + // 直接取消掉,并让本轮租约未结算、后续工具全部被拒。 + .env( + "MCP_TOOL_TIMEOUT", + CLAUDE_CODE_MCP_TOOL_TIMEOUT_MS.to_string(), + ); let session = crate::platform_session::current_platform_session(); let route = claude_code_route(llm, session.as_ref()); app_log!( @@ -1138,7 +1208,13 @@ struct ClaudeCodeToolState { #[derive(Default, Debug)] struct ClaudeCodeStreamState { current_message_id: String, - block_item_ids: HashMap<(String, usize), String>, + /// 内容块 → 过程条目 id。 + /// + /// 键必须带内容块类型:Claude Code 会把同一条 message 的内容块拆成多条 `assistant` + /// 事件(每条只含一个块),这些事件在各自数组里的下标都是 0。只按 `(message_id, index)` + /// 记忆,会让同一条消息里的 thinking 与 text 抢同一个 id(现场:`…:thinking:0` 先落盘, + /// 随后 text 复用该 id 且内容不同,历史层判冲突并让整轮变成“过程历史保存失败”)。 + block_item_ids: HashMap<(String, String, usize), String>, block_tool_ids: HashMap<(String, usize), String>, tool_calls: HashMap, history_error: Option, @@ -1198,11 +1274,43 @@ fn claude_stream_observe( } } +/// 过程条目 id 撞车时的兜底条目:保留原内容,只把 id 换成带唯一后缀的取值。 +/// +/// 历史层只接受“同 id 同内容”的重复写;同 id 不同内容一律判冲突。Claude Code 会把同一条 +/// message 的内容块拆成多条 `assistant` 事件,因此仍有无法用 `(message_id, kind, index)` +/// 区分的情况(例如同一条消息里的两个 text 块)。撞车时宁可多留一条过程历史,也不能让已经 +/// 跑完的整轮被判成失败。缺失 id 时给一个自说明的兜底 id,保证追加一定能拿到唯一键。 +fn completed_item_conflict_fallback(item: &serde_json::Value) -> serde_json::Value { + let mut retried = item.clone(); + let fallback_id = match item.get("id").and_then(serde_json::Value::as_str) { + Some(id) if !id.trim().is_empty() => format!("{id}:{}", uuid::Uuid::new_v4()), + _ => format!("direct-cc:item:{}", uuid::Uuid::new_v4()), + }; + if let Some(object) = retried.as_object_mut() { + object.insert("id".to_string(), serde_json::Value::String(fallback_id)); + } + retried +} + impl ClaudeCodeStreamState { /// 已完成过程与 Codex 共用项目历史出口,收到时即保存;终态失败不能撤掉前面已发生的事实。 fn persist_completed_item(&mut self, root: &Path, item: &serde_json::Value) { - if let Err(error) = append_direct_project_history_item_at(root, item) { - self.history_error.get_or_insert(error); + match append_direct_project_history_item_at(root, item) { + Ok(()) => {} + Err(error) if error.starts_with("DirectProject 历史 item id 冲突:") => { + // 同一个 message id 的内容块会在多条 `assistant` 事件里重复出现,条目 id 一旦 + // 与已有条目“同 id 不同内容”,历史层会判冲突。这里按收尾回复的既有做法补一个 + // 唯一后缀:把 id 撞车降级成多留一条过程历史,而不是让整轮变成过程历史保存失败。 + let retried = completed_item_conflict_fallback(item); + if let Err(retry_error) = append_direct_project_history_item_at(root, &retried) { + self.history_error.get_or_insert(format!( + "过程历史追加失败:{retry_error}(首个条目冲突:{error})" + )); + } + } + Err(error) => { + self.history_error.get_or_insert(error); + } } } @@ -1215,7 +1323,7 @@ impl ClaudeCodeStreamState { fn block_item_id(&mut self, message_id: &str, index: usize, kind: &str) -> String { self.block_item_ids - .entry((message_id.to_string(), index)) + .entry((message_id.to_string(), kind.to_string(), index)) .or_insert_with(|| format!("direct-cc:{message_id}:{kind}:{index}")) .clone() } @@ -1495,13 +1603,13 @@ impl ClaudeCodeStreamState { } let item_id = self.block_item_id(message_id, index, "text"); self.partial_history - .complete_item(&serde_json::json!({"id": item_id})); + .complete_item(&serde_json::json!({"id": item_id.clone()})); let at = crate::agent::now_ms(); crate::agent::append_thread_event( &crate::agent::thread_id_for_project(root), ThreadEvent::item_completed( ThreadItem::Message { - item_id, + item_id: item_id.clone(), role: "assistant".to_string(), text: text.clone(), at, @@ -1509,12 +1617,15 @@ impl ClaudeCodeStreamState { at, ), ); - self.persist_completed_item(root, &serde_json::json!({ - "type": "message", - "role": "assistant", - "id": self.block_item_ids.get(&(message_id.to_string(), index)).cloned().unwrap_or_default(), - "content": [{"type": "output_text", "text": text}], - })); + self.persist_completed_item( + root, + &serde_json::json!({ + "type": "message", + "role": "assistant", + "id": item_id, + "content": [{"type": "output_text", "text": text}], + }), + ); self.assistant_texts.push(text); claude_stream_observe( observer, @@ -1870,6 +1981,54 @@ mod tests { request } + #[test] + fn claude_block_item_id_keeps_thinking_and_text_apart() { + let mut state = ClaudeCodeStreamState::default(); + let thinking = state.block_item_id("msg_1", 0, "thinking"); + let text = state.block_item_id("msg_1", 0, "text"); + + assert_eq!(thinking, "direct-cc:msg_1:thinking:0"); + assert_eq!(text, "direct-cc:msg_1:text:0"); + assert_ne!(thinking, text); + // 同一个块重复出现(Claude Code 按块分事件重发同一 message)必须复用同一个 id。 + assert_eq!(state.block_item_id("msg_1", 0, "thinking"), thinking); + assert_eq!(state.block_item_id("msg_1", 0, "text"), text); + } + + #[test] + fn claude_completed_item_conflict_fallback_keeps_content_and_unique_id() { + let item = serde_json::json!({ + "type": "reasoning", + "id": "direct-cc:msg_1:thinking:0", + "text": "已经落盘过的思考内容", + }); + let fallback = completed_item_conflict_fallback(&item); + + assert_eq!(fallback["type"], item["type"]); + assert_eq!(fallback["text"], item["text"]); + let fallback_id = fallback["id"].as_str().expect("fallback id"); + assert_ne!(fallback_id, "direct-cc:msg_1:thinking:0"); + assert!( + fallback_id.starts_with("direct-cc:msg_1:thinking:0:"), + "兜底 id 必须保留原 id 便于排查:{fallback_id}" + ); + assert_ne!( + fallback["id"], + completed_item_conflict_fallback(&item)["id"], + "兜底 id 每次都必须是新的唯一取值" + ); + + let without_id = serde_json::json!({ "type": "reasoning", "text": "无 id" }); + let fallback_without_id = completed_item_conflict_fallback(&without_id); + assert!( + fallback_without_id["id"] + .as_str() + .expect("fallback id") + .starts_with("direct-cc:item:"), + "缺少 id 时也要给出自说明的兜底 id" + ); + } + #[test] fn parses_claude_code_text_result() { let response = parse_claude_code_result( @@ -2324,5 +2483,11 @@ mod tests { ] { assert!(!envs.contains_key(name), "{name} 不应下发到 sidecar"); } + // MCP 工具调用必须显式抬高超时:默认 60 秒会把分钟级的付费生成/试玩取消掉, + // 让本轮租约未结算并拒绝后续工具调用。 + assert_eq!( + envs.get("MCP_TOOL_TIMEOUT"), + Some(&Some(CLAUDE_CODE_MCP_TOOL_TIMEOUT_MS.to_string())) + ); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 1d5e3ff81..2fa09fbfe 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -11,7 +11,7 @@ use tokio::sync::{mpsc, oneshot, Mutex, Notify}; mod direct_project_history_wire; use direct_project_history_wire::build_direct_project_history_injection_params; -mod process_tree; +pub(crate) mod process_tree; use process_tree::{OwnedProcessTree, ProcessTreeExitProof}; mod direct_project_identity; mod execution; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/process_tree.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/process_tree.rs index e83fb8333..5b913db5e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/process_tree.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/process_tree.rs @@ -30,7 +30,7 @@ impl ProcessTreeExitProof { } } -pub(super) struct OwnedProcessTree { +pub(crate) struct OwnedProcessTree { pid: u32, #[cfg(unix)] start_identity: String, @@ -41,7 +41,13 @@ pub(super) struct OwnedProcessTree { impl OwnedProcessTree { /// 调用方只能在此成功后发送 initialize,保证所有受控模型执行都在归属内。 - pub(super) fn attach(child: &Child) -> Result { + pub(crate) fn attach(child: &Child) -> Result { + Self::attach_with_role(child, "Codex") + } + + /// 具名角色版本:Claude Code sidecar 与 Codex app-server 都是受控执行器, + /// 共用同一套 Job Object 归属与退出证明,只是 Job 名字要能区分来源便于排障。 + pub(crate) fn attach_with_role(child: &Child, role: &str) -> Result { let pid = child.id().ok_or("app-server-process-owner-missing")?; #[cfg(unix)] let start_identity = @@ -52,7 +58,7 @@ impl OwnedProcessTree { #[cfg(windows)] let job = crate::process_session::WindowsProcessJob::assign_tokio_named( child, - &format!("Local\\AGCCodex-{}", uuid::Uuid::new_v4()), + &format!("Local\\AGC{role}-{}", uuid::Uuid::new_v4()), ) .map_err(|error| format!("app-server-process-job-unavailable:{error}"))?; #[cfg(not(any(windows, unix)))] @@ -68,7 +74,7 @@ impl OwnedProcessTree { }) } - pub(super) async fn shutdown(&self, child: &mut Child) -> Result { + pub(crate) async fn shutdown(&self, child: &mut Child) -> Result { let mut stored = self.exit_proof.lock().await; if let Some(proof) = stored.as_ref() { return proof.clone(); @@ -78,7 +84,7 @@ impl OwnedProcessTree { result } - pub(super) async fn recorded_exit_proof(&self) -> Result { + pub(crate) async fn recorded_exit_proof(&self) -> Result { self.exit_proof .lock() .await diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index f3d94ad20..dae7e74d2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -1208,6 +1208,9 @@ fn external_asset_host_is_private(host: &str) -> bool { || address.is_unique_local() || address.is_unicast_link_local() || address.is_multicast() + // 与 IPv4 的 198.18/15 对齐:RFC 5180 的 2001:2::/48 同样只有“经已鉴权 + // 稳定引用换签”这一条窄例外能放行,用户或上游直接给的地址仍然失败关闭。 + || external_asset_ip_is_proxy_benchmark(std::net::IpAddr::V6(address)) || address .to_ipv4_mapped() .is_some_and(|mapped| external_asset_host_is_private(&mapped.to_string())) @@ -1221,7 +1224,14 @@ fn external_asset_ip_is_proxy_benchmark(address: std::net::IpAddr) -> bool { let [first, second, ..] = address.octets(); first == 198 && (18..=19).contains(&second) } - std::net::IpAddr::V6(_) => false, + // Clash / Clash.Meta 的 IPv6 fake-IP 默认段是 RFC 5180 的 2001:2::/48,与 IPv4 的 + // RFC 2544 段是同一类“透明代理占位地址”。双栈解析会同时返回两个 fake-IP(现场: + // OSS 域名解析出 198.18.0.162 + 2001:2::91),只承认 IPv4 会让“全部地址都是 + // fake-IP”永远不成立,把正常的平台资产下载误判成私网请求并拒绝。 + std::net::IpAddr::V6(address) => { + let segments = address.segments(); + segments[0] == 0x2001 && segments[1] == 0x0002 && segments[2] == 0x0000 + } } } @@ -1235,9 +1245,9 @@ fn external_asset_resolved_addresses_are_safe( { return true; } - // Clash 等透明代理会把公网域名映射到 RFC 2544 的 198.18.0.0/15 fake-IP。 - // 只有经已鉴权 objectKey/legacy path 换签得到的 URL 可以使用这项窄例外; - // 用户或上游直接提供的 URL、其它私网地址及公私混合解析仍然失败关闭。 + // Clash 等透明代理会把公网域名映射到 RFC 2544 的 198.18.0.0/15(IPv4)和 RFC 5180 的 + // 2001:2::/48(IPv6)fake-IP。只有经已鉴权 objectKey/legacy path 换签得到的 URL 可以使 + // 用这项窄例外;用户或上游直接提供的 URL、其它私网地址及公私混合解析仍然失败关闭。 came_from_stable_reference && !addresses.is_empty() && addresses @@ -3061,11 +3071,28 @@ mod tests { assert!(external_asset_resolved_addresses_are_safe(&fake_ip, true)); assert!(!external_asset_resolved_addresses_are_safe(&fake_ip, false)); + // Clash 双栈 fake-IP:IPv6 侧是 RFC 5180 的 2001:2::/48,必须与 IPv4 一起放行, + // 否则双栈解析下“全部地址都是 fake-IP”永不成立,正常资产下载会被误拒。 + let fake_ipv6 = ["[2001:2::91]:443".parse().expect("parse proxy fake IPv6")]; + assert!(external_asset_resolved_addresses_are_safe(&fake_ipv6, true)); + assert!(!external_asset_resolved_addresses_are_safe( + &fake_ipv6, false + )); + let dual_stack_fake_ips = [ + "198.18.0.162:443".parse().expect("parse proxy fake IPv4"), + "[2001:2::91]:443".parse().expect("parse proxy fake IPv6"), + ]; + assert!(external_asset_resolved_addresses_are_safe( + &dual_stack_fake_ips, + true + )); + for address in [ "127.0.0.1:443", "10.0.0.1:443", "169.254.169.254:80", "[::1]:443", + "[fd00::1]:443", ] { let addresses = [address.parse().expect("parse private address")]; assert!(!external_asset_resolved_addresses_are_safe(