From d22e8071be2c0901e0dff57509d0e03d21e3da8d Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:35:23 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E6=94=B6=E6=95=9B=20CC=20?= =?UTF-8?q?=E4=B8=8E=20DirectProject=20=E9=94=99=E8=AF=AF=E6=AD=A3?= =?UTF-8?q?=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留 CC sidecar 清理、stderr 解码和会话锁错误 让 MCP 服务、manifest、时间和 I/O 错误返回具体正文 让失败终态缺载荷与完成判定读取错误可见 补充 Win32 进程诊断、回归测试和排障记录 --- .../src-tauri/src/agent/claude_code_cli.rs | 55 ++++-- .../src-tauri/src/agent/direct_runtime/mod.rs | 64 ++++++- .../src-tauri/src/agent/direct_tools_mcp.rs | 160 +++++++++++++----- .../src/agent/thread_manager/dispatch.rs | 5 +- .../src-tauri/src/process_session/model.rs | 40 ++++- .../chat/conversation/directThreadChat.ts | 9 +- .../tests/directThreadChat.test.ts | 6 +- docs/project-memory/shared-memory/pitfalls.md | 7 + 8 files changed, 269 insertions(+), 77 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 bedde75b8..3670370e8 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 @@ -59,20 +59,33 @@ fn claude_project_key(root: &Path) -> PathBuf { std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()) } -fn kill_claude_code_process_tree(pid: u32) { +fn kill_claude_code_process_tree(pid: u32) -> Result<(), String> { #[cfg(unix)] - unsafe { - libc::kill(-(pid as i32), libc::SIGKILL); + { + let result = unsafe { libc::kill(-(pid as i32), libc::SIGKILL) }; + if result != 0 { + return Err(format!( + "终止 Claude Code sidecar 进程树失败:{}", + std::io::Error::last_os_error() + )); + } } #[cfg(windows)] { - let _ = std::process::Command::new("taskkill") + let status = std::process::Command::new("taskkill") .args(["/PID", &pid.to_string(), "/T", "/F"]) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) - .status(); + .status() + .map_err(|error| format!("启动 taskkill 终止 Claude Code sidecar 失败:{error}"))?; + if !status.success() { + return Err(format!( + "终止 Claude Code sidecar 进程树失败:taskkill 返回 {status}" + )); + } } + Ok(()) } struct ActiveClaudeCodeTurnGuard { @@ -322,7 +335,9 @@ async fn run_sidecar_turn( break; } Some("error") => { - kill_claude_code_process_tree(pid); + if let Err(error) = kill_claude_code_process_tree(pid) { + app_log!("agent.direct_codex.claude_sidecar_kill_failed detail={error}"); + } let detail = claude_result_error_detail(&value).or_else(|| { last_event_detail .clone() @@ -383,14 +398,17 @@ async fn run_sidecar_turn( // 所有失败都走同一条收口:先杀进程树,再以有界窗口读取 stderr。之前只有 // 静默超时读取 stderr,cc 的非零退出 / stdout JSON 错误 / RPC error 会丢掉 // sidecar 给出的真正原因,最终只剩一条 transport 通用句。 - kill_claude_code_process_tree(pid); + let kill_detail = 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))) => String::from_utf8(bytes) - .map_err(|error| { - format!("读取 Claude Agent SDK sidecar stderr 失败:{error}") - }) - .ok(), + Ok(Ok(Ok(bytes))) => match String::from_utf8(bytes) { + Ok(value) => Some(value), + Err(error) => Some(format!( + "读取 Claude Agent SDK sidecar stderr 失败:{};原始字节数={}", + error.utf8_error(), + error.as_bytes().len() + )), + }, Ok(Ok(Err(error))) => Some(format!( "读取 Claude Agent SDK sidecar stderr 失败:{error}" )), @@ -422,8 +440,10 @@ async fn run_sidecar_turn( } }); let detail = stderr_detail + .into_iter() + .chain(kill_detail) .map(|value| format!(":{value}")) - .unwrap_or_default(); + .collect::(); return Err(format!("{reason}{detail}")); } }; @@ -451,7 +471,9 @@ pub(crate) fn cancel_direct_claude_code_turn_at( } } active.alive.store(false, Ordering::Release); - kill_claude_code_process_tree(active.pid); + if let Err(error) = kill_claude_code_process_tree(active.pid) { + return Err(error); + } let client_turn_id = active.client_turn_id.clone(); Ok(Some(super::codex_app_server::TurnCancelView { outcome: super::codex_app_server::DIRECT_TURN_CANCEL_OUTCOME_INTERRUPTED.to_string(), @@ -1696,8 +1718,9 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( let session_key = claude_project_key(root); let resume = claude_direct_sessions() .lock() - .ok() - .and_then(|sessions| sessions.get(&session_key).cloned()); + .map_err(|_| "Claude Code Direct 会话表不可用,无法恢复上一次会话".to_string())? + .get(&session_key) + .cloned(); direct_turn_trace("claude-sidecar-start"); let payload = serde_json::json!({ "type": "turn", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 69c7708e9..1d4aed6c7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -2091,7 +2091,7 @@ fn record_direct_codex_failure_facts( "未能保存项目诊断" }; // sidecar 照写,但引用不进用户可见文案。 - let _ = persist_agent_runtime_error( + if let Err(error) = persist_agent_runtime_error( root, client_turn_id, "direct-codex", @@ -2103,8 +2103,16 @@ fn record_direct_codex_failure_facts( serde_json::json!({ "legacyDiagnosticWritten": diagnostic_written, }), - ) - .ok(); + ) { + // 失败载荷仍然会带着原始 detail 返回前端;这里额外保留统一错误事件落盘失败的 + // OS/JSON/时钟正文,不能让诊断写入失败也被 `.ok()` 吞掉。 + app_log!( + "agent.runtime.error_persist_failed source=direct-codex stage={} code={} detail={}", + stage, + error_code, + error + ); + } let public_text = format!( "direct-codex-failure:v2 stage={} code={} retryable={} summary={};建议:{};{}", stage, @@ -2549,6 +2557,53 @@ fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec { .collect() } +/// 完成判定前的输入完整性检查。 +/// +/// 运行态的“是否引用平台素材”是业务判定;源码读取、清单解析和已登记图片读取则是 +/// 宿主 I/O。后者失败时不能把权限、损坏 JSON 或磁盘错误伪装成“没有引用素材”,否则 +/// 回合会继续走同一条通用返修提示,用户看不到真正原因。 +fn validate_direct_completion_inputs(root: &Path) -> Result<(), String> { + let entry = agent_runtime_game_entry_relative_path(root); + let source_paths = direct_codex_game_outputs(root) + .into_iter() + .map(|(relative_path, _, _)| relative_path) + .chain(direct_npm_source_paths(root)) + .collect::>(); + for relative_path in source_paths { + let path = root.join(&relative_path); + if !path.exists() { + continue; + } + if !path.is_file() { + return Err(format!("读取游戏源码失败:{} 不是文件", relative_path)); + } + std::fs::read_to_string(&path) + .map_err(|error| format!("读取游戏源码 {} 失败:{error}", relative_path))?; + } + if !root.join(&entry).is_file() { + return Ok(()); + } + let manifest = + read_manifest_for_project(root).map_err(|error| format!("读取项目清单失败:{error}"))?; + for asset in manifest.assets.iter().filter(|asset| { + asset.media_type.starts_with("image/") + && asset.source.kind == GameCreationAppAssetSourceKind::Canvas + // 完整图集可以合法地没有切片文件;切片是可选的运行时投影,不能让它的 + // 缺失覆盖真正的 spritesheet 完整性判断。 + && !asset.local_path.starts_with("assets/art-spritesheet-slices/") + }) { + let Some(relative_path) = direct_normalized_project_asset_path(&asset.local_path) else { + continue; + }; + let path = root.join(&relative_path); + let bytes = std::fs::read(&path) + .map_err(|error| format!("读取已登记平台素材 {} 失败:{error}", relative_path))?; + validate_platform_art_png_bytes_with_limits(&bytes, &format!("平台素材 {relative_path}")) + .map_err(|error| format!("校验已登记平台素材 {} 失败:{error}", relative_path))?; + } + Ok(()) +} + fn direct_registered_taonier_runtime_image_paths(root: &Path) -> Vec { let Ok(manifest) = read_manifest_for_project(root) else { return Vec::new(); @@ -2667,6 +2722,9 @@ fn direct_game_output_completion_error(root: &Path) -> Option { if !root.join(entry).is_file() { return Some(format!("Codex 返回后未找到 {entry},项目未进入可运行状态")); } + if let Err(error) = validate_direct_completion_inputs(root) { + return Some(format!("完成判定读取项目文件失败:{error}")); + } if !direct_game_sources_reference_taonier_art_package(root) { return Some( "游戏代码已生成,但未在源码中引用任何已登记的陶泥儿平台图片;不会将项目标记为完成" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index eee83fb55..a7fa9eb0c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -110,18 +110,27 @@ async fn direct_tools_mcp_specs() -> Value { feature = "godot-editor-execute" )) { - // 每次 tools/list 询问绑定的宿主;失败时不广告可选插件工具。 - if let Ok(result) = tokio::time::timeout( + // 每次 tools/list 询问绑定的宿主;失败时不广告可选插件工具,但必须把桥失败的 + // HTTP/IPC/解析正文留在应用日志,不能静默变成“工具不存在”。 + match tokio::time::timeout( std::time::Duration::from_secs(5), call_client_tool_bridge("builtin.plugins.tools", &json!({})), ) .await { - if result["isError"] == false { + Ok(result) if result["isError"] == false => { let availability = result .pointer("/content/0/text") .and_then(Value::as_str) - .and_then(|text| serde_json::from_str::(text).ok()); + .and_then(|text| match serde_json::from_str::(text) { + Ok(value) => Some(value), + Err(error) => { + app_log!( + "agent.direct_tools_mcp.plugin_capability_parse_failed detail={error}" + ); + None + } + }); cocos_editor_available = availability .as_ref() .and_then(|v| v["tools"].as_array()) @@ -147,6 +156,13 @@ async fn direct_tools_mcp_specs() -> Value { .any(|tool| tool == crate::builtin_plugins::AGC_GODOT_EDITOR_TOOL_NAME) }); } + Ok(result) => app_log!( + "agent.direct_tools_mcp.plugin_capability_bridge_failed detail={}", + result + ), + Err(error) => app_log!( + "agent.direct_tools_mcp.plugin_capability_bridge_timeout timeoutSeconds=5 detail={error}" + ), } } direct_tools_mcp_specs_for_plugins( @@ -1132,22 +1148,18 @@ fn external_mcp_session_id(root: &Path) -> String { format!("mcp-{:x}", Sha256::digest(material.as_bytes())) } -fn external_mcp_project_id(root: &Path) -> String { - std::fs::read(root.join(".agent/manifest.json")) - .ok() - .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) - .and_then(|value| { - value - .get("projectId") - .and_then(Value::as_str) - .map(str::to_string) - }) - .unwrap_or_else(|| { - format!( - "project-{:x}", - Sha256::digest(root.to_string_lossy().as_bytes()) - ) - }) +fn external_mcp_project_id(root: &Path) -> Result { + let path = root.join(".agent/manifest.json"); + let bytes = std::fs::read(&path).map_err(|error| format!("读取 MCP 项目清单失败:{error}"))?; + let value: Value = serde_json::from_slice(&bytes) + .map_err(|error| format!("解析 MCP 项目清单失败:{error}"))?; + value + .get("projectId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .ok_or_else(|| "MCP 项目清单缺少有效 projectId".to_string()) } fn external_mcp_account_id() -> String { @@ -1263,18 +1275,28 @@ fn external_mcp_record_response(root: &Path, arguments: &Value) -> Value { ); } } + let received_at = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) { + Ok(duration) => duration.as_millis() as u64, + Err(error) => { + return mcp_tool_result( + format!("读取 Codex 返回记录时间失败:{error}"), + Vec::new(), + true, + ) + } + }; let record = json!({ "recordId": uuid::Uuid::new_v4().to_string(), "recordType": "codex.response", "accountId": external_mcp_account_id(), - "projectId": external_mcp_project_id(root), + "projectId": match external_mcp_project_id(root) { + Ok(project_id) => project_id, + Err(error) => return mcp_tool_result(error, Vec::new(), true), + }, "sessionId": external_mcp_session_id(root), "requestId": request_id, "sequence": sequence, - "receivedAt": std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_millis() as u64) - .unwrap_or_default(), + "receivedAt": received_at, "content": redacted, "contentSha256": format!("{:x}", Sha256::digest(redacted.as_bytes())), "summary": external_mcp_response_summary(&redacted), @@ -1291,9 +1313,17 @@ fn external_mcp_record_response(root: &Path, arguments: &Value) -> Value { ) } }; - let current_size = std::fs::metadata(&path) - .map(|metadata| metadata.len()) - .unwrap_or(0); + let current_size = match std::fs::metadata(&path) { + Ok(metadata) => metadata.len(), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => 0, + Err(error) => { + return mcp_tool_result( + format!("读取 Codex 返回记录大小失败:{error}"), + Vec::new(), + true, + ) + } + }; if current_size.saturating_add(line.len() as u64 + 1) > EXTERNAL_MCP_JOURNAL_MAX_BYTES { return mcp_tool_result( "Codex 返回记录达到客户端保留上限".to_string(), @@ -1331,15 +1361,10 @@ fn external_mcp_record_response(root: &Path, arguments: &Value) -> Value { } fn external_mcp_session_info(root: &Path) -> Value { - let manifest = std::fs::read(root.join(".agent/manifest.json")) - .ok() - .and_then(|bytes| serde_json::from_slice::(&bytes).ok()); - let project_id = manifest - .as_ref() - .and_then(|value| value.get("projectId")) - .and_then(Value::as_str) - .unwrap_or("unknown") - .to_string(); + let project_id = match external_mcp_project_id(root) { + Ok(project_id) => project_id, + Err(error) => return mcp_tool_result(error, Vec::new(), true), + }; mcp_tool_result( json!({ "status": "bound", @@ -1908,7 +1933,9 @@ async fn start_external_mcp_loopback_with_mode( .layer(DefaultBodyLimit::max(DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES)) .with_state(state); let task = tokio::spawn(async move { - let _ = axum::serve(listener, app).await; + if let Err(error) = axum::serve(listener, app).await { + app_log!("agent.direct_tools_mcp.server_failed detail={error}"); + } }); let url = format!("http://127.0.0.1:{}{route}", address.port()); let registry = @@ -1930,21 +1957,33 @@ async fn start_external_mcp_loopback_with_mode( pub(crate) fn stop_external_mcp_loopback() { if let Some(registry) = EXTERNAL_MCP_SERVER.get() { - if let Ok(mut guard) = registry.lock() { - guard.clear(); + match registry.lock() { + Ok(mut guard) => guard.clear(), + Err(_) => app_log!( + "agent.direct_tools_mcp.registry_unavailable action=stop_all detail=mutex-poisoned" + ), } } } pub(crate) fn stop_external_mcp_loopback_for_root(root: &Path, token: &str) { - let Ok(root) = root.canonicalize() else { - return; + let root = match root.canonicalize() { + Ok(root) => root, + Err(error) => { + app_log!("agent.direct_tools_mcp.stop_root_canonicalize_failed detail={error}"); + return; + } }; if let Some(registry) = EXTERNAL_MCP_SERVER.get() { - if let Ok(mut guard) = registry.lock() { - if guard.get(&root).is_some_and(|server| server.token == token) { - guard.remove(&root); + match registry.lock() { + Ok(mut guard) => { + if guard.get(&root).is_some_and(|server| server.token == token) { + guard.remove(&root); + } } + Err(_) => app_log!( + "agent.direct_tools_mcp.registry_unavailable action=stop_root detail=mutex-poisoned" + ), } } } @@ -3464,6 +3503,37 @@ mod tests { assert!(error.contains("Codex 返回记录格式损坏"), "{error}"); } + #[test] + fn external_codex_response_does_not_hide_corrupt_project_manifest() { + let temporary = crate::tests::canonical_test_tempdir("direct-tools-manifest-error-"); + let root = temporary.path(); + init_local_game_project_at(root, "direct-tools-manifest-error", "MCP 清单错误测试") + .expect("init project"); + std::fs::write(root.join(".agent/manifest.json"), b"{broken-json\n") + .expect("corrupt manifest"); + + let record = external_mcp_record_response( + root, + &json!({ + "requestId": "req-corrupt-manifest", + "sequence": 0, + "content": "这条记录不应伪造 projectId" + }), + ); + assert_eq!(record["isError"], true, "{record}"); + assert!( + record.to_string().contains("解析 MCP 项目清单失败"), + "{record}" + ); + + let session = external_mcp_session_info(root); + assert_eq!(session["isError"], true, "{session}"); + assert!( + session.to_string().contains("解析 MCP 项目清单失败"), + "{session}" + ); + } + #[test] fn recorded_codex_response_only_writes_its_own_journal() { let temporary = crate::tests::canonical_test_tempdir("direct-tools-record-response-"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs index 9e34a3643..16b064817 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs @@ -90,7 +90,10 @@ impl Drop for TurnReservation { fn drop(&mut self) { // 兜底:任务 panic、future 被丢弃、或今后在终态之前新增的 `?` 早退。 // 这里至少把 Drop 当下能观测到的收场事实带进失败载荷,不能只给一个空分类。 - let panic_detail = self.panic_detail.lock().ok().and_then(|slot| slot.clone()); + let panic_detail = match self.panic_detail.lock() { + Ok(slot) => slot.clone(), + Err(_) => Some("读取 DirectProject 宿主 panic 详情失败:panic 上下文锁已中毒".into()), + }; let host_drop_detail = panic_detail.as_deref().unwrap_or(if std::thread::panicking() { "DirectProject 宿主任务 panic,未能写下终态;具体 panic 负载请查看应用日志" } else { diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs index f8d02848c..19ca0894e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs @@ -244,13 +244,19 @@ impl WindowsProcessJob { }; let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; if process.is_null() { - return Err("无法打开进程核对 Windows Job 归属".into()); + return Err(format!( + "无法打开进程核对 Windows Job 归属:{}", + std::io::Error::last_os_error() + )); } let mut in_job = 0; let okay = unsafe { IsProcessInJob(process, self.0, &mut in_job) }; unsafe { CloseHandle(process) }; if okay == 0 { - return Err("无法核对进程 Windows Job 归属".into()); + return Err(format!( + "无法核对进程 Windows Job 归属:{}", + std::io::Error::last_os_error() + )); } Ok(in_job != 0) } @@ -263,12 +269,18 @@ impl WindowsProcessJob { }; let process = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) }; if process.is_null() { - return Err("无法打开进程加入 Windows Job".into()); + return Err(format!( + "无法打开进程加入 Windows Job:{}", + std::io::Error::last_os_error() + )); } let okay = unsafe { AssignProcessToJobObject(self.0, process) }; unsafe { CloseHandle(process) }; if okay == 0 { - return Err("无法将进程加入 Windows Job".into()); + return Err(format!( + "无法将进程加入 Windows Job:{}", + std::io::Error::last_os_error() + )); } Ok(()) } @@ -309,7 +321,10 @@ impl WindowsProcessJob { } let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) }; if snapshot == INVALID_HANDLE_VALUE { - return Err("读取受控命令主线程失败".into()); + return Err(format!( + "读取受控命令主线程失败:{}", + std::io::Error::last_os_error() + )); } let mut entry = THREADENTRY32::default(); entry.dwSize = std::mem::size_of::() as u32; @@ -337,7 +352,10 @@ impl WindowsProcessJob { ) }; if thread.is_null() { - return Err("打开受控命令主线程失败".into()); + return Err(format!( + "打开受控命令主线程失败:{}", + std::io::Error::last_os_error() + )); } let belongs = unsafe { GetProcessIdOfThread(thread) } == pid; let previous = if belongs { @@ -347,7 +365,10 @@ impl WindowsProcessJob { }; unsafe { CloseHandle(thread) }; if previous != 1 { - return Err("受控命令恢复未确认,不能无门执行".into()); + return Err(format!( + "受控命令恢复未确认,不能无门执行:previousResumeCount={previous};{}", + std::io::Error::last_os_error() + )); } Ok(()) } @@ -404,7 +425,10 @@ impl WindowsProcessJob { ) }; if okay == 0 { - return Err("无法确认 Windows Job 子树已退出".into()); + return Err(format!( + "无法确认 Windows Job 子树已退出:{}", + std::io::Error::last_os_error() + )); } Ok(information.ActiveProcesses == 0) } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts index 1bb0c20d9..ced9fe911 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts @@ -366,12 +366,17 @@ export function reduceDirectThreadEvent( return state; } // 失败终态带 `failure` 载荷:先把它落成本轮最后一条说明条目,再和正常终态走同一个收口 - // 函数。载荷在才算一条说明(文案由 typed 变体拼,必非空);缺载荷不补一条空气泡。 + // 函数。跨 IPC 的坏事件可能丢掉载荷;这种协议错误也必须可见,不能悄悄收口成一轮 + // 没有任何解释的失败。 const failure = event.failure; // 载荷在生成类型里是必填的,但跨 IPC 没有运行时校验:缺字段 / `null` 时当"没有原因"处理, // 不在这里抛错——一条坏事件不该把这一条订阅之后的全部事件一起打断。文案只从 typed 变体拼, // 宿主原文(`detail` / `cause` / `diagnostic`)不上屏。 - const failureText = failure ? directTurnFailureNoticeText(failure) : ''; + const failureText = failure + ? directTurnFailureNoticeText(failure) + : event.status === 'failed' + ? '陶泥儿智能创作:失败终态缺少错误载荷,本轮未完成;请检查项目诊断后重试' + : ''; const noticeItemId = directTurnFailureItemId(eventUserItemId, eventAt); const noticeOf = (): DirectChatEntry => ({ itemId: noticeItemId, diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts index 69c3b5b5f..fb6a2d907 100644 --- a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -571,7 +571,7 @@ describe('DirectProject 聊天 reducer', () => { expect(untouched.live).toHaveLength(0); }); - it('没有失败载荷时不落说明条目,但终态照样收口', () => { + it('失败载荷缺失时也落协议错误说明,不能静默收口', () => { const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ withUserItemId( event({ type: 'turn.started', at: 1_000 }), @@ -584,7 +584,9 @@ describe('DirectProject 聊天 reducer', () => { ]); expect(failed.turnRunning).toBe(false); expect(failed.turnEndedAt).toBe(2_000); - expect(failed.history).toHaveLength(0); + expect(failed.history.at(-1)?.text).toBe( + '陶泥儿智能创作:失败终态缺少错误载荷,本轮未完成;请检查项目诊断后重试', + ); }); it('未知失败变体不抛错,并保留精确脱敏 detail 收口', () => { diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 444162046..e1083b65f 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,13 @@ 这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。 +## 2026-10-06 AGC 失败终态与 CC/MCP 边界不能吞掉协议、清理和 I/O 错误 + +- **现象**:DirectProject 的 `turn.completed(status=failed)` 若跨 IPC 丢失 `failure` 载荷,前端会只收口回合而不显示任何说明;Claude Code sidecar 的非 UTF-8 stderr、进程树清理失败、会话表锁损坏,以及 MCP loopback 服务退出、插件能力桥超时、损坏 manifest,会分别被压成空 detail、继续成功取消、伪造 hash projectId 或静默不广告插件。 +- **现行口径**:失败载荷缺失显示明确的协议错误;CC 清理、stderr 解码、会话锁都保留原始正文;MCP server / capability bridge 失败写入统一应用日志,MCP manifest / journal / 时间 / metadata 读取失败返回具体错误;完成判定在把素材缺失归因于“未引用平台图”前先验证源码、manifest 和已登记图片的 I/O/PNG 错误。切片文件是可选投影,完整 spritesheet 不因无切片而误失败。 +- **验证**:`cargo test agent::claude_code_cli::tests::` 14 passed;`cargo test agent::direct_tools_mcp::tests::` 34 passed;`cargo test agent::direct_runtime::tests::` 92 passed;`npx vitest run tests/directThreadChat.test.ts tests/directTurnFailure.test.ts` 64 passed;另有损坏 manifest 回归用例 1 passed。新失败正文仍经既有脱敏边界处理。 +- **关联**:`agent/claude_code_cli.rs`、`agent/direct_tools_mcp.rs`、`agent/direct_runtime/mod.rs`、`agent/thread_manager/dispatch.rs`、`chat/conversation/directThreadChat.ts`。 + ## 2026-10-03 AGC 随包 plugins 的 feature 档位必须与消费方一致,且门禁会因 build.rs 未重跑而假通过 - **现象**:Windows 本机 `npm run check:generated-bindings`(`npm run lint` 链内,`scripts/check-repository-ci.sh` 的 Repository checks 也走它)在 `build.rs:167:29` panic:`插件随包资源校验失败:随包插件存在未声明文件:.../src-tauri/resources/plugins/agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll(目标 x86_64-pc-windows-msvc 与当前 feature 组合不允许;请先执行随包资源准备步骤)`;树上换成 `agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe` 时报同一类错。反向还有更隐蔽的形态:门禁 2 秒就 exit 0 说「通过」,但 tree 上其实带着编辑器产物。