diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog.rs index 68adf5720..a718f0d3d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog.rs @@ -105,7 +105,7 @@ async fn read_bounded(mut stream: impl AsyncRead + Unpin, limit: usize) -> Resul let count = stream .read(&mut buffer) .await - .map_err(|_| "model-catalog-read-failed")?; + .map_err(|error| format!("model-catalog-read-failed: {error}"))?; if count == 0 { return Ok(output); } @@ -132,31 +132,46 @@ async fn export_catalog( if cancel.is_some_and(|flag| flag.load(Ordering::Acquire)) { return Err("model-catalog-cancelled".into()); } - let mut child = command.spawn().map_err(|_| "model-catalog-spawn-failed")?; + let mut child = command + .spawn() + .map_err(|error| format!("model-catalog-spawn-failed: {error}"))?; let tree = match OwnedProcessTree::attach(&child) { Ok(tree) => tree, - Err(_) => { + Err(error) => { let _ = child.start_kill(); let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await; - return Err("model-catalog-process-owner-unavailable".into()); + return Err(format!("model-catalog-process-owner-unavailable: {error}")); } }; let result = if let (Some(stdout), Some(stderr)) = (child.stdout.take(), child.stderr.take()) { let collected = async { - let (stdout, _, status) = tokio::try_join!( + let (stdout, stderr, status) = tokio::try_join!( read_bounded(stdout, MAX_CATALOG_BYTES), read_bounded(stderr, 64 * 1024), async { child .wait() .await - .map_err(|_| "model-catalog-wait-failed".to_string()) + .map_err(|error| format!("model-catalog-wait-failed: {error}")) }, )?; if !status.success() { - return Err("model-catalog-export-failed".into()); + let detail = String::from_utf8_lossy(&stderr).trim().to_string(); + let detail = crate::sanitize_diagnostic_message( + &detail, + Some(Path::new("__agc_no_project_root__")), + ); + return Err(if detail.is_empty() { + format!("model-catalog-export-failed: exitStatus={status}") + } else { + format!( + "model-catalog-export-failed: exitStatus={status}; stderr={}", + detail.chars().take(1200).collect::() + ) + }); } - serde_json::from_slice(&stdout).map_err(|_| "model-catalog-json-invalid".into()) + serde_json::from_slice(&stdout) + .map_err(|error| format!("model-catalog-json-invalid: {error}")) }; tokio::select! { biased; diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs index 712c41fbd..bcce384f0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs @@ -111,38 +111,47 @@ fn parse_devtools_ws_url(line: &str) -> Option { Some(ws.to_string()) } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] enum OwnedBrowserLaunchError { - SpawnFailed, + SpawnFailed(String), WsTimeout, - WsFailed, + WsFailed(String), ConnectTimeout, - ConnectFailed, + ConnectFailed(String), } impl OwnedBrowserLaunchError { - fn stage(self) -> BrowserLaunchStage { + fn stage(&self) -> BrowserLaunchStage { match self { - Self::SpawnFailed => BrowserLaunchStage::Spawn, - Self::WsTimeout | Self::WsFailed => BrowserLaunchStage::WsHandshake, - Self::ConnectTimeout | Self::ConnectFailed => BrowserLaunchStage::CdpConnect, + Self::SpawnFailed(_) => BrowserLaunchStage::Spawn, + Self::WsTimeout | Self::WsFailed(_) => BrowserLaunchStage::WsHandshake, + Self::ConnectTimeout | Self::ConnectFailed(_) => BrowserLaunchStage::CdpConnect, } } - fn is_recoverable(self) -> bool { + fn is_recoverable(&self) -> bool { matches!( self, - Self::WsTimeout | Self::WsFailed | Self::ConnectTimeout | Self::ConnectFailed + Self::WsTimeout | Self::WsFailed(_) | Self::ConnectTimeout | Self::ConnectFailed(_) ) } - fn code(self) -> &'static str { + fn code(&self) -> &'static str { match self { - Self::SpawnFailed => "browser-spawn-failed", + Self::SpawnFailed(_) => "browser-spawn-failed", Self::WsTimeout => "browser-ws-timeout", - Self::WsFailed => "browser-ws-failed", + Self::WsFailed(_) => "browser-ws-failed", Self::ConnectTimeout => "browser-cdp-connect-timeout", - Self::ConnectFailed => "browser-cdp-connect-failed", + Self::ConnectFailed(_) => "browser-cdp-connect-failed", + } + } + + fn detail(&self) -> Option<&str> { + match self { + Self::SpawnFailed(detail) | Self::WsFailed(detail) | Self::ConnectFailed(detail) => { + Some(detail) + } + Self::WsTimeout | Self::ConnectTimeout => None, } } } @@ -166,13 +175,14 @@ impl BrowserLaunchStage { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] struct BrowserLaunchFailure { code: &'static str, stage: BrowserLaunchStage, recoverable: bool, subprocess_exited: bool, cleanup_confirmed: bool, + detail: Option, } impl BrowserLaunchFailure { @@ -183,6 +193,7 @@ impl BrowserLaunchFailure { recoverable: false, subprocess_exited: true, cleanup_confirmed: true, + detail: None, } } @@ -197,11 +208,17 @@ impl BrowserLaunchFailure { recoverable: error.is_recoverable(), subprocess_exited, cleanup_confirmed, + detail: error.detail().map(|detail| { + crate::sanitize_diagnostic_message(detail, None) + .chars() + .take(1200) + .collect() + }), } } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] struct BrowserRecoveryFailure { first: BrowserLaunchFailure, final_attempt: Option, @@ -211,16 +228,26 @@ impl BrowserRecoveryFailure { fn diagnostic(self) -> String { match self.final_attempt { Some(final_attempt) => format!( - "browser-recovery-failed: initial-stage={} final-stage={} subprocess-exited={} cleanup-confirmed={} recovery-retried=true initial-cause={} final-cause={}", + "browser-recovery-failed: initial-stage={} final-stage={} subprocess-exited={} cleanup-confirmed={} recovery-retried=true initial-cause={} final-cause={}{}{}", self.first.stage.as_str(), final_attempt.stage.as_str(), final_attempt.subprocess_exited, final_attempt.cleanup_confirmed, self.first.code, final_attempt.code, + self.first + .detail + .as_deref() + .map(|detail| format!(" initial-detail={detail}")) + .unwrap_or_default(), + final_attempt + .detail + .as_deref() + .map(|detail| format!(" final-detail={detail}")) + .unwrap_or_default(), ), None => format!( - "{}: stage={} subprocess-exited={} cleanup-confirmed={} recovery-retried=false cause={}", + "{}: stage={} subprocess-exited={} cleanup-confirmed={} recovery-retried=false cause={}{}", if self.first.recoverable { "browser-recovery-blocked" } else { @@ -230,6 +257,11 @@ impl BrowserRecoveryFailure { self.first.subprocess_exited, self.first.cleanup_confirmed, self.first.code, + self.first + .detail + .as_deref() + .map(|detail| format!(" detail={detail}")) + .unwrap_or_default(), ), } } @@ -271,7 +303,7 @@ async fn devtools_ws_url_from_stderr( let stderr = child .stderr .take() - .ok_or(OwnedBrowserLaunchError::SpawnFailed)?; + .ok_or_else(|| OwnedBrowserLaunchError::SpawnFailed("browser stderr 未建立".into()))?; let mut reader = futures::io::BufReader::new(stderr); let mut captured: Vec = Vec::new(); let mut exited = Box::pin(child.wait()).fuse(); @@ -279,18 +311,18 @@ async fn devtools_ws_url_from_stderr( loop { let mut line = Vec::new(); futures::select! { - _status = exited => return Err(OwnedBrowserLaunchError::WsFailed), + _status = exited => return Err(OwnedBrowserLaunchError::WsFailed(safe_browser_detail(&captured))), result = reader.read_until(b'\n', &mut line).fuse() => { - let count = result.map_err(|_| OwnedBrowserLaunchError::WsFailed)?; + let count = result.map_err(|error| OwnedBrowserLaunchError::WsFailed(format!("读取 browser stderr 失败:{error};{}", safe_browser_detail(&captured))))?; if count == 0 { - return Err(OwnedBrowserLaunchError::WsFailed); + return Err(OwnedBrowserLaunchError::WsFailed(safe_browser_detail(&captured))); } captured.extend_from_slice(&line); if captured.len() > MAX_LAUNCH_STDERR_BYTES { - return Err(OwnedBrowserLaunchError::WsFailed); + return Err(OwnedBrowserLaunchError::WsFailed(format!("browser stderr 超过大小上限;{}", safe_browser_detail(&captured)))); } let Ok(text) = std::str::from_utf8(&line) else { - return Err(OwnedBrowserLaunchError::WsFailed); + return Err(OwnedBrowserLaunchError::WsFailed(format!("browser stderr 不是 UTF-8;{}", safe_browser_detail(&captured)))); }; if let Some(url) = parse_devtools_ws_url(text) { return Ok(url); @@ -306,6 +338,17 @@ async fn devtools_ws_url_from_stderr( } } +fn safe_browser_detail(bytes: &[u8]) -> String { + let text = String::from_utf8_lossy(bytes).trim().to_string(); + if text.is_empty() { + return "未收到 browser stderr 具体正文".to_string(); + } + crate::sanitize_diagnostic_message(&text, None) + .chars() + .take(1200) + .collect() +} + fn spawn_stderr_drain(mut reader: BrowserStderrReader) -> tokio::task::JoinHandle<()> { // 持续排空 stderr:浏览器日志写满管道会整体 stall。内容有界、不留存。 tokio::spawn(async move { @@ -484,9 +527,9 @@ async fn launch_owned_browser( ) -> Result { let child = match config.launch() { Ok(child) => child, - Err(_) => { + Err(error) => { return Err(BrowserLaunchFailure::from_launch_error( - OwnedBrowserLaunchError::SpawnFailed, + OwnedBrowserLaunchError::SpawnFailed(error.to_string()), true, true, )); @@ -511,7 +554,9 @@ async fn launch_owned_browser( return Err(cleanup_failed_launch( process, temp, - OwnedBrowserLaunchError::SpawnFailed, + OwnedBrowserLaunchError::SpawnFailed( + "browser root identity unavailable".into(), + ), false, ) .await); @@ -528,13 +573,15 @@ async fn launch_owned_browser( return Err(cleanup_failed_launch( process, temp, - OwnedBrowserLaunchError::SpawnFailed, + OwnedBrowserLaunchError::SpawnFailed( + "browser process tree not covered by Job".into(), + ), tree_reaped, ) .await); } } - Err(_) => { + Err(error) => { let tree_reaped = match (child.inner.id(), root_identity.as_deref()) { (Some(root_pid), Some(root_identity)) => { super::sweep::kill_browser_process_tree(root_pid, root_identity).is_ok() @@ -545,7 +592,7 @@ async fn launch_owned_browser( return Err(cleanup_failed_launch( process, temp, - OwnedBrowserLaunchError::SpawnFailed, + OwnedBrowserLaunchError::SpawnFailed(format!("创建 browser Job 失败:{error}")), tree_reaped, ) .await); @@ -575,12 +622,12 @@ async fn launch_owned_browser( .await; let (browser, mut handler) = match connected { Ok(Ok(pair)) => pair, - Ok(Err(_)) => { + Ok(Err(error)) => { drain_task.abort(); return Err(cleanup_failed_launch( process, temp, - OwnedBrowserLaunchError::ConnectFailed, + OwnedBrowserLaunchError::ConnectFailed(error.to_string()), true, ) .await); @@ -828,7 +875,7 @@ mod health_tests { if attempt == 0 { drop(temporary); Err(BrowserLaunchFailure::from_launch_error( - OwnedBrowserLaunchError::WsFailed, + OwnedBrowserLaunchError::WsFailed("fixture ws failure".into()), true, true, )) @@ -876,7 +923,7 @@ mod health_tests { async fn consecutive_browser_failures_keep_both_recovery_stages_and_safe_diagnostics() { let failure = launch_with_one_recovery(|| async { Err::<(), BrowserLaunchFailure>(BrowserLaunchFailure::from_launch_error( - OwnedBrowserLaunchError::WsFailed, + OwnedBrowserLaunchError::WsFailed("fixture ws failure".into()), true, true, )) @@ -889,6 +936,7 @@ mod health_tests { assert!(diagnostic.contains("subprocess-exited=true")); assert!(diagnostic.contains("cleanup-confirmed=true")); assert!(diagnostic.contains("recovery-retried=true")); + assert!(diagnostic.contains("fixture ws failure")); assert!(!diagnostic.contains("/tmp")); } diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index bdd038d4a..50a3ff361 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -109,6 +109,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - Claude Code(cc)侧车的非零退出、RPC `error`、stdout JSON/UTF-8 解析失败、stderr 和静默超时也走同一映射;侧车 stderr 只在有界收口窗口内读取并进入同一脱敏 detail,不再只写裸 `eprintln!`。 - Codex CLI / Claude sidecar 的 JSONL/JSON 解析失败、失败终态、缺失终态、超时和空回执都保留解析错误、失败事件正文与有界安全片段;`LlmError` 没有 detail 字段的超时/空回执由 Direct `ModelCallFailed.detail` 补回,不再把这些协议事实统一压成单一类别。 - 账户、模型目录、External Editor/资源编辑、发布、素材上传、错误报告与客户端受控工具桥共用同一原则:网络错误保留底层因链,JSON/协议解析保留 serde 原因,HTTP 错误保留状态码与安全正文;只有响应完全没有正文时才说明“未提供 error/code/message”,不得退成“无法连接/格式无效/服务器未返回错误信息”。 +- 浏览器启动/DevTools 握手与 Codex model-catalog 子进程失败保留 stderr、退出状态、解析错误和阶段;稳定机器码仍用于分类,但不能单独成为用户可见正文。 - HTTP 409 只有明确包含泥点不足事实时才映射为 `paidCreditsInsufficient`;Claude Code 的普通 409 冲突保留为 `upstreamFailed`。 - `hostDropped` 携带可选的脱敏 `detail`:panic hook 能取得的负载和位置随原回合占用进入失败事件;普通 Drop 仅记录“退出时未写终态、未观察到 panic”,不推断为网络中断。旧版本无 `detail` 的事件继续可读,界面明确标注旧事件未记录原因。 - DirectProject 控制器的取消待发、终止、附件上传、项目历史读取和发送前置异常也复用同一份前端精确脱敏出口;这些非回合错误不得直接把 `Error.message` 原文写入状态栏、composer 或历史错误行。 diff --git a/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md index aa68e85f3..3bc04036e 100644 --- a/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md @@ -19,6 +19,7 @@ Parent Milestone: `【里程碑】AGC错误具体文本展示-2026-10-04.md` 10. 资产导入、邀请码、策划工作区、发布封面/截图和素材命令的 UI 错误沿用同一共享出口。 11. Codex CLI / Claude sidecar 的解析失败、失败终态、缺失终态、超时和空回执保留有界脱敏正文;即使平台 `LlmError` 变体没有 detail 字段,也由统一 Direct `ModelCallFailed.detail` 继续携带原始原因。 12. 扩展统一正文口径到账户、模型目录、External Editor/资源编辑、发布、素材上传、错误报告与客户端受控工具桥,保留 HTTP 状态、响应安全片段、JSON 解析原因、网络因链和 IPC/桥接原因。 +13. 浏览器启动/DevTools 握手与 Codex model-catalog 子进程错误保留 stderr、退出状态、解析原因和阶段信息,机器码只作为分类字段。 ## 实现顺序